Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ jobs:
test-pmctl-decision.sh
test-pmctl-worktree.sh
test-lint-test-docstrings.sh
- name: Host-owned shellcheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # tag 2.0.0
with:
scandir: ./hosts

test-doctor:
runs-on: ubuntu-latest
Expand Down
112 changes: 112 additions & 0 deletions BACKLOG.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Versions follow [Semantic Versioning](https://semver.org/).

### Added

- **Project-scoped canonical memory config (CC-490).** Replaces the unsafe machine-wide `dispatch.memory_dir` selector with `memory.projects.<stable-project-key>.dir`. `pmctl memory config set|migrate|lint` manages mappings atomically, diagnoses deprecated global config, and keeps unmatched repositories on their own legacy discovery or unavailable path. Strict resolution fails closed for invalid matched entries and legacy-global config; regression coverage proves an unmatched append cannot mutate another project's canonical store.

- **Memory substrate cross-tool location seam (CC-412, PR#352).** `find_memory_dir` now honors an explicit override with precedence `PM_MEMORY_DIR` env > `dispatch.memory_dir` config > `CLAUDE_CONFIG_DIR` convention — behavior is byte-identical to before when neither override is set. The injection layering is now documented in `docs/memory-system.md`: the portable core is the `pmctl context --source memory` retrieval API; injection is a per-tool adapter concern (Claude keeps its existing hook; codex/opencode/future hosts call the retrieval API directly).

- **Gate detached lifecycle (CC-423, PR#353).** `pmctl gate run --lifecycle detached` (now the default) returns a `gate_id` immediately and runs `pr-gate.sh` under a `setsid`/`nohup` gate-supervisor, mirroring the existing dispatch detached mode. `pmctl gate wait <gate_id>` reattaches via a nonce-authenticated sentinel and fails closed on result-integrity violations. A session interrupt can no longer kill a running gate or corrupt its exit-code reporting.
Expand Down
6 changes: 6 additions & 0 deletions cli/pmctl
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,12 @@ case "$cmd/$sub" in
fi
pmctl_memory_resolve "$@"
;;
memory/config)
if ! declare -F pmctl_memory_config >/dev/null; then
pmctl_die "memory config unavailable"
fi
pmctl_memory_config "$@"
;;
memory/append-episode)
if ! declare -F pmctl_memory_append_episode >/dev/null; then
pmctl_die "memory append-episode unavailable"
Expand Down
4 changes: 4 additions & 0 deletions docs/host-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ the tracker's data location is a separate state-store concern.
| `doctor_module` | yes | Repo-relative path to the sourceable doctor host module; must exist. |
| `install_module` | no (v1 compatibility) | Repo-relative path to an opt-in host wiring module, or `null`/omitted when the host is installed by the legacy base path or has no independent write path yet. Generic dispatchers use this field instead of naming hosts in core. New authored manifests should include it explicitly. |
| `uninstall_module` | yes | Repo-relative path to the matching opt-in teardown module, or `null` when no independently dispatched write path exists. |
| `memory_update_module` | no | Repo-relative host-owned executable for explicit natural-language project-memory updates. It must delegate location and writes to the canonical `pmctl memory append-episode` API and must not accept a host-private memory path. |

### `install_targets` entries

Expand All @@ -86,6 +87,9 @@ Closed enum; adding a value is a schema revision, not a per-host improvisation:
shape as Claude Code `settings.json` (probed: codex reads this file, not a
`config.toml` section).
- `codex-config-toml` — codex `config.toml` (approval/sandbox policy).
- `codex-agents-md` — marker-delimited global guidance in
`$CODEX_HOME/AGENTS.md`; installers own only their marked block and must
preserve all foreign content byte-for-byte outside that block.
- `opencode-config-json` — `opencode.json` declarative permission config.
- `markdown-managed-block` — owned marker-delimited block inside a Markdown file.
- `symlink-tree` — directory wired as symlinks back into the repo.
Expand Down
56 changes: 48 additions & 8 deletions docs/memory-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,30 @@ explicit override, checked in this order:

1. `PM_MEMORY_DIR` environment variable (highest priority; works everywhere,
including the Claude Code hook path, at zero extra cost)
2. `dispatch.memory_dir` in `~/.pm-dispatch/config` (checked by CLI-driven
callers — `pmctl memory *`, `pmctl context --source memory`,
installer/migrator scripts — not by the hook path, to avoid adding a
config-file read to every Claude Code turn)
2. the matching `memory.projects.<project_key>.dir` entry in
`~/.pm-dispatch/config` (checked by CLI-driven callers — `pmctl memory *`,
`pmctl context --source memory`, installer/migrator scripts — not by the
low-latency `memory.sh` hook path itself)
3. the `CLAUDE_CONFIG_DIR/projects/<id>/memory/` convention (unchanged
default when neither override is set)

The deprecated `dispatch.memory_dir` global key is unsafe in a multi-repo
environment. Strict resolution returns `invalid-explicit` with
`resolution_source: config-legacy-global`; compatibility discovery ignores the
value and warns. Migrate it atomically for one repository with:

```bash
pmctl memory config migrate --repo-root /absolute/repo --json
pmctl memory config lint --json
```

For a new or replacement mapping, use `pmctl memory config set --repo-root
/absolute/repo --memory-dir /absolute/memory`. Both commands preserve unrelated
config lines; `set` removes a remaining unsafe global key. Repeating `migrate`
after success is a no-op. Tests must still isolate operator config with
`PM_DISPATCH_CONFIG_FILE`; they must never assume `~/.pm-dispatch/config` is
absent.

An override is only honored when the target directory already exists; an
unset or nonexistent override falls through to the next tier, so existing
installs see byte-identical resolution with no environment changes.
Expand All @@ -200,8 +217,9 @@ pmctl memory resolve --repo-root "$(pwd)" --json
```

The result identifies the canonical repo, stable project key, selected memory
directory, and resolution source (`env`, `config`, `legacy`, or `none`). An
explicit `PM_MEMORY_DIR` or `dispatch.memory_dir` that is unavailable returns
directory, and resolution source (`env`, `config`, `legacy`,
`config-legacy-global`, or `none`). An explicit `PM_MEMORY_DIR` or matched
project-scoped config path that is unavailable returns
`status: invalid-explicit` and exit 3; it never falls through to another
host's legacy directory. With no explicit selection, legacy Claude discovery
remains compatible, and absence is reported as `unavailable`.
Expand Down Expand Up @@ -234,13 +252,15 @@ Writes use the same resolver. `pmctl memory append-episode --repo-root <repo>
--host <name> --summary <text>` appends one locked JSONL record to the resolved
canonical `episodes.jsonl`. It refuses invalid explicit paths, unwritable
directories, and symlink episode targets; it never accepts a caller-guessed
memory directory. `/mem-log` and Claude's Stop skeleton writer both use this
memory directory. `--host` is required and has no default: it records the
adapter that actually initiated the event, while project identity alone selects
the canonical destination. `/mem-log` and each host's Stop adapter both use this
API; skeleton session-id dedupe happens inside the same append lock.

| Host | Deterministic read entry | Canonical write entry | Native memory |
| --- | --- | --- | --- |
| Claude | `/pm` calls `pm prepare --host claude`; `UserPromptSubmit` runs `guard-inject-memory.sh` | `pmctl memory append-episode --host claude` | auxiliary; `unknown` unless separately observed |
| Codex | `UserPromptSubmit` runs `guard-inject-memory.sh`; batch PM uses `--host codex` | `pmctl memory append-episode --host codex` | auxiliary; `unknown` unless separately observed |
| Codex | `UserPromptSubmit` runs `guard-inject-memory.sh`; batch PM uses `--host codex` | `codex-memory-update.sh` routes explicit requests to `pmctl memory append-episode --host codex`; `Stop` writes a canonical skeleton | auxiliary; `unknown` unless separately observed |
| OpenCode | `/pm` calls the installed `pm_prepare` tool with `--host opencode` | `pmctl memory append-episode --host opencode` | auxiliary; `unknown` unless separately observed |
| Generic/no hook | `pmctl pm prepare --host generic` | `pmctl memory append-episode --host generic` | auxiliary; `unknown` |

Expand All @@ -252,6 +272,26 @@ API; skeleton session-id dedupe happens inside the same append lock.
- Run `/mem-recall` for session continuity before a major `/pm` batch.
- Keep `episodes.jsonl` append-only for auditability.

### Codex explicit update requests

The Codex host installer adds a marker-delimited block to
`$CODEX_HOME/AGENTS.md`. When a user explicitly asks to update, save, or record
project memory, that guidance routes the action through:

```bash
/path/to/pm-dispatch/hosts/codex/bin/memory-update.sh \
--repo-root "$(git rev-parse --show-toplevel)" \
--summary "$SUMMARY" \
--json
```

The wrapper fixes writer provenance to `codex` and delegates path selection to
the strict resolver. It never accepts a native-memory path. `AGENTS.md` is a
model instruction surface, not a filesystem access-control boundary, so the
installer and tests make the supported route deterministic while the
filesystem-diff E2E verifies that this route changes only canonical
`episodes.jsonl`. Invalid explicit memory remains fail-closed.


## Runtime conventions

Expand Down
2 changes: 1 addition & 1 deletion hosts/claude/host.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ guard_bindings:
coverage: none
stability: evolving
confidence: probed
# Session lifecycle: guard-session-summary.sh wired on the Stop event,
# Session lifecycle: guard-session-summary.sh --host claude wired on Stop,
# writing a session summary — probed end-to-end (fires on every session
# stop), advisory only (does not block anything).
- capability: session_lifecycle
Expand Down
48 changes: 48 additions & 0 deletions hosts/codex/bin/memory-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Canonical project-memory write seam for Codex host instructions.
# Never accepts or derives a Codex-native memory path: pmctl resolves the
# project-owned canonical store and fails closed on invalid explicit config.
set -euo pipefail

HOST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_ROOT="$(cd "$HOST_DIR/../.." && pwd)"
PMCTL="$REPO_ROOT/cli/pmctl"

repo_root=""
summary=""
session_id=""
json=0

usage() {
cat <<'EOF'
Usage: memory-update.sh --repo-root <path> --summary <text>
[--session-id <id>] [--json]

Append a Codex-authored episode through the strict canonical pmctl writer.
EOF
}

while [[ $# -gt 0 ]]; do
case "$1" in
--repo-root)
[[ $# -ge 2 ]] || { printf 'codex-memory-update: --repo-root requires a value\n' >&2; exit 2; }
repo_root="$2"; shift 2 ;;
--summary)
[[ $# -ge 2 ]] || { printf 'codex-memory-update: --summary requires a value\n' >&2; exit 2; }
summary="$2"; shift 2 ;;
--session-id)
[[ $# -ge 2 ]] || { printf 'codex-memory-update: --session-id requires a value\n' >&2; exit 2; }
session_id="$2"; shift 2 ;;
--json) json=1; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'codex-memory-update: unknown argument: %s\n' "$1" >&2; exit 2 ;;
esac
done

[[ -n "$repo_root" ]] || { printf 'codex-memory-update: --repo-root is required\n' >&2; exit 2; }
[[ -n "${summary//[[:space:]]/}" ]] || { printf 'codex-memory-update: --summary must not be empty\n' >&2; exit 2; }
[[ -x "$PMCTL" ]] || { printf 'codex-memory-update: pmctl is unavailable: %s\n' "$PMCTL" >&2; exit 1; }

args=(memory append-episode --repo-root "$repo_root" --host codex --session-id "$session_id" --summary "$summary")
[[ "$json" -eq 1 ]] && args+=(--json)
exec "$PMCTL" "${args[@]}"
22 changes: 12 additions & 10 deletions hosts/codex/host.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ install_targets:
path: "$CODEX_HOME/config.toml"
format: codex-config-toml
managed: false # asserted by doctor, never written
- id: instructions
path: "$CODEX_HOME/AGENTS.md"
format: codex-agents-md
managed: true # marker-delimited partial ownership

# Hook runtime facts probed end-to-end: the hook config lives in hooks.json
# using the Claude-Code-compatible hooks block shape (NOT a config.toml
Expand Down Expand Up @@ -93,19 +97,16 @@ guard_bindings:
coverage: none
stability: evolving
confidence: probed
# The capabilities below have not been evaluated on this host yet. The
# contract requires full enumeration: a none tuple with confidence assumed
# is the declared form of "not yet evaluated" (vs the probed none tuple
# above, which means "evaluated and unsupported").
# session_lifecycle: SessionStart/Stop events exist on the hook surface,
# but no end-to-end binding has been probed — stays assumed until one is.
# Stop payload carries cwd + session_id and the installed adapter passes an
# explicit Codex writer provenance into the shared canonical session writer.
# Only Stop is bound (SessionStart remains unused), hence partial coverage.
- capability: session_lifecycle
binding_form: none
provider: none
binding_form: hook-script
provider: host_hook
enforcement: none
coverage: none
coverage: partial
stability: evolving
confidence: assumed
confidence: probed
# Codex lacks Claude's interactive Agent/AskUserQuestion loop. It instead
# uses pmctl pm: one fully specified request/brief in, one batch result out.
# That supports PM orchestration without claiming interactive parity.
Expand Down Expand Up @@ -137,3 +138,4 @@ permissions_surface:
doctor_module: scripts/lib/doctor-host-codex.sh
install_module: scripts/install-guards-codex.sh
uninstall_module: scripts/uninstall-guards-codex.sh
memory_update_module: hosts/codex/bin/memory-update.sh
38 changes: 38 additions & 0 deletions hosts/codex/lib/memory-contract.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Codex-owned AGENTS.md memory-contract block helpers.
#
# Install and uninstall both need byte-consistent marker parsing and whitespace
# normalization. Keep that host-format policy beside hosts/codex instead of
# duplicating it in the generic scripts/ entrypoints.

CODEX_MEMORY_CONTRACT_START='<!-- pm-dispatch:codex-memory-contract:start -->'
CODEX_MEMORY_CONTRACT_END='<!-- pm-dispatch:codex-memory-contract:end -->'

codex_memory_contract_strip() {
local input="$1" output="$2" start_count end_count
start_count="$(grep -Fxc "$CODEX_MEMORY_CONTRACT_START" "$input" || true)"
end_count="$(grep -Fxc "$CODEX_MEMORY_CONTRACT_END" "$input" || true)"
[[ "$start_count" -eq "$end_count" && "$start_count" -le 1 ]] || return 2

awk -v start="$CODEX_MEMORY_CONTRACT_START" -v end="$CODEX_MEMORY_CONTRACT_END" '
$0 == start { managed=1; next }
$0 == end { managed=0; next }
!managed { print }
' "$input" \
| awk 'NF { while (blanks > 0) { print ""; blanks-- } print; next } { blanks++ }' \
> "$output"
}

codex_memory_contract_append() {
local output="$1" memory_update_cmd_q="$2"
[[ ! -s "$output" ]] || printf '\n' >> "$output"
cat >> "$output" <<EOF
$CODEX_MEMORY_CONTRACT_START
## pm-dispatch canonical project memory

- When the user explicitly asks to update, save, or record project memory, do not write a project record under \`.codex/memories\` or another host-private store.
- Resolve the repository root, summarize the durable facts, and run \`$memory_update_cmd_q --repo-root <absolute-repo-root> --summary <summary> --json\`.
- Treat pmctl output as the write confirmation. If canonical resolution fails, stop and report the error; never fall through to native or legacy memory.
$CODEX_MEMORY_CONTRACT_END
EOF
}
30 changes: 29 additions & 1 deletion scripts/guard-session-summary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ set -euo pipefail
# shellcheck disable=SC1091
. "$(dirname "$0")/lib/pmctl-memory.sh"

writer_host=""
while [[ $# -gt 0 ]]; do
case "$1" in
--host)
[[ $# -ge 2 ]] || { printf 'guard-session-summary: --host requires a value\n' >&2; exit 2; }
pmctl_host_is_valid "$2" || {
printf 'guard-session-summary: --host must be claude, codex, opencode, or generic\n' >&2
exit 2
}
writer_host="$2"
shift 2
;;
-h|--help)
printf 'Usage: guard-session-summary.sh --host <claude|codex|opencode|generic>\n'
exit 0
;;
*)
printf 'guard-session-summary: unknown argument: %s\n' "$1" >&2
exit 2
;;
esac
done

[[ -n "$writer_host" ]] || {
printf 'guard-session-summary: --host is required; the shared writer has no CLI-specific default\n' >&2
exit 2
}

payload=$(cat)
[[ -z "$payload" ]] && exit 0

Expand Down Expand Up @@ -84,7 +112,7 @@ fi

# Re-resolve and append under the canonical lock. `--skeleton` performs the
# session-id dedupe again inside the lock, closing the concurrent Stop race.
pmctl_memory_append_episode --repo-root "$cwd" --allow-non-git --host claude \
pmctl_memory_append_episode --repo-root "$cwd" --allow-non-git --host "$writer_host" \
--session-id "$session_id" --summary "" --skeleton >/dev/null 2>&1 || exit 0

exit 0
Loading