diff --git a/BACKLOG.md b/BACKLOG.md index d9142a50..57b75635 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,9 +28,10 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-480 | ✅ done | host-switch memory continuity:嚴格 resolution contract + Codex `pmctl pm prepare` 確定性 hydration + Claude↔Codex 共用同一 canonical memory E2E;v0.9.0 host 軸 continuity 驗收 | arch/memory | 2026-07-12 | — | P1 | design | | CC-481 | ✅ done | test runner contract:`run-tests.sh` direct-impact iteration planner + `run-all-tests.sh` 相容 full wrapper + 共用 suite executor + tree-bound result verifier;PR-gate 維持 repo-agnostic,full suite 移出 gate lifecycle | ops/test | 2026-07-13 | feedback:2026-07-13 | P1 | design | | CC-482 | ✅ done | Claude PR-gate reviewer definitions 最小讀取權限:headless `acceptEdits` 無法批准 `~/.claude/agents/*.md`;改用 workspace 內 run-scoped immutable snapshots,禁止擴大為任意 home read/`bypassPermissions` | ops/gate | 2026-07-13 | feedback:2026-07-13 | P1 | hygiene | -| CC-483 | 🔵 active | Codex PM workflow memory provider 優先權:使用者指定 `pmctl memory` 為 canonical substrate,不得默認優先使用 Codex native memory;盤點 prepare/guard/host instruction routing 與可觀測 provenance | arch/memory | 2026-07-13 | feedback:2026-07-13 | P1 | design | +| CC-483 | ✅ closed 2026-07-14 | Codex PM workflow memory provider 優先權:使用者指定 `pmctl memory` 為 canonical substrate,不得默認優先使用 Codex native memory;盤點 prepare/guard/host instruction routing 與可觀測 provenance | arch/memory | 2026-07-13 | pr:#399 | P1 | design | | CC-484 | ✅ done | JapanJob 與 qa-testing-rules 的 `pmctl context` refresh 未生效:重現 session/index/update/pack 實際路徑、repo-root/project-key/DB freshness,補跨 repo live E2E 與 actionable diagnostics | ops/memory | 2026-07-13 | feedback:2026-07-13 | P1 | retrieval | | CC-485 | ✅ done | 工具能力與維護者政策分離:通用 gate/test/PM 不規定使用流程;affected feedback 僅屬開發/PR,pm-dispatch release 固定由 `release-verify.sh --e2e`(內含 fresh full suite)+ checklist 驗收 | arch/process | 2026-07-13 | pr:#398 | P3 | design | +| CC-486 | ⏸ deferred | direct-impact test planner mapping 提前退出:changed path 含 `agents/*.md`/`commands/*.md` 時 `map_path` 呼叫未註冊的 `lint-frontmatter`,`add_suite` 回傳 1 並在 `set -e` 下無輸出終止,導致 `run-tests.sh --base ... --list` exit 1 | ops/test | 2026-07-13 | feedback:2026-07-13 | P2 | hygiene | | CC-465 | 🔵 active | memory/context 關鍵詞管線 CJK 支援:抽出共用零依賴斷詞 lib,取代三處各自 ASCII-only 抽詞;工作序列起點(465→467→468→466)(2026-07-07 記憶系統深入分析) | memory | 2026-07-07 | feedback:2026-07-07 | P2 | retrieval | | CC-466 | 🔵 active | 記憶卡片生命週期閉環:expires_at 執行 + 關窗式 supersede + usage sidecar 休眠偵測 + doctor→distill 接線;排在 CC-467 之後(需其遙測為前置)(2026-07-07 記憶系統分析 + 外部研究 Graphiti/mcp-memory-service) | memory | 2026-07-07 | feedback:2026-07-07 | P2 | retrieval | | CC-467 | 🔵 active | `pmctl memory stats`:注入效益可視化(唯讀聚合器)——注入 bytes/卡片命中分佈/從未命中卡/episode 填寫率,回答「記憶有跟沒有差在哪」;排在 CC-466 之前(2026-07-07;業界僅離線 recall 評測,無 per-injection 遙測) | DX/memory | 2026-07-07 | — | P2 | retrieval | @@ -616,7 +617,7 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed --- -## CC-483 — Codex workflow 優先使用 pmctl memory canonical substrate 🔵 active +## CC-483 — Codex workflow 優先使用 pmctl memory canonical substrate ✅ 2026-07-14 **Problem**: 現有 Codex workflow/host guidance 可能先使用 Codex 自身 memory surface,再把 `pmctl memory` 視為 fallback;但本專案的跨 host continuity 契約已由 [[CC-480]] 定義為 project-owned `pmctl memory`。若 provider priority 反轉,同一 repo 會產生兩套互不一致的記憶來源,Claude→Codex 切換表面成功、實際 retrieval/provenance 漂移。 @@ -635,6 +636,22 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed **Diagnostic evidence (2026-07-13)**: live `pmctl pm prepare --cd /home/screenleon/github/JapanJob --json` 已由 `pmctl memory resolve` 命中該 repo 的 canonical legacy memory dir/project key,且 `memory_context_status=hydrated`;pmctl coordinator 本身不是 native-first。缺口位於 Codex interactive host wiring:目前 live `~/.codex/hooks.json` 只有 Bash `PreToolUse` guard,沒有 prompt/session entry 將 preparation 固定導入 pmctl memory。拋棄式 `UserPromptSubmit` payload/injection probe 被中斷,未取得 runtime contract 前不先綁定未驗證 hook。 +**Implementation handoff (session close, 2026-07-13)**: working tree 尚未 commit,CC-483 保持 active。已完成 host-neutral provenance(`provider=pmctl`、canonical project key/dir、resolution source、hit count/refs、native `auxiliary/unknown`)、Codex `UserPromptSubmit` 安裝、OpenCode `--host opencode` preparation、strict locked `pmctl memory append-episode`、Claude `/mem-log` 與 Stop skeleton writer 遷移、invalid explicit 讀寫 fail-closed、generic non-git resolver opt-in,以及 dispatch brief 的 canonical provenance。因目前 checkout/global `pmctl` 沒有 `simplify` 子命令,已用 Codex read-only simplify/reuse review 代行並依結果抽出共用 host enum、prepare/run hydration、resolver-owned generic fallback,且把 Stop 寫入納入同一 lock。Focused evidence:memory 67/67、pm 30/30、guards full 294/294(新增後 invalid-explicit 2/2、session-hook 11/11)、commands 277/277、Codex host 36/36、OpenCode host 13/13、Codex doctor 9/9;lint-scripts/agents/frontmatter/test-docstrings 均通過。Live isolated E2E 已實際驗證 Codex 0.144.1 prompt payload/injection 與 invalid explicit pre-model block、Claude Code 2.1.207 canonical injection、OpenCode 1.17.8 `/pm` preparation;後續不得再用 Claude 做 gate(使用者額度要求)。本 session 結束前 full suite 僅啟動後即依使用者要求停止,沒有 sign-off 結果;Codex-only PR gate 尚未執行。下一 session 先重跑 full suite,處理真正 regression(若有),再以 `pr-gate.sh --executor codex --allow-dirty` gate。direct-impact planner 自身的既有無輸出 exit 1 已拆為 [[CC-486]],不可誤報為 CC-483 產品失敗。 + +**Outcome**: 2026-07-14 完成 Claude、Codex、OpenCode 共用 canonical `pmctl memory` resolver/writer、可觀測 provenance、Codex prompt/session hooks 與 symlink-safe atomic episode append。Full suite 77/77、Claude full-tier gate Final GO,PR CI 全數通過;CC-486 維持獨立 deferred,不併入本票。 + +**See**: pr:#399 + +--- + +## CC-486 — direct-impact planner 未註冊 suite 觸發 `set -e` 提前退出 ⏸ deferred + +**Problem**: `scripts/run-tests.sh --base origin/main --list` 在 changed paths 含 `agents/*.md` 或 `commands/*.md` 時,`map_path` 會呼叫 `add_suite lint-frontmatter`;但 `test-suite-runner.sh --list` 沒有註冊該 suite。`add_suite` 的最後一個條件式因此回傳 1,頂層 `set -e` 直接終止,沒有 planner diagnostics,exit 1。 + +**Acceptance**: 未註冊的 optional mapping 不得讓 planner 提前退出;應修正 mapping 名稱或讓 `add_suite` 明確 return 0,並新增包含 agent/command changed path 的 regression,確認 `--list` 輸出已選 suites、coverage gaps 與 exit 0。不得藉此弱化「沒有任何可用 suite 時 exit 2」的既有契約。 + +**Evidence**: CC-483 收尾時以 `bash -x scripts/run-tests.sh --base origin/main --list` 重現;trace 停在 `add_suite lint-frontmatter` 的 `[[ -n '' ]]`。同一批 CC-483 focused suites與 lint 均綠,故此項獨立追蹤,不視為 CC-483 產品 regression。 + --- ## CC-484 — JapanJob/qa-testing-rules pmctl context refresh 失效 ✅ 2026-07-13 diff --git a/agents/project-pm.md b/agents/project-pm.md index 351227ef..2c242d57 100644 --- a/agents/project-pm.md +++ b/agents/project-pm.md @@ -54,6 +54,14 @@ Use the legacy convention only when preparation supplied no canonical memory. If canonical fields are present but invalid, surface the mismatch and do not silently fall back to a different host-local memory. +When `memory_provenance` is present, require `provider: pmctl` and +`authority: canonical`; preserve its project key, resolution source, hit count, +and refs in any implementation brief as `canonical_memory_provenance`. +`auxiliary_memory` is never authoritative. A value of `status: unknown` means +unobserved, not empty, and must not be used to weaken or replace a canonical +constraint. If provenance names an invalid explicit resolution, stop instead +of consulting a legacy or host-native path. + # On invocation 1. **Identify project**: `pwd` and `ls ~/github/`. If user names a project use that; if ambiguous ask. diff --git a/cli/pmctl b/cli/pmctl index 8e2069a2..8a0dac09 100755 --- a/cli/pmctl +++ b/cli/pmctl @@ -353,6 +353,12 @@ case "$cmd/$sub" in fi pmctl_memory_resolve "$@" ;; + memory/append-episode) + if ! declare -F pmctl_memory_append_episode >/dev/null; then + pmctl_die "memory append-episode unavailable" + fi + pmctl_memory_append_episode "$@" + ;; memory/doctor) if ! declare -F pmctl_memory_doctor >/dev/null; then pmctl_die "memory doctor unavailable" diff --git a/commands/mem-log.md b/commands/mem-log.md index f99911d5..5701ea52 100644 --- a/commands/mem-log.md +++ b/commands/mem-log.md @@ -21,16 +21,15 @@ Record what happened in this session to the episodic memory layer. Follow these /mem-log "Session delivered first-pass migration plan and closed a hook exception." ``` -## Step 1 — Find episodes.jsonl +## Step 1 — Confirm canonical memory -Run `pmctl memory dir` to locate the memory directory, then derive the episodes path: +Resolve the same strict canonical memory that every host uses: ```bash -mem="$(pmctl memory dir)" || { echo "No memory directory found for this project"; exit 1; } -ep="$mem/episodes.jsonl" +pmctl memory resolve --repo-root "$(pwd)" --json ``` -If `pmctl memory dir` exits non-zero, report "No memory directory found for this project" and stop. The file `$ep` may not exist yet — that is expected when logging for the first time. +If resolution is unavailable or an explicit path is invalid, report the resolver error and stop. Never derive or guess a host-local path. ## Step 2 — Write the summary @@ -46,23 +45,20 @@ If `$ARGUMENTS` is non-empty, incorporate it as a hint about what to emphasize. Keep the summary factual and dense. No filler phrases. -## Step 3 — Update episodes.jsonl +## Step 3 — Append through the strict write API -**/mem-log owns summary creation.** Do not assume the Stop hook has already written a skeleton — the Stop hook runs at session end, after /mem-log. If a skeleton already exists (e.g., from a previous session), update it; otherwise append a new entry. +Call the host-neutral writer; do not open or mutate `episodes.jsonl` directly: -Read the episodes.jsonl file (it may not exist yet). Then: - -1. **Find an updatable entry**: scan all lines for one where `session_id` matches the current session AND `summary` is empty. If found, replace that line with the full entry (same `date`/`cwd`/`session_id`, filled `summary`). -2. **Otherwise**: append a new entry with the current date and cwd. - -The entry format: -```json -{"date":"","cwd":"","session_id":"","summary":"<3-5 line text>"} +```bash +pmctl memory append-episode \ + --repo-root "$(pwd)" \ + --host claude \ + --session-id "" \ + --summary "$SUMMARY" \ + --json ``` -`session_id` is only available inside hook payloads; from a slash command it may not be accessible. Use an empty string `""` if it is unknown — the Stop hook will still skip this session correctly if the last entry for this cwd already has a non-empty summary. - -Write the updated file (replace the matched line in-place or append; preserve all other lines). +`session_id` may be unavailable from a slash command; use `""` in that case. The API re-runs strict resolution, appends under a lock, rejects a symlink target, and fails closed instead of falling through to another host's memory. ## Step 4 — Confirm diff --git a/commands/pm.md b/commands/pm.md index b531e030..df5fa5b5 100644 --- a/commands/pm.md +++ b/commands/pm.md @@ -6,19 +6,21 @@ argument-hint: "` when the snapshot was captured above** (PM agent uses the snapshot for orientation; see `agents/project-pm.md` `## Snapshot ingestion` for the git re-derivation rules that apply before trusting any snapshot field), **`memory_dir: ` and the pointer-only `memory_context` pack when preparation returned them** (read the referenced cards from that directory before planning; never infer a different host-local memory path), and relevant prior-turn context the subagent won't otherwise see. +Invoke `project-pm` via Agent with `run_in_background: true` (default). PM tasks routinely take 30–120s and burn 30–80K tokens; foregrounding holds the main thread idle while the user can't interject. Foreground only when PM's verdict is the sole input to the immediate next tool call AND no parallel main-thread prep work exists (rare). Do not force a model — inherit the main-thread model so the user's own session choice applies (see `docs/model-tier-policy.md` §`/pm`). Brief with: request ($ARGUMENTS), current working directory, **`snapshot_file: ` when the snapshot was captured above** (PM agent uses the snapshot for orientation; see `agents/project-pm.md` `## Snapshot ingestion` for the git re-derivation rules that apply before trusting any snapshot field), **`memory_provenance`, `memory_dir: `, and the pointer-only `memory_context` pack when preparation returned them** (pmctl/canonical takes precedence; host-native memory is auxiliary only; read the referenced cards from that directory before planning; never infer a different host-local memory path), and relevant prior-turn context the subagent won't otherwise see. Relay the PM's user-facing summary. Do not do the PM's job yourself. diff --git a/docs/memory-system.md b/docs/memory-system.md index 7cdd6419..7a3a2201 100644 --- a/docs/memory-system.md +++ b/docs/memory-system.md @@ -208,22 +208,42 @@ remains compatible, and absence is reported as `unavailable`. Exit codes are `0` resolved, `1` unavailable, `2` usage error, and `3` invalid explicit selection. +Lifecycle adapters that can receive a real directory outside a Git worktree +use `--allow-non-git`. That option keeps env/config validation and legacy +discovery inside this same resolver, while deriving a deterministic path key; +hooks must not reconstruct a synthetic resolution record themselves. Normal PM +preparation and dispatch remain Git-only. + **Injection is a per-tool adapter, not part of the portable core.** The -portable core is the retrieval API: `pmctl context --source memory` (and -`pmctl memory doctor` for health checks). Claude Code's `UserPromptSubmit` -hook (`guard-inject-memory.sh`) is one adapter that calls into this core -automatically every turn. A tool without an equivalent hook (codex, opencode) -gets the same memory by calling `pmctl context --source memory` directly — -there is no requirement to replicate Claude's hook-based injection timing. - -For the Codex batch PM interface, this call is deterministic rather than a -model convention: `pmctl pm prepare` runs strict resolution and a bounded, +portable core is strict resolution, `pmctl context --source memory`, and the +canonical write API. Claude and Codex can both run the host-neutral +`guard-inject-memory.sh` on `UserPromptSubmit`; OpenCode's installed `/pm` +command calls its `pm_prepare` custom tool; a host with neither mechanism uses +`pmctl pm prepare --host generic` directly. All four routes expose pmctl as +canonical and label unobserved host-native memory `auxiliary/unknown`. + +For every batch PM interface, this call is deterministic rather than a model +convention: `pmctl pm prepare --host ` runs strict resolution and a bounded, request-scoped memory query on every preparation. Its JSON contract carries -`memory_resolution`, `memory_context_status`, and the retrieved +`memory_resolution`, `memory_provenance`, `memory_context_status`, and the retrieved `memory_context`, so a calling host can verify which canonical memory it used. No memory or zero hits is fail-open; an invalid explicit selection is fail-closed to prevent silent continuity loss. +Writes use the same resolver. `pmctl memory append-episode --repo-root +--host --summary ` 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 +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 | +| 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` | + ## Practical conventions - Keep `MEMORY.md` short and high-signal. diff --git a/hosts/codex/host.yaml b/hosts/codex/host.yaml index 576d18b5..0edc0930 100644 --- a/hosts/codex/host.yaml +++ b/hosts/codex/host.yaml @@ -37,8 +37,9 @@ install_targets: # 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 -# section), and deny decisions return via hookSpecificOutput.permissionDecision -# on the hook script's stdout. +# section), deny decisions return via hookSpecificOutput.permissionDecision, +# and UserPromptSubmit provides cwd/prompt while plain stdout becomes model +# context. The install module uses that verified contract for canonical memory. hook_surface: config_format: codex-hooks-json events: diff --git a/scripts/guard-inject-memory.sh b/scripts/guard-inject-memory.sh index 37f04e0d..bb4930ee 100755 --- a/scripts/guard-inject-memory.sh +++ b/scripts/guard-inject-memory.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash -# guard-inject-memory.sh — UserPromptSubmit hook: inject MEMORY.md index. -# Receives JSON payload via stdin from Claude Code UserPromptSubmit event. +# guard-inject-memory.sh — host-neutral UserPromptSubmit canonical-memory adapter. +# Claude and Codex both provide cwd/prompt on stdin and accept plain stdout as +# additional context. Hosts without that contract use `pmctl pm prepare`. set -euo pipefail # shellcheck disable=SC1091 -. "$(dirname "$0")/lib/memory.sh" -# shellcheck disable=SC1091 -. "$(dirname "$0")/lib/portable.sh" +. "$(dirname "$0")/lib/pmctl-memory.sh" MAX_INJECT_ENTRIES=20 MAX_INJECT_BYTES=3000 @@ -23,7 +22,6 @@ MEMORY_KEYWORD_WEIGHT="${PM_MEMORY_FRECENCY_KEYWORD_WEIGHT:-100000}" payload=$(cat) [[ -z "$payload" ]] && exit 0 -_config_dir="${CLAUDE_CONFIG_DIR:-${HOME}/.claude}" _tmp=$(mktemp) _t2tmp=$(mktemp) trap 'rm -f "$_tmp" "$_t2tmp"' EXIT @@ -32,7 +30,20 @@ printf '%s' "$payload" > "$_tmp" cwd=$(jq -r 'if (.cwd | type) == "string" then .cwd else empty end' "$_tmp" 2>/dev/null) || cwd="" [[ -n "$cwd" ]] || exit 0 -memory_dir=$(find_memory_dir "$cwd" "$_config_dir") || exit 0 +memory_resolution="" +memory_rc=0 +memory_resolution="$(pmctl_memory_resolve --repo-root "$cwd" --allow-non-git --json 2>/dev/null)" || memory_rc=$? +if [[ "$memory_rc" -eq 3 ]]; then + reason="$(jq -r '.reason // "invalid explicit canonical memory"' <<<"$memory_resolution" 2>/dev/null || printf 'invalid explicit canonical memory')" + jq -cn --arg reason "pmctl canonical memory configuration is invalid: $reason" \ + '{decision:"block",reason:$reason}' + exit 0 +fi +[[ "$memory_rc" -eq 0 ]] || exit 0 +memory_dir="$(jq -r '.memory_dir // empty' <<<"$memory_resolution")" +memory_project_key="$(jq -r '.project_key // empty' <<<"$memory_resolution")" +memory_resolution_source="$(jq -r '.resolution_source // "none"' <<<"$memory_resolution")" +[[ -n "$memory_dir" ]] || exit 0 memory_path="$memory_dir/MEMORY.md" [[ -f "$memory_path" ]] || exit 0 @@ -189,9 +200,10 @@ remaining_slots=$((MAX_INJECT_ENTRIES - tier1_count)) # Compute bytes already used by preamble + tier1 (tier1 is never byte-capped) preamble_line1='=== auto-memory: MEMORY.md index ===' -preamble_line2="Memory dir: ${memory_dir} | ${total_count} cards total" -preamble_line3='Use /mem-search for full retrieval' -bytes_used=$(( ${#preamble_line1} + 1 + ${#preamble_line2} + 1 + ${#preamble_line3} + 1 )) +preamble_line2="Provider: pmctl | authority: canonical | project_key: ${memory_project_key} | resolution: ${memory_resolution_source}" +preamble_line3="Memory dir: ${memory_dir} | ${total_count} cards total | native memory: auxiliary/unknown" +preamble_line4='Use /mem-search for full retrieval' +bytes_used=$(( ${#preamble_line1} + 1 + ${#preamble_line2} + 1 + ${#preamble_line3} + 1 + ${#preamble_line4} + 1 )) for _line in "${tier1_lines[@]+"${tier1_lines[@]}"}"; do bytes_used=$(( bytes_used + ${#_line} + 1 )) done @@ -209,7 +221,7 @@ done omitted_count=$(( total_count - tier1_count - "${#selected_tier2[@]}" )) # Emit preamble -printf '%s\n' "$preamble_line1" "$preamble_line2" "$preamble_line3" +printf '%s\n' "$preamble_line1" "$preamble_line2" "$preamble_line3" "$preamble_line4" # Emit tier1 (always-inject) [[ "${#tier1_lines[@]}" -gt 0 ]] && printf '%s\n' "${tier1_lines[@]}" diff --git a/scripts/guard-session-summary.sh b/scripts/guard-session-summary.sh index a9a9e5ee..ec410aed 100755 --- a/scripts/guard-session-summary.sh +++ b/scripts/guard-session-summary.sh @@ -5,12 +5,11 @@ set -euo pipefail # shellcheck disable=SC1091 -. "$(dirname "$0")/lib/memory.sh" +. "$(dirname "$0")/lib/pmctl-memory.sh" payload=$(cat) [[ -z "$payload" ]] && exit 0 -_config_dir="${CLAUDE_CONFIG_DIR:-${HOME}/.claude}" _tmp=$(mktemp) trap 'rm -f "$_tmp"' EXIT printf '%s' "$payload" > "$_tmp" @@ -23,7 +22,9 @@ cwd=$(jq -r 'if (.cwd | type) == "string" then .cwd else empty end' "$_tmp" 2>/d session_id=$(jq -r 'if (.session_id | type) == "string" then .session_id else empty end' "$_tmp" 2>/dev/null) || session_id="" [[ -n "$session_id" ]] || exit 0 -memory_dir=$(find_memory_dir "$cwd" "$_config_dir") || exit 0 +memory_resolution="$(pmctl_memory_resolve --repo-root "$cwd" --allow-non-git --json 2>/dev/null)" || exit 0 +memory_dir="$(jq -r '.memory_dir // empty' <<<"$memory_resolution")" +[[ -n "$memory_dir" ]] || exit 0 episodes_file="$memory_dir/episodes.jsonl" # Check for existing entry with this session_id @@ -81,14 +82,9 @@ if [[ -f "$episodes_file" ]]; then fi fi -# Write new skeleton entry -now=$(date -u +%Y-%m-%dT%H:%M:%SZ) -entry=$(jq -cn \ - --arg date "$now" \ - --arg cwd "$cwd" \ - --arg session_id "$session_id" \ - '{date: $date, cwd: $cwd, session_id: $session_id, summary: ""}') -mkdir -p "$memory_dir" -printf '%s\n' "$entry" >> "$episodes_file" +# 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 \ + --session-id "$session_id" --summary "" --skeleton >/dev/null 2>&1 || exit 0 exit 0 diff --git a/scripts/install-guards-codex.sh b/scripts/install-guards-codex.sh index fbe2c238..038c5fb7 100755 --- a/scripts/install-guards-codex.sh +++ b/scripts/install-guards-codex.sh @@ -6,8 +6,7 @@ # hosts/codex/host.yaml's install_targets (id: hooks) and guard_bindings # (command_guard) instead of hardcoding the path/format here — see # scripts/lib/host-manifest.sh and docs/host-contract.md. Wires exactly one -# hook today: matcher "Bash" -> scripts/hook-codex-command-guard.sh (see that -# script's header for the fail-closed v1 policy caveat). +# command guard plus the host-neutral canonical-memory UserPromptSubmit adapter. # # Usage: # scripts/install-guards-codex.sh # apply @@ -57,9 +56,10 @@ fi hooks_file="$(host_manifest_expand_path "$hooks_path_template")" hook_cmd="$REPO_ROOT/scripts/hook-codex-command-guard.sh" +memory_hook_cmd="$REPO_ROOT/scripts/guard-inject-memory.sh" -if [[ ! -x "$hook_cmd" ]]; then - echo "install-guards-codex: guard hook missing or not executable: $hook_cmd" >&2 +if [[ ! -x "$hook_cmd" || ! -x "$memory_hook_cmd" ]]; then + echo "install-guards-codex: managed hook missing or not executable" >&2 exit 2 fi @@ -69,6 +69,7 @@ fi # — printf %q only adds backslashes when needed, so space-free paths are # stored verbatim (no churn for existing installs). hook_cmd_q="$(printf '%q' "$hook_cmd")" +memory_hook_cmd_q="$(printf '%q' "$memory_hook_cmd")" tmp_new="$(mktemp)" tmp_current="$(mktemp)" @@ -83,12 +84,15 @@ fi # Merge idempotently: only append the managed hook entry if no existing # PreToolUse/Bash entry already points at this repo's guard script. -jq --arg cmd "$hook_cmd_q" ' +jq --arg cmd "$hook_cmd_q" --arg memory_cmd "$memory_hook_cmd_q" ' .hooks = (.hooks // {}) | .hooks.PreToolUse = (.hooks.PreToolUse // []) | + .hooks.UserPromptSubmit = (.hooks.UserPromptSubmit // []) | ([.hooks.PreToolUse[]? | select(.matcher == "Bash") | .hooks[]?.command] | index($cmd)) as $already | - if $already != null then . - else .hooks.PreToolUse += [{"matcher": "Bash", "hooks": [{"type": "command", "command": $cmd}]}] + (if $already != null then . else .hooks.PreToolUse += [{"matcher": "Bash", "hooks": [{"type": "command", "command": $cmd}]}] end) | + ([.hooks.UserPromptSubmit[]? | .hooks[]?.command] | index($memory_cmd)) as $memory_already | + if $memory_already != null then . + else .hooks.UserPromptSubmit += [{"hooks": [{"type": "command", "command": $memory_cmd}]}] end ' "$tmp_current" > "$tmp_new" diff --git a/scripts/install-host-opencode.sh b/scripts/install-host-opencode.sh index 38958ec2..3f22888a 100755 --- a/scripts/install-host-opencode.sh +++ b/scripts/install-host-opencode.sh @@ -198,7 +198,7 @@ export default tool({ }, async execute(args, context) { const focus = [...new Set(args.focus_tickets)] - const argv = [PMCTL, "pm", "prepare", "--cd", context.worktree, "--request", args.request, "--json"] + const argv = [PMCTL, "pm", "prepare", "--cd", context.worktree, "--request", args.request, "--host", "opencode", "--json"] if (focus.length > 0) argv.push("--focus", focus.join(",")) const proc = Bun.spawn(argv, { diff --git a/scripts/lib/host-names.sh b/scripts/lib/host-names.sh new file mode 100644 index 00000000..a70f8d6e --- /dev/null +++ b/scripts/lib/host-names.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Shared host-name policy for host-neutral pmctl surfaces. + +pmctl_host_is_valid() { + case "${1:-}" in + claude|codex|opencode|generic) return 0 ;; + *) return 1 ;; + esac +} diff --git a/scripts/lib/pmctl-memory.sh b/scripts/lib/pmctl-memory.sh index 081291e6..dc4b1bd3 100644 --- a/scripts/lib/pmctl-memory.sh +++ b/scripts/lib/pmctl-memory.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# pmctl-memory.sh — project-memory health reporting (`pmctl memory doctor`). +# pmctl-memory.sh — canonical project-memory resolution, writes, and health. # -# Source this file; do not execute directly. Provides pmctl_memory_doctor(): a -# read-only reporter over the project memory directory. It MUTATES nothing — no -# card writes, no enforce. Enforce + live-card backfill are sequenced follow-ups. +# Source this file; do not execute directly. `pmctl_memory_doctor` remains a +# read-only reporter; mutating callers must use the strict locked write surfaces +# in this module rather than writing canonical files directly. # # Reuses find_memory_dir() from memory.sh (the base, non-routing variant) to # locate the memory dir — it never re-walks paths itself and never sources @@ -14,6 +14,16 @@ # shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/memory.sh" +# shellcheck source=scripts/lib/host-names.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/host-names.sh" + +if [[ "$(type -t serialize_with_lock 2>/dev/null)" != function ]]; then + # shellcheck source=scripts/lib/portable.sh + # shellcheck disable=SC1091 + . "$(dirname "${BASH_SOURCE[0]}")/portable.sh" +fi + # Explicitly own the dispatch.memory_dir config dependency (not pmctl-dispatch.sh # or an adapter module, so the "MUST NOT source adapters" rule does not apply) # instead of relying on cli/pmctl's lib-load ordering having already sourced it. @@ -50,7 +60,7 @@ pmctl_memory_dir() { _pmctl_memory_resolve_usage() { cat <<'EOF' -Usage: pmctl memory resolve [--repo-root ] [--json] +Usage: pmctl memory resolve [--repo-root ] [--allow-non-git] [--json] Resolve the canonical project-memory directory without silently falling back from an explicitly configured but unavailable path. @@ -94,24 +104,39 @@ _pmctl_memory_emit_resolution() { pmctl_memory_resolve() { # Use the caller's cwd, not cli/pmctl's install-root REPO_ROOT. An explicit # --repo-root still wins and is recommended for automation. - local repo_root="$PWD" json=0 + local repo_root="$PWD" allow_non_git=0 json=0 while [[ $# -gt 0 ]]; do case "$1" in --repo-root) [[ $# -ge 2 ]] || { printf 'pmctl memory resolve: --repo-root requires a value\n' >&2; return 2; } repo_root="$2"; shift 2 ;; + --allow-non-git) allow_non_git=1; shift ;; --json) json=1; shift ;; -h|--help) _pmctl_memory_resolve_usage; return 0 ;; *) printf 'pmctl memory resolve: unknown argument: %s\n' "$1" >&2; return 2 ;; esac done - repo_root="$(cd "$repo_root" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)" || { - printf 'pmctl memory resolve: --repo-root must be inside a git worktree\n' >&2 + local requested_root="$repo_root" git_root="" + repo_root="$(cd "$requested_root" 2>/dev/null && pwd -P)" || { + printf 'pmctl memory resolve: --repo-root must name an existing directory\n' >&2 return 2 } + git_root="$(git -C "$repo_root" rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -n "$git_root" ]]; then + repo_root="$git_root" + elif [[ "$allow_non_git" -ne 1 ]]; then + printf 'pmctl memory resolve: --repo-root must be inside a git worktree\n' >&2 + return 2 + fi local project_key memory_dir="" source="none" status="unavailable" reason="" rc=1 - project_key="$(_pmctl_memory_project_key "$repo_root")" + if [[ -n "$git_root" ]]; then + project_key="$(_pmctl_memory_project_key "$repo_root")" + elif declare -F _portable_sha1 >/dev/null 2>&1; then + project_key="$(printf '%s\n' "$repo_root" | _portable_sha1 2>/dev/null || printf '%s' "$repo_root")" + else + project_key="$repo_root" + fi _pmctl_memory_load_config_override if [[ -n "${PM_MEMORY_DIR:-}" ]]; then @@ -147,6 +172,159 @@ pmctl_memory_resolve() { return "$rc" } +_pmctl_memory_append_episode_usage() { + cat <<'EOF' +Usage: pmctl memory append-episode --repo-root --summary + [--session-id ] [--date ] + [--host ] [--skeleton] + [--allow-non-git] [--json] + +Append one JSONL episode through the strict canonical resolver. An invalid +explicit memory path, unavailable memory, or unwritable target fails closed. +EOF +} + +_pmctl_memory_secure_append_dir() { + local append_dir="$1" owner + [[ ! -L "$append_dir" ]] || { + printf 'pmctl memory append-episode: refusing symlink lock directory: %s\n' "$append_dir" >&2 + return 1 + } + mkdir -p -- "$append_dir" 2>/dev/null || return 1 + [[ -d "$append_dir" && ! -L "$append_dir" ]] || return 1 + chmod 700 "$append_dir" 2>/dev/null || return 1 + owner="$(stat -c '%u' "$append_dir" 2>/dev/null || stat -f '%u' "$append_dir" 2>/dev/null || true)" + if [[ -n "$owner" && "$owner" != "$(id -u)" ]]; then + printf 'pmctl memory append-episode: refusing lock directory not owned by current user: %s\n' "$append_dir" >&2 + return 1 + fi +} + +_pmctl_memory_append_episode_inner() { + local episodes_file="$1" json_line="$2" mode="$3" session_id="$4" append_dir="$5" + local snapshot tmp + snapshot="$(mktemp "$append_dir/episodes.snapshot.XXXXXX")" || return 1 + tmp="$(mktemp "$append_dir/episodes.new.XXXXXX")" || { rm -f -- "$snapshot"; return 1; } + chmod 600 "$snapshot" "$tmp" 2>/dev/null || { rm -f -- "$snapshot" "$tmp"; return 1; } + rm -f -- "$snapshot" || { rm -f -- "$tmp"; return 1; } + + # Capture the current directory entry without following a symlink. The hard + # link pins the exact inode that was present at lookup time; a concurrent + # pathname swap can therefore only make ln fail or produce a snapshot that + # is itself a symlink, both of which fail closed before any content is read. + if [[ -e "$episodes_file" || -L "$episodes_file" ]]; then + if ! ln -P -- "$episodes_file" "$snapshot" 2>/dev/null; then + rm -f -- "$snapshot" "$tmp" + printf 'pmctl memory append-episode: failed to capture target safely: %s\n' "$episodes_file" >&2 + return 1 + fi + if [[ -L "$snapshot" || ! -f "$snapshot" ]]; then + rm -f -- "$snapshot" "$tmp" + printf 'pmctl memory append-episode: refusing symlink target: %s\n' "$episodes_file" >&2 + return 1 + fi + cat -- "$snapshot" > "$tmp" || { rm -f -- "$snapshot" "$tmp"; return 1; } + fi + + if [[ "$mode" == "skeleton" ]] \ + && jq -eRs --arg sid "$session_id" \ + 'split("\n") | map(select(length>0) | try fromjson catch empty) | any(.session_id == $sid)' \ + "$tmp" >/dev/null 2>&1; then + rm -f -- "$snapshot" "$tmp" + return 0 + fi + printf '%s\n' "$json_line" >> "$tmp" || { rm -f -- "$snapshot" "$tmp"; return 1; } + + # Never reopen episodes_file for writing. rename(2) replaces a raced symlink + # directory entry instead of following it, so an external target cannot be + # corrupted between validation and commit. + mv -f -- "$tmp" "$episodes_file" || { rm -f -- "$snapshot" "$tmp"; return 1; } + rm -f -- "$snapshot" +} + +# Strict canonical write surface for cross-host episodic memory. The resolver +# is deliberately invoked here instead of accepting a caller-computed memory +# path, so reads and writes cannot drift to different host-owned directories. +pmctl_memory_append_episode() { + local repo_root="$PWD" summary="" session_id="" episode_date="" host="generic" + local mode="summary" allow_non_git=0 json=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --repo-root) + [[ $# -ge 2 ]] || { printf 'pmctl memory append-episode: --repo-root requires a value\n' >&2; return 2; } + repo_root="$2"; shift 2 ;; + --summary) + [[ $# -ge 2 ]] || { printf 'pmctl memory append-episode: --summary requires a value\n' >&2; return 2; } + summary="$2"; shift 2 ;; + --session-id) + [[ $# -ge 2 ]] || { printf 'pmctl memory append-episode: --session-id requires a value\n' >&2; return 2; } + session_id="$2"; shift 2 ;; + --date) + [[ $# -ge 2 ]] || { printf 'pmctl memory append-episode: --date requires a value\n' >&2; return 2; } + episode_date="$2"; shift 2 ;; + --host) + [[ $# -ge 2 ]] || { printf 'pmctl memory append-episode: --host requires a value\n' >&2; return 2; } + pmctl_host_is_valid "$2" || { printf 'pmctl memory append-episode: --host must be claude, codex, opencode, or generic\n' >&2; return 2; } + host="$2" + shift 2 ;; + --skeleton) mode="skeleton"; shift ;; + --allow-non-git) allow_non_git=1; shift ;; + --json) json=1; shift ;; + -h|--help) _pmctl_memory_append_episode_usage; return 0 ;; + *) printf 'pmctl memory append-episode: unknown argument: %s\n' "$1" >&2; return 2 ;; + esac + done + + [[ "$mode" == "skeleton" && -n "$session_id" ]] || [[ -n "${summary//[[:space:]]/}" ]] || { + printf 'pmctl memory append-episode: --summary must not be empty\n' >&2 + return 2 + } + episode_date="${episode_date:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" + + local resolution rc=0 memory_dir project_key source episodes_file json_line + local -a resolve_args=(--repo-root "$repo_root" --json) + [[ "$allow_non_git" -eq 1 ]] && resolve_args+=(--allow-non-git) + resolution="$(pmctl_memory_resolve "${resolve_args[@]}")" || rc=$? + if [[ "$rc" -ne 0 ]]; then + printf 'pmctl memory append-episode: canonical memory resolution failed: %s\n' \ + "$(jq -r '.reason // .status // "unknown reason"' <<<"$resolution" 2>/dev/null || printf 'unknown reason')" >&2 + return "$rc" + fi + + memory_dir="$(jq -r '.memory_dir // empty' <<<"$resolution")" + project_key="$(jq -r '.project_key // empty' <<<"$resolution")" + source="$(jq -r '.resolution_source // "none"' <<<"$resolution")" + [[ -n "$memory_dir" && -d "$memory_dir" ]] || { + printf 'pmctl memory append-episode: resolved memory directory is unavailable\n' >&2 + return 1 + } + [[ "$(jq -r '.writable // false' <<<"$resolution")" == "true" ]] || { + printf 'pmctl memory append-episode: canonical memory directory is not writable: %s\n' "$memory_dir" >&2 + return 1 + } + + json_line="$(jq -cn \ + --arg date "$episode_date" --arg cwd "$(jq -r '.repo_root' <<<"$resolution")" \ + --arg session_id "$session_id" --arg summary "$summary" --arg writer_host "$host" \ + '{date:$date,cwd:$cwd,session_id:$session_id,summary:$summary,writer_host:$writer_host}')" || return 1 + episodes_file="$memory_dir/episodes.jsonl" + local append_dir="$memory_dir/.pm-dispatch" + _pmctl_memory_secure_append_dir "$append_dir" || return 1 + serialize_with_lock "$append_dir/episodes" \ + _pmctl_memory_append_episode_inner "$episodes_file" "$json_line" "$mode" "$session_id" "$append_dir" || return 1 + + if [[ "$json" -eq 1 ]]; then + jq -cn --arg provider pmctl --arg authority canonical \ + --arg project_key "$project_key" --arg memory_dir "$memory_dir" \ + --arg resolution_source "$source" --arg episodes_file "$episodes_file" --arg writer_host "$host" \ + --argjson episode "$json_line" \ + '{schema_version:1,provider:$provider,authority:$authority,writer_host:$writer_host,project_key:$project_key,memory_dir:$memory_dir,resolution_source:$resolution_source,episodes_file:$episodes_file,episode:$episode}' + else + printf 'provider: pmctl\nauthority: canonical\nwriter_host: %s\nproject_key: %s\nresolution_source: %s\nepisodes_file: %s\n' \ + "$host" "$project_key" "$source" "$episodes_file" + fi +} + _mem_json_esc() { local s="$1" s="${s//\\/\\\\}" diff --git a/scripts/lib/pmctl-pm.sh b/scripts/lib/pmctl-pm.sh index 9a0566a1..11f5b8f0 100644 --- a/scripts/lib/pmctl-pm.sh +++ b/scripts/lib/pmctl-pm.sh @@ -6,10 +6,14 @@ # command owns the repeatable shell steps around it: snapshot, validation, # detached dispatch, and authenticated wait. +# shellcheck source=scripts/lib/host-names.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/host-names.sh" + pmctl_pm_usage() { cat >&2 <<'EOF' -usage: pmctl pm prepare --request [--cd ] [--focus ] [--json] - pmctl pm run --adapter --brief-file --cd [--model ] [--isolation ] [--timeout ] [--no-auto-pack] [--json] +usage: pmctl pm prepare --request [--cd ] [--focus ] [--host ] [--json] + pmctl pm run --adapter --brief-file --cd [--host ] [--model ] [--isolation ] [--timeout ] [--no-auto-pack] [--json] Batch-only interface: prepare captures context for a fully specified request; run requires a complete dispatch_handover_v1 brief. Ambiguous requests are not @@ -50,10 +54,63 @@ pmctl_pm_bound_memory_pack() { return 1 } +# Resolve and query canonical memory with one failure/status contract for both +# preparation and dispatch. Results are returned in the three PMCTL_PM_MEMORY_* +# globals so callers can retain the resolver document even on fail-closed rc 3. +pmctl_pm_hydrate_memory() { + local work_dir="$1" task_id="$2"; shift 2 + local memory_rc=1 query_rc=0 query + local -a pack_args=("$work_dir" --task-id "$task_id" --source memory) + PMCTL_PM_MEMORY_RESOLUTION="$(jq -cn --arg repo "$work_dir" '{schema_version:1,status:"unavailable",repo_root:$repo,project_key:"",memory_dir:null,resolution_source:"none",readable:false,writable:false,reason:null}')" + PMCTL_PM_MEMORY_CONTEXT="" + PMCTL_PM_MEMORY_CONTEXT_STATUS="unavailable" + + declare -F pmctl_memory_resolve >/dev/null 2>&1 || return 0 + memory_rc=0 + PMCTL_PM_MEMORY_RESOLUTION="$(pmctl_memory_resolve --repo-root "$work_dir" --json)" || memory_rc=$? + [[ "$memory_rc" -ne 3 ]] || return 3 + [[ "$memory_rc" -eq 0 ]] || return 0 + + PMCTL_PM_MEMORY_CONTEXT_STATUS="no-hits" + declare -F pmctl_context_pack >/dev/null 2>&1 || return 0 + for query in "$@"; do + [[ -n "${query//[[:space:]]/}" ]] && pack_args+=(--query "$query") + done + [[ "${#pack_args[@]}" -gt 5 ]] || pack_args+=(--query "dispatch brief") + PMCTL_PM_MEMORY_CONTEXT="$(pmctl_context_pack "${pack_args[@]}")" || query_rc=$? + if [[ "$query_rc" -ne 0 ]]; then + PMCTL_PM_MEMORY_CONTEXT="" + PMCTL_PM_MEMORY_CONTEXT_STATUS="query-failed" + elif ! jq -e '.memories | length > 0' <<<"$PMCTL_PM_MEMORY_CONTEXT" >/dev/null 2>&1; then + PMCTL_PM_MEMORY_CONTEXT="" + elif PMCTL_PM_MEMORY_CONTEXT="$(pmctl_pm_bound_memory_pack "$PMCTL_PM_MEMORY_CONTEXT" 6000)"; then + PMCTL_PM_MEMORY_CONTEXT_STATUS="hydrated" + else + PMCTL_PM_MEMORY_CONTEXT="" + PMCTL_PM_MEMORY_CONTEXT_STATUS="query-failed" + fi +} + +pmctl_pm_memory_provenance() { + local memory_resolution="$1" memory_context_status="$2" memory_context="$3" host="${4:-generic}" + local refs='[]' hit_count=0 + if [[ -n "$memory_context" ]]; then + refs="$(jq -c '[.memories[]?.ref] | unique' <<<"$memory_context" 2>/dev/null)" || refs='[]' + hit_count="$(jq -r 'length' <<<"$refs" 2>/dev/null)" || hit_count=0 + fi + [[ "$hit_count" =~ ^[0-9]+$ ]] || hit_count=0 + jq -cn \ + --argjson resolution "$memory_resolution" \ + --arg context_status "$memory_context_status" --arg host "$host" \ + --argjson hit_count "$hit_count" --argjson refs "$refs" \ + '{schema_version:1,host:$host,provider:"pmctl",authority:"canonical",project_key:($resolution.project_key // ""),memory_dir:($resolution.memory_dir // null),resolution_source:($resolution.resolution_source // "none"),resolution_status:($resolution.status // "unavailable"),context_status:$context_status,hit_count:$hit_count,refs:$refs,auxiliary_memory:{provider:"host-native",host:$host,role:"auxiliary",status:"unknown",observable:false,hit_count:null,refs:null}}' +} + pmctl_pm_emit_prepare() { local json="$1" work_dir="$2" request="$3" focus="$4" snapshot="$5" snapshot_status="$6" local memory_resolution="$7" memory_context_status="$8" memory_context="$9" - local repo_context="${10}" + local repo_context="${10}" host="${11:-generic}" memory_provenance + memory_provenance="$(pmctl_pm_memory_provenance "$memory_resolution" "$memory_context_status" "$memory_context" "$host")" || return 1 if [[ "$json" -eq 1 ]]; then jq -cn \ --arg work_dir "$work_dir" \ @@ -64,8 +121,9 @@ pmctl_pm_emit_prepare() { --argjson memory_resolution "$memory_resolution" \ --arg memory_context_status "$memory_context_status" \ --arg memory_context "$memory_context" \ + --argjson memory_provenance "$memory_provenance" \ --argjson repo_context "$repo_context" \ - '{schema_version:1,mode:"batch-only",working_dir:$work_dir,request:$request,focus_tickets:(if $focus == "" then [] else $focus | split(",") end),snapshot_file:(if $snapshot == "" then null else $snapshot end),snapshot_status:$snapshot_status,repo_context:$repo_context,memory_resolution:$memory_resolution,memory_context_status:$memory_context_status,memory_context:(if $memory_context == "" then null else $memory_context end),handover_required:true,ambiguity_policy:"reject-and-return-to-host"}' + '{schema_version:1,mode:"batch-only",working_dir:$work_dir,request:$request,focus_tickets:(if $focus == "" then [] else $focus | split(",") end),snapshot_file:(if $snapshot == "" then null else $snapshot end),snapshot_status:$snapshot_status,repo_context:$repo_context,memory_resolution:$memory_resolution,memory_provenance:$memory_provenance,memory_context_status:$memory_context_status,memory_context:(if $memory_context == "" then null else $memory_context end),handover_required:true,ambiguity_policy:"reject-and-return-to-host"}' else printf 'mode: batch-only\nworking_dir: %s\n' "$work_dir" [[ -n "$focus" ]] && printf 'focus_tickets: %s\n' "$focus" @@ -74,6 +132,15 @@ pmctl_pm_emit_prepare() { printf 'context_status: %s\n' "$(jq -r '.freshness // "unavailable"' <<<"$repo_context")" printf 'context_db: %s\n' "$(jq -r '.db_path // ""' <<<"$repo_context")" printf 'memory_status: %s\n' "$memory_context_status" + printf 'memory_provider: %s\n' "$(jq -r '.provider' <<<"$memory_provenance")" + printf 'memory_authority: %s\n' "$(jq -r '.authority' <<<"$memory_provenance")" + printf 'memory_project_key: %s\n' "$(jq -r '.project_key' <<<"$memory_provenance")" + printf 'memory_resolution_source: %s\n' "$(jq -r '.resolution_source' <<<"$memory_provenance")" + printf 'memory_hit_count: %s\n' "$(jq -r '.hit_count' <<<"$memory_provenance")" + printf 'memory_refs: %s\n' "$(jq -c '.refs' <<<"$memory_provenance")" + printf 'memory_host: %s\n' "$(jq -r '.host' <<<"$memory_provenance")" + printf 'auxiliary_memory_role: %s\n' "$(jq -r '.auxiliary_memory.role' <<<"$memory_provenance")" + printf 'auxiliary_memory_status: %s\n' "$(jq -r '.auxiliary_memory.status' <<<"$memory_provenance")" local memory_dir memory_dir="$(jq -r '.memory_dir // empty' <<<"$memory_resolution")" [[ -n "$memory_dir" ]] && printf 'memory_dir: %s\n' "$memory_dir" @@ -86,12 +153,17 @@ pmctl_pm_emit_prepare() { pmctl_pm_prepare() { local repo_root="$1"; shift - local request="" work_dir="" focus="" json=0 + local request="" work_dir="" focus="" host="generic" json=0 while [[ $# -gt 0 ]]; do case "$1" in --request) [[ $# -ge 2 ]] || { printf 'pmctl pm prepare: --request requires a value\n' >&2; return 2; }; request="$2"; shift 2 ;; --cd) [[ $# -ge 2 ]] || { printf 'pmctl pm prepare: --cd requires a value\n' >&2; return 2; }; work_dir="$2"; shift 2 ;; --focus) [[ $# -ge 2 ]] || { printf 'pmctl pm prepare: --focus requires a value\n' >&2; return 2; }; focus="$2"; shift 2 ;; + --host) + [[ $# -ge 2 ]] || { printf 'pmctl pm prepare: --host requires a value\n' >&2; return 2; } + pmctl_host_is_valid "$2" || { printf 'pmctl pm prepare: --host must be claude, codex, opencode, or generic\n' >&2; return 2; } + host="$2" + shift 2 ;; --json) json=1; shift ;; -h|--help) pmctl_pm_usage; return 0 ;; *) printf 'pmctl pm prepare: unknown option: %s\n' "$1" >&2; return 2 ;; @@ -138,65 +210,44 @@ pmctl_pm_prepare() { snapshot="" fi - # Host-neutral memory hydration. The strict resolver prevents an explicitly - # selected memory path from silently falling back to Claude's legacy path. - local memory_resolution memory_rc=0 memory_context="" memory_context_status="unavailable" query_rc=0 - memory_resolution='{"schema_version":1,"status":"unavailable","repo_root":"","project_key":"","memory_dir":null,"resolution_source":"none","readable":false,"writable":false,"reason":null}' - if declare -F pmctl_memory_resolve >/dev/null 2>&1; then - memory_resolution="$(pmctl_memory_resolve --repo-root "$work_dir" --json)" || memory_rc=$? - if [[ "$memory_rc" -eq 3 ]]; then - printf 'pmctl pm prepare: explicit memory configuration is invalid: %s\n' \ - "$(jq -r '.reason // "unknown reason"' <<<"$memory_resolution" 2>/dev/null || printf 'unknown reason')" >&2 - [[ -n "$snapshot" && -f "$snapshot" ]] && rm -f "$snapshot" - return 1 - elif [[ "$memory_rc" -eq 0 ]]; then - memory_context_status="no-hits" - if declare -F pmctl_context_pack >/dev/null 2>&1; then - local -a memory_terms=() memory_pack_args=("$work_dir" --task-id pm-prepare --source memory) - local memory_term - if declare -F _ctx_extract_terms >/dev/null 2>&1; then - while IFS= read -r memory_term; do - [[ -n "$memory_term" ]] && memory_terms+=("$memory_term") - [[ "${#memory_terms[@]}" -ge 8 ]] && break - done < <(_ctx_extract_terms "$request") - fi - # Empty extraction (currently CJK-only text, short English, or - # stopword-only requests; CC-465 covers CJK) falls back to the whole - # request so LIKE/FTS can still find exact substrings. - [[ "${#memory_terms[@]}" -gt 0 ]] || memory_terms+=("$request") - for memory_term in "${memory_terms[@]}"; do - memory_pack_args+=(--query "$memory_term") - done - memory_context="$(pmctl_context_pack "${memory_pack_args[@]}")" || query_rc=$? - if [[ "$query_rc" -ne 0 ]]; then - memory_context="" - memory_context_status="query-failed" - elif ! jq -e '.memories | length > 0' <<<"$memory_context" >/dev/null 2>&1; then - memory_context="" - else - # Preparation is bounded but remains a valid context-pack document. - if memory_context="$(pmctl_pm_bound_memory_pack "$memory_context" 6000)"; then - memory_context_status="hydrated" - else - memory_context="" - memory_context_status="query-failed" - fi - fi - fi - fi + # Host-neutral memory hydration. Empty term extraction (currently CJK-only, + # short English, or stopword-only requests) falls back to the whole request. + local -a memory_terms=() + local memory_term memory_hydrate_rc=0 + if declare -F _ctx_extract_terms >/dev/null 2>&1; then + while IFS= read -r memory_term; do + [[ -n "$memory_term" ]] && memory_terms+=("$memory_term") + [[ "${#memory_terms[@]}" -ge 8 ]] && break + done < <(_ctx_extract_terms "$request") + fi + [[ "${#memory_terms[@]}" -gt 0 ]] || memory_terms+=("$request") + pmctl_pm_hydrate_memory "$work_dir" pm-prepare "${memory_terms[@]}" || memory_hydrate_rc=$? + local memory_resolution="$PMCTL_PM_MEMORY_RESOLUTION" + local memory_context="$PMCTL_PM_MEMORY_CONTEXT" + local memory_context_status="$PMCTL_PM_MEMORY_CONTEXT_STATUS" + if [[ "$memory_hydrate_rc" -eq 3 ]]; then + printf 'pmctl pm prepare: explicit memory configuration is invalid: %s\n' \ + "$(jq -r '.reason // "unknown reason"' <<<"$memory_resolution" 2>/dev/null || printf 'unknown reason')" >&2 + [[ -n "$snapshot" && -f "$snapshot" ]] && rm -f "$snapshot" + return 1 fi pmctl_pm_emit_prepare "$json" "$work_dir" "$request" "$focus" "$snapshot" "$snapshot_status" \ - "$memory_resolution" "$memory_context_status" "$memory_context" "$repo_context" + "$memory_resolution" "$memory_context_status" "$memory_context" "$repo_context" "$host" } pmctl_pm_run() { local repo_root="$1"; shift - local adapter="" brief_file="" work_dir="" model="" isolation="" timeout="" no_auto_pack=0 json=0 + local adapter="" brief_file="" work_dir="" host="generic" model="" isolation="" timeout="" no_auto_pack=0 json=0 while [[ $# -gt 0 ]]; do case "$1" in --adapter) [[ $# -ge 2 ]] || { printf 'pmctl pm run: --adapter requires a value\n' >&2; return 2; }; adapter="$2"; shift 2 ;; --brief-file) [[ $# -ge 2 ]] || { printf 'pmctl pm run: --brief-file requires a value\n' >&2; return 2; }; brief_file="$2"; shift 2 ;; --cd) [[ $# -ge 2 ]] || { printf 'pmctl pm run: --cd requires a value\n' >&2; return 2; }; work_dir="$2"; shift 2 ;; + --host) + [[ $# -ge 2 ]] || { printf 'pmctl pm run: --host requires a value\n' >&2; return 2; } + pmctl_host_is_valid "$2" || { printf 'pmctl pm run: --host must be claude, codex, opencode, or generic\n' >&2; return 2; } + host="$2" + shift 2 ;; --model) [[ $# -ge 2 ]] || { printf 'pmctl pm run: --model requires a value\n' >&2; return 2; }; model="$2"; shift 2 ;; --isolation) [[ $# -ge 2 ]] || { printf 'pmctl pm run: --isolation requires a value\n' >&2; return 2; }; isolation="$2"; shift 2 ;; --timeout) [[ $# -ge 2 ]] || { printf 'pmctl pm run: --timeout requires a value\n' >&2; return 2; }; timeout="$2"; shift 2 ;; @@ -210,11 +261,49 @@ pmctl_pm_run() { work_dir="$(cd "$work_dir" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)" || { printf 'pmctl pm run: --cd must be inside a git worktree\n' >&2; return 2; } + + # Re-resolve at the dispatch boundary. Preparation output may be stale or + # caller-modified; the actual executor brief must carry fresh canonical + # provenance and an invalid explicit selector must stop before dispatch. + local goal="" memory_hydrate_rc=0 memory_resolution memory_context memory_context_status memory_provenance + if declare -F pmctl_dispatch_extract_goal >/dev/null 2>&1; then + goal="$(pmctl_dispatch_extract_goal "$brief_file" 2>/dev/null || true)" + fi + [[ -n "${goal//[[:space:]]/}" ]] || goal="dispatch brief" + pmctl_pm_hydrate_memory "$work_dir" pm-run "$goal" || memory_hydrate_rc=$? + memory_resolution="$PMCTL_PM_MEMORY_RESOLUTION" + memory_context="$PMCTL_PM_MEMORY_CONTEXT" + memory_context_status="$PMCTL_PM_MEMORY_CONTEXT_STATUS" + if [[ "$memory_hydrate_rc" -eq 3 ]]; then + printf 'pmctl pm run: explicit memory configuration is invalid: %s\n' \ + "$(jq -r '.reason // "unknown reason"' <<<"$memory_resolution" 2>/dev/null || printf 'unknown reason')" >&2 + return 1 + fi + memory_provenance="$(pmctl_pm_memory_provenance "$memory_resolution" "$memory_context_status" "$memory_context" "$host")" || return 1 + pmctl_validate_brief "$repo_root" "$brief_file" >/dev/null || { printf 'pmctl pm run: brief validation failed\n' >&2; return 2; } - local -a dispatch_args=(--adapter "$adapter" --brief-file "$brief_file" --cd "$work_dir" --lifecycle detached) + local effective_brief="$brief_file" + if [[ "$(jq -r '.resolution_status' <<<"$memory_provenance")" == "resolved" ]]; then + effective_brief="$(mktemp "${TMPDIR:-/tmp}/brief-pm-memory-XXXXXX.md")" || return 1 + cp "$brief_file" "$effective_brief" || return 1 + { + printf '\n\ncanonical_memory_provenance:\n' + printf ' provider: pmctl\n authority: canonical\n' + printf ' host: "%s"\n' "$host" + printf ' project_key: %s\n' "$(jq -r '.project_key | @json' <<<"$memory_provenance")" + printf ' memory_dir: %s\n' "$(jq -r '.memory_dir | @json' <<<"$memory_provenance")" + printf ' resolution_source: %s\n' "$(jq -r '.resolution_source | @json' <<<"$memory_provenance")" + printf ' context_status: %s\n' "$(jq -r '.context_status | @json' <<<"$memory_provenance")" + printf ' hit_count: %s\n refs:\n' "$(jq -r '.hit_count' <<<"$memory_provenance")" + jq -r '.refs[]? | " - " + (@json)' <<<"$memory_provenance" + printf ' auxiliary_memory:\n role: auxiliary\n status: unknown\n' + } >> "$effective_brief" || return 1 + fi + + local -a dispatch_args=(--adapter "$adapter" --brief-file "$effective_brief" --cd "$work_dir" --lifecycle detached) [[ -n "$model" ]] && dispatch_args+=(--model "$model") [[ -n "$isolation" ]] && dispatch_args+=(--isolation "$isolation") [[ -n "$timeout" ]] && dispatch_args+=(--timeout "$timeout") @@ -233,10 +322,13 @@ pmctl_pm_run() { pmctl_dispatch_wait "$repo_root" "${wait_args[@]}" || wait_rc=$? fi if [[ "$json" -eq 1 ]]; then - jq -cn --arg run_id "$run_id" --arg work_dir "$work_dir" --arg adapter "$adapter" --argjson exit_code "$wait_rc" \ - '{schema_version:1,mode:"batch-only",run_id:$run_id,working_dir:$work_dir,adapter:$adapter,wait_exit_code:$exit_code}' + jq -cn --arg run_id "$run_id" --arg work_dir "$work_dir" --arg adapter "$adapter" --arg dispatch_brief "$effective_brief" --argjson memory_provenance "$memory_provenance" --argjson exit_code "$wait_rc" \ + '{schema_version:1,mode:"batch-only",run_id:$run_id,working_dir:$work_dir,adapter:$adapter,dispatch_brief:$dispatch_brief,memory_provenance:$memory_provenance,wait_exit_code:$exit_code}' else - printf 'run_id: %s\nworking_dir: %s\nadapter: %s\nwait_exit_code: %s\n' "$run_id" "$work_dir" "$adapter" "$wait_rc" + printf 'run_id: %s\nworking_dir: %s\nadapter: %s\ndispatch_brief: %s\nmemory_provider: pmctl\nmemory_project_key: %s\nmemory_resolution_source: %s\nmemory_hit_count: %s\nmemory_refs: %s\nauxiliary_memory_status: unknown\nwait_exit_code: %s\n' \ + "$run_id" "$work_dir" "$adapter" "$effective_brief" \ + "$(jq -r '.project_key' <<<"$memory_provenance")" "$(jq -r '.resolution_source' <<<"$memory_provenance")" \ + "$(jq -r '.hit_count' <<<"$memory_provenance")" "$(jq -c '.refs' <<<"$memory_provenance")" "$wait_rc" fi return "$wait_rc" } diff --git a/scripts/test-commands.sh b/scripts/test-commands.sh index ed3323fb..d746213a 100755 --- a/scripts/test-commands.sh +++ b/scripts/test-commands.sh @@ -143,10 +143,10 @@ MEM_LOG="$COMMANDS_DIR/mem-log.md" assert_frontmatter "mem-log: frontmatter valid" "$MEM_LOG" should_run "mem-log: no python3 calls" && assert_not_contains "mem-log: no python3 calls" "$MEM_LOG" "python3" -should_run "mem-log: Step 1 uses pmctl memory dir" && assert_file_contains "mem-log: Step 1 uses pmctl memory dir" "$MEM_LOG" "pmctl memory dir" && pass "mem-log: Step 1 uses pmctl memory dir" -should_run "mem-log: Step 1 derives episodes.jsonl path" && assert_file_contains "mem-log: Step 1 derives episodes.jsonl path" "$MEM_LOG" "episodes.jsonl" && pass "mem-log: Step 1 derives episodes.jsonl path" -should_run "mem-log: Step 1 handles no memory dir error" && assert_file_contains "mem-log: Step 1 handles no memory dir error" "$MEM_LOG" "No memory directory found for this project" && pass "mem-log: Step 1 handles no memory dir error" -should_run "mem-log: Step 1 allows missing file on first log" && assert_file_contains "mem-log: Step 1 allows missing file on first log" "$MEM_LOG" "may not exist" && pass "mem-log: Step 1 allows missing file on first log" +should_run "mem-log: Step 1 uses strict memory resolve" && assert_file_contains "mem-log: Step 1 uses strict memory resolve" "$MEM_LOG" "pmctl memory resolve" && pass "mem-log: Step 1 uses strict memory resolve" +should_run "mem-log: Step 3 uses canonical append API" && assert_file_contains "mem-log: Step 3 uses canonical append API" "$MEM_LOG" "pmctl memory append-episode" && pass "mem-log: Step 3 uses canonical append API" +should_run "mem-log: Step 3 identifies Claude writer host" && assert_file_contains "mem-log: Step 3 identifies Claude writer host" "$MEM_LOG" "--host claude" && pass "mem-log: Step 3 identifies Claude writer host" +should_run "mem-log: never writes episodes directly" && assert_not_contains "mem-log: never writes episodes directly" "$MEM_LOG" 'ep="$mem/episodes.jsonl"' # ── mem-recall.md contract ──────────────────────────────────────────────────── diff --git a/scripts/test-guards.sh b/scripts/test-guards.sh index 6848aafe..a897f6ca 100755 --- a/scripts/test-guards.sh +++ b/scripts/test-guards.sh @@ -1538,6 +1538,32 @@ inject_hook_no_memory_found() { rm -rf "$dir" } +inject_hook_invalid_explicit_blocks_without_fallback() { + # Verifies invalid explicit canonical memory blocks the prompt and never injects legacy memory. + # Steps: + # 1. Create a matching legacy memory fixture and select a missing PM_MEMORY_DIR + # 2. Run the shared Claude/Codex UserPromptSubmit hook + # 3. Assert the structured block decision is returned with no legacy card text + local name="inject-hook/invalid-explicit-blocks-without-fallback" + should_run "$name" || return 0 + local dir cwd payload output status=0 + dir="$(mktemp -d)" + cwd="$dir/workspace" + mkdir -p "$cwd" + write_inject_memory "$dir" "$cwd" $'- legacy-card-must-not-inject\n' + payload="{\"cwd\":\"$cwd\",\"prompt\":\"test invalid canonical memory\"}" + output="$(printf '%s' "$payload" | PM_MEMORY_DIR="$dir/missing-memory" CLAUDE_CONFIG_DIR="$dir" "$MEM_HOOK" 2>/dev/null)" || status=$? + if [[ "$status" -eq 0 ]] && jq -e '.decision == "block" and (.reason | contains("canonical memory configuration is invalid"))' <<<"$output" >/dev/null 2>&1 \ + && [[ "$output" != *"legacy-card-must-not-inject"* ]]; then + PASS=$((PASS+1)) + [[ "${VERBOSE:-}" ]] && printf ' PASS %s\n' "$name" + else + FAIL=$((FAIL+1)); FAILED_CASES+=("$name") + printf ' FAIL %s — exit=%s output=%q\n' "$name" "$status" "$output" + fi + rm -rf "$dir" +} + inject_hook_empty_index() { # Verifies MEMORY.md without "- " index lines exits 0 with empty stdout. # Steps: @@ -1795,7 +1821,8 @@ inject_hook_default_home_fallback() { local name="inject-hook/default-home-fallback" cwd encoded tmp_home project_dir payload output status should_run "$name" || return 0 tmp_home="$(mktemp -d)" - cwd="/tmp/inject-hook-home-fallback-test-$$" + cwd="$tmp_home/worktree" + mkdir -p "$cwd" encoded="$(inject_encoded_path "$cwd")" project_dir="${tmp_home}/.claude/projects/${encoded}/memory" mkdir -p "$project_dir" @@ -2502,6 +2529,7 @@ inject_hook_priority_always_bypasses_lifecycle_gate() { inject_hook_happy_path inject_hook_parent_fallback inject_hook_no_memory_found +inject_hook_invalid_explicit_blocks_without_fallback inject_hook_empty_index inject_hook_malformed_payload inject_hook_empty_stdin @@ -3321,6 +3349,32 @@ session_hook_no_memory_dir() { rm -rf "$dir" } +session_hook_invalid_explicit_no_legacy_write() { + # Verifies Stop metadata cannot fall through from invalid explicit memory to legacy memory. + # Steps: + # 1. Create a legacy project memory fixture and select a missing PM_MEMORY_DIR + # 2. Run the Stop hook with a stable session id + # 3. Assert the best-effort hook exits cleanly without writing either target + local name="session-hook/invalid-explicit-no-legacy-write" + should_run "$name" || return 0 + local dir cwd episodes output status=0 + dir="$(mktemp -d)" + cwd="$dir/workspace" + mkdir -p "$cwd" + write_inject_memory "$dir" "$cwd" $'- alpha\n' + episodes="$dir/projects/$(inject_encoded_path "$cwd")/memory/episodes.jsonl" + output="$(printf '%s' "{\"cwd\":\"$cwd\",\"session_id\":\"invalid-explicit\"}" | \ + PM_MEMORY_DIR="$dir/missing-memory" CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null)" || status=$? + if [[ "$status" -eq 0 && -z "$output" && ! -e "$episodes" ]]; then + PASS=$((PASS+1)) + [[ "${VERBOSE:-}" ]] && printf ' PASS %s\n' "$name" + else + FAIL=$((FAIL+1)); FAILED_CASES+=("$name") + printf ' FAIL %s — exit=%s output=%q episodes=%s\n' "$name" "$status" "$output" "$([[ -e "$episodes" ]] && echo yes || echo no)" + fi + rm -rf "$dir" +} + session_hook_malformed_payload() { # Verifies exit 0 with no output when payload is not valid JSON. # Steps: @@ -3425,6 +3479,7 @@ session_hook_duplicate_no_summary session_hook_duplicate_has_summary session_hook_new_session_appends session_hook_no_memory_dir +session_hook_invalid_explicit_no_legacy_write session_hook_malformed_payload session_hook_empty_stdin session_hook_missing_session_id diff --git a/scripts/test-host-write-codex.sh b/scripts/test-host-write-codex.sh index 41ebd0df..6c33a55c 100755 --- a/scripts/test-host-write-codex.sh +++ b/scripts/test-host-write-codex.sh @@ -214,10 +214,12 @@ test_install_guards_codex_wires_hook() { return fi if jq -e '.hooks.PreToolUse[] | select(.matcher=="Bash") | .hooks[] | select(.command | endswith("hook-codex-command-guard.sh"))' \ + "$codex_home/hooks.json" >/dev/null 2>&1 \ + && jq -e '.hooks.UserPromptSubmit[] | .hooks[] | select(.command | endswith("guard-inject-memory.sh"))' \ "$codex_home/hooks.json" >/dev/null 2>&1; then pass "$name" else - fail "$name" "expected PreToolUse Bash hook not found in $codex_home/hooks.json" + fail "$name" "expected command guard and canonical-memory prompt hook in $codex_home/hooks.json" fi } @@ -227,10 +229,12 @@ test_install_guards_codex_idempotent() { local codex_home="$tmp_root/ic-idem/.codex" CODEX_HOME="$codex_home" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 local before after - before="$(jq -c '.hooks.PreToolUse' "$codex_home/hooks.json")" + before="$(jq -c '.hooks' "$codex_home/hooks.json")" CODEX_HOME="$codex_home" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 - after="$(jq -c '.hooks.PreToolUse' "$codex_home/hooks.json")" - [[ "$before" == "$after" ]] && [[ "$(jq '.hooks.PreToolUse | length' "$codex_home/hooks.json")" == "1" ]] \ + after="$(jq -c '.hooks' "$codex_home/hooks.json")" + [[ "$before" == "$after" ]] \ + && [[ "$(jq '.hooks.PreToolUse | length' "$codex_home/hooks.json")" == "1" ]] \ + && [[ "$(jq '.hooks.UserPromptSubmit | length' "$codex_home/hooks.json")" == "1" ]] \ && pass "$name" || fail "$name" "re-running should not duplicate the managed hook entry" } diff --git a/scripts/test-pmctl-memory.sh b/scripts/test-pmctl-memory.sh index 2a3e9172..a420f596 100755 --- a/scripts/test-pmctl-memory.sh +++ b/scripts/test-pmctl-memory.sh @@ -1736,6 +1736,174 @@ case_memory_doctor_shard_count() { pass "$name" } +# Behavior: every host appends through the same strict canonical episode path. +# Steps: append one marker per host to an env-selected memory dir and assert JSONL/provenance. +case_memory_append_episode_cross_host_contract() { + local name="pmctl memory append-episode: Claude Codex OpenCode and generic share canonical path" + should_run "$name" || return 0 + local repo="$tmp_root/append-cross-host-repo" mdir="$tmp_root/append-cross-host-memory" host out status=0 + mkdir -p "$repo" "$mdir" + git -C "$repo" init -q + for host in claude codex opencode generic; do + out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --host "$host" \ + --session-id "session-$host" --date "2026-07-13T00:00:00Z" --summary "cc483-${host}-canonical-marker" --json 2>/dev/null)" || status=$? + if [[ "$status" -ne 0 ]] || ! jq -e --arg host "$host" --arg mdir "$mdir" \ + '.provider == "pmctl" and .authority == "canonical" and .writer_host == $host and .memory_dir == $mdir and .episode.writer_host == $host' <<<"$out" >/dev/null; then + fail "$name" "host=$host status=$status out=$out"; return 0 + fi + done + if [[ "$(wc -l < "$mdir/episodes.jsonl" | tr -d ' ')" == "4" ]] \ + && jq -e -s 'map(.writer_host) == ["claude","codex","opencode","generic"]' "$mdir/episodes.jsonl" >/dev/null; then + pass "$name" + else + fail "$name" "episodes=$(cat "$mdir/episodes.jsonl" 2>/dev/null || true)" + fi +} + +# Behavior: the write API never falls through from an invalid explicit path to legacy memory. +# Steps: create writable legacy memory, select a missing PM_MEMORY_DIR, and assert no episode is written. +case_memory_append_episode_invalid_explicit_no_fallback() { + local name="pmctl memory append-episode: invalid explicit path fails closed without legacy write" + should_run "$name" || return 0 + local repo="$tmp_root/append-invalid-repo" cfg="$tmp_root/append-invalid-cfg" missing="$tmp_root/append-missing" out="$tmp_root/append-invalid.out" legacy status=0 + mkdir -p "$repo" + git -C "$repo" init -q + legacy="$(make_fixture_memory "$cfg" "$repo")" + PM_MEMORY_DIR="$missing" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory append-episode --repo-root "$repo" \ + --host codex --summary 'must-not-land' --json > "$out" 2>&1 || status=$? + if [[ "$status" -eq 3 && ! -e "$legacy/episodes.jsonl" ]] && grep -q 'canonical memory resolution failed' "$out"; then + pass "$name" + else + fail "$name" "status=$status legacy_episode=$([[ -e "$legacy/episodes.jsonl" ]] && printf yes || printf no) out=$(<"$out")" + fi +} + +# Behavior: an episode written by one host is immediately queryable through the shared pmctl memory plane. +# Steps: Codex appends a unique marker, then a host-neutral memory query retrieves episodes.jsonl. +case_memory_append_episode_query_round_trip() { + local name="pmctl memory append-episode: Codex write is queryable by shared memory retrieval" + should_run "$name" || return 0 + command -v sqlite3 >/dev/null 2>&1 || { pass "$name (sqlite unavailable)"; return 0; } + local repo="$tmp_root/append-query-repo" mdir="$tmp_root/append-query-memory" marker="cc483codexwriteroundtrip" out status=0 + mkdir -p "$repo" "$mdir" + git -C "$repo" init -q + PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --host codex --summary "$marker" --json >/dev/null 2>&1 || status=$? + out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" context query "$repo" --source memory -- "$marker" 2>/dev/null || true)" + if [[ "$status" -eq 0 && "$out" == *"episodes.jsonl"* ]]; then + pass "$name" + else + fail "$name" "status=$status query=$out" + fi +} + +# Behavior: the generic lifecycle fallback is owned by the strict resolver, not a hook-local reimplementation. +# Steps: resolve a non-git cwd with the explicit opt-in and assert deterministic canonical provenance. +case_memory_resolve_allows_generic_non_git() { + local name="pmctl memory resolve: generic non-git fallback stays structured" + should_run "$name" || return 0 + local cwd="$tmp_root/resolve-generic-cwd" cfg="$tmp_root/resolve-generic-cfg" mdir out status=0 + mkdir -p "$cwd" + mdir="$(make_fixture_memory "$cfg" "$cwd")" + out="$(CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory resolve --repo-root "$cwd" --allow-non-git --json 2>/dev/null)" || status=$? + if [[ "$status" -eq 0 ]] && jq -e --arg cwd "$cwd" --arg mdir "$mdir" \ + '.status == "resolved" and .repo_root == $cwd and .memory_dir == $mdir and .resolution_source == "legacy" and (.project_key | length) > 0' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "status=$status out=$out" + fi +} + +# Behavior: skeleton dedupe is performed inside the canonical append lock. +# Steps: launch concurrent Stop-style writes for one session and assert one JSONL record. +case_memory_append_episode_concurrent_skeleton_dedupe() { + local name="pmctl memory append-episode: concurrent skeleton writes deduplicate" + should_run "$name" || return 0 + local repo="$tmp_root/append-skeleton-repo" mdir="$tmp_root/append-skeleton-memory" i failed=0 + mkdir -p "$repo" "$mdir" + git -C "$repo" init -q + for i in 1 2 3 4 5 6; do + PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --host claude \ + --session-id shared-stop-session --summary "" --skeleton >/dev/null 2>&1 & + done + wait || failed=1 + if [[ "$failed" -eq 0 && "$(wc -l < "$mdir/episodes.jsonl" | tr -d ' ')" == "1" ]] \ + && jq -e -s 'length == 1 and .[0].session_id == "shared-stop-session" and .[0].summary == ""' "$mdir/episodes.jsonl" >/dev/null; then + pass "$name" + else + fail "$name" "failed=$failed episodes=$(cat "$mdir/episodes.jsonl" 2>/dev/null || true)" + fi +} + +# Behavior: canonical writes refuse an episodes.jsonl symlink even when its target is writable. +# Steps: point episodes.jsonl at an external file and assert the command fails without changing it. +case_memory_append_episode_refuses_symlink() { + local name="pmctl memory append-episode: refuses symlink target" + should_run "$name" || return 0 + local repo="$tmp_root/append-symlink-repo" mdir="$tmp_root/append-symlink-memory" target="$tmp_root/external-episodes" out status=0 + mkdir -p "$repo" "$mdir" + git -C "$repo" init -q + printf 'sentinel\n' > "$target" + ln -s "$target" "$mdir/episodes.jsonl" + out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --host generic --summary must-not-land 2>&1)" || status=$? + if [[ "$status" -eq 1 && "$(<"$target")" == "sentinel" && "$out" == *"refusing symlink target"* ]]; then + pass "$name" + else + fail "$name" "status=$status target=$(<"$target") out=$out" + fi +} + +# Behavior: a target swapped to a symlink immediately before commit cannot redirect the canonical write. +# Steps: intercept the final rename, install a symlink to an external sentinel, and assert rename replaces only the link. +case_memory_append_episode_symlink_swap_race() { + local name="pmctl memory append-episode: atomic commit defeats symlink swap race" + should_run "$name" || return 0 + # shellcheck disable=SC1091 + . "$REPO_ROOT/scripts/lib/pmctl-memory.sh" + local mdir="$tmp_root/append-race-memory" episodes target append_dir json_line status=0 swapped=0 + mkdir -p "$mdir" + episodes="$mdir/episodes.jsonl" + target="$tmp_root/append-race-external" + append_dir="$mdir/.pm-dispatch" + printf 'sentinel\n' > "$target" + _pmctl_memory_secure_append_dir "$append_dir" + json_line='{"date":"2026-07-14T00:00:00Z","cwd":"/tmp/race","session_id":"race","summary":"atomic","writer_host":"codex"}' + # shellcheck disable=SC2329,SC2317 # invoked indirectly by the sourced append helper. + mv() { + if [[ "$swapped" -eq 0 && "${*: -1}" == "$episodes" ]]; then + ln -sf -- "$target" "$episodes" + swapped=1 + fi + command mv "$@" + } + _pmctl_memory_append_episode_inner "$episodes" "$json_line" summary race "$append_dir" || status=$? + unset -f mv + if [[ "$status" -eq 0 && "$swapped" -eq 1 && ! -L "$episodes" ]] \ + && [[ "$(<"$target")" == "sentinel" ]] \ + && jq -e '.summary == "atomic" and .writer_host == "codex"' "$episodes" >/dev/null 2>&1; then + pass "$name" + else + fail "$name" "status=$status swapped=$swapped target=$(<"$target") episodes=$(cat "$episodes" 2>/dev/null || true)" + fi +} + +# Behavior: the append lock/work directory cannot be redirected through a symlink. +# Steps: symlink .pm-dispatch to an external directory and assert fail-closed with no episode artifacts. +case_memory_append_episode_refuses_symlink_lock_dir() { + local name="pmctl memory append-episode: refuses symlink lock directory" + should_run "$name" || return 0 + local repo="$tmp_root/append-lock-repo" mdir="$tmp_root/append-lock-memory" external="$tmp_root/append-lock-external" out status=0 + mkdir -p "$repo" "$mdir" "$external" + git -C "$repo" init -q + ln -s "$external" "$mdir/.pm-dispatch" + out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --host codex --summary must-not-land 2>&1)" || status=$? + if [[ "$status" -eq 1 && "$out" == *"refusing symlink lock directory"* ]] \ + && [[ ! -e "$mdir/episodes.jsonl" && -z "$(find "$external" -mindepth 1 -print -quit)" ]]; then + pass "$name" + else + fail "$name" "status=$status out=$out external=$(find "$external" -mindepth 1 -print 2>/dev/null || true)" + fi +} + # ── Run all cases ────────────────────────────────────────────────────────────── case_memory_resolve_env_contract @@ -1747,6 +1915,14 @@ case_memory_resolve_invalid_config_no_fallback case_memory_resolve_rejects_relative_explicit_paths case_memory_resolve_unavailable case_memory_resolve_rejects_non_git_root +case_memory_resolve_allows_generic_non_git +case_memory_append_episode_cross_host_contract +case_memory_append_episode_invalid_explicit_no_fallback +case_memory_append_episode_query_round_trip +case_memory_append_episode_concurrent_skeleton_dedupe +case_memory_append_episode_refuses_symlink +case_memory_append_episode_symlink_swap_race +case_memory_append_episode_refuses_symlink_lock_dir case_memory_dir_happy_path case_memory_dir_nested_subdir case_memory_dir_uses_pwd_default diff --git a/scripts/test-pmctl-pm.sh b/scripts/test-pmctl-pm.sh index d635faea..225a79e4 100755 --- a/scripts/test-pmctl-pm.sh +++ b/scripts/test-pmctl-pm.sh @@ -196,6 +196,51 @@ MD fi } +# Behavior: preparation names the calling host while keeping pmctl canonical and native memory auxiliary. +# Steps: run the same shared-memory query through all host labels and assert identical refs/authority. +case_prepare_emits_cross_host_memory_provenance() { + local name="pmctl pm prepare: all hosts emit canonical pmctl provenance" + should_run "$name" || return 0 + local work="$tmp_root/provenance-work" mdir="$tmp_root/provenance-memory" host out snapshot code + mkdir -p "$work" "$mdir" + git -C "$work" init -q + printf '%s\n' '- [CC483 provenance](project_cc483.md) — cc483provenancemarker' > "$mdir/MEMORY.md" + printf '%s\n' '---' 'topics:' ' - cc483provenancemarker' 'priority: normal' 'status: active' 'updated_at: "2026-07-13"' 'repo_refs: []' '---' 'canonical cc483provenancemarker' > "$mdir/project_cc483.md" + for host in claude codex opencode generic; do + code=0 + out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" pm prepare --cd "$work" --host "$host" --request cc483provenancemarker --json 2>/dev/null)" || code=$? + snapshot="$(jq -r '.snapshot_file // empty' <<<"$out" 2>/dev/null || true)" + [[ -n "$snapshot" && -f "$snapshot" ]] && rm -f "$snapshot" + if [[ "$code" -ne 0 ]] || ! jq -e --arg host "$host" --arg mdir "$mdir" \ + '.memory_provenance.host == $host and .memory_provenance.provider == "pmctl" and .memory_provenance.authority == "canonical" and .memory_provenance.memory_dir == $mdir and .memory_provenance.resolution_source == "env" and .memory_provenance.hit_count >= 1 and (.memory_provenance.refs | index("project_cc483.md:1") != null) and .memory_provenance.auxiliary_memory.role == "auxiliary" and .memory_provenance.auxiliary_memory.status == "unknown"' <<<"$out" >/dev/null; then + fail "$name" "host=$host code=$code out=$out"; return 0 + fi + done + pass "$name" +} + +# Behavior: a conflicting host-native note cannot replace canonical pmctl results. +# Steps: place opposite text under CODEX_HOME, query canonical memory, and assert only canonical refs are exposed. +case_prepare_native_conflict_stays_auxiliary() { + local name="pmctl pm prepare: conflicting native note cannot replace canonical constraint" + should_run "$name" || return 0 + local work="$tmp_root/native-conflict-work" mdir="$tmp_root/native-conflict-memory" codex_home="$tmp_root/native-conflict-codex" out snapshot code=0 + mkdir -p "$work" "$mdir" "$codex_home/memories" + git -C "$work" init -q + printf '%s\n' '- [Canonical authority](canonical_rule.md) — cc483authoritymarker' > "$mdir/MEMORY.md" + printf '%s\n' '---' 'topics:' ' - cc483authoritymarker' 'priority: always' 'status: active' 'updated_at: "2026-07-13"' 'repo_refs: []' '---' 'CANONICAL_RULE_WINS cc483authoritymarker' > "$mdir/canonical_rule.md" + printf '%s\n' 'NATIVE_CONFLICT_WINS cc483authoritymarker' > "$codex_home/memories/conflict.md" + out="$(PM_MEMORY_DIR="$mdir" CODEX_HOME="$codex_home" "$PMCTL" pm prepare --cd "$work" --host codex --request cc483authoritymarker --json 2>/dev/null)" || code=$? + snapshot="$(jq -r '.snapshot_file // empty' <<<"$out" 2>/dev/null || true)" + [[ -n "$snapshot" && -f "$snapshot" ]] && rm -f "$snapshot" + if [[ "$code" -eq 0 ]] && jq -e \ + '.memory_provenance.provider == "pmctl" and (.memory_provenance.refs | index("canonical_rule.md:1") != null) and .memory_provenance.auxiliary_memory.status == "unknown" and (.memory_context | contains("conflict.md") | not)' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + # Behavior: Codex preparation refuses an invalid explicit memory selection instead of reading legacy memory. # Steps: create an available legacy path plus a missing PM_MEMORY_DIR and assert fail-closed preparation. case_prepare_rejects_invalid_explicit_memory() { @@ -425,7 +470,7 @@ case_run_uses_validate_detached_wait() { } # Behavior: run's human mode prints the authenticated dispatch handoff result. -# Steps: stub validation, launch, and wait; assert the complete four-line human contract. +# Steps: stub validation, launch, and wait; assert dispatch plus memory provenance lines. case_run_emits_human_contract() { local name="pmctl pm run: emits human batch contract" should_run "$name" || return 0 @@ -436,13 +481,48 @@ case_run_emits_human_contract() { pmctl_dispatch_run() { printf 'run-test-human-output\n'; } pmctl_dispatch_wait() { return 0; } pmctl_pm_run "$REPO_ROOT" --adapter codex --brief-file /tmp/brief-test.md --cd "$REPO_ROOT" > "$out" || code=$? - if [[ "$code" -eq 0 ]] && [[ "$(<"$out")" == $'run_id: run-test-human-output\nworking_dir: '"$REPO_ROOT"$'\nadapter: codex\nwait_exit_code: 0' ]]; then + if [[ "$code" -eq 0 ]] \ + && grep -q '^run_id: run-test-human-output$' "$out" \ + && grep -q '^memory_provider: pmctl$' "$out" \ + && grep -q '^auxiliary_memory_status: unknown$' "$out" \ + && grep -q '^wait_exit_code: 0$' "$out"; then pass "$name" else fail "$name" "code=$code out=$(<"$out")" fi } +# Behavior: dispatch re-resolves canonical memory and carries provenance/refs in the executed brief. +# Steps: stub resolver/query/dispatch, run as OpenCode host, and inspect JSON plus forwarded brief. +case_run_carries_memory_provenance_into_dispatch_brief() { + local name="pmctl pm run: dispatch artifact carries canonical memory provenance" + should_run "$name" || return 0 + . "$REPO_ROOT/scripts/lib/pmctl-pm.sh" + local work="$tmp_root/run-provenance-work" mdir="$tmp_root/run-provenance-memory" brief="$tmp_root/run-provenance-source.md" trace="$tmp_root/run-provenance-trace" out="$tmp_root/run-provenance.out" code=0 effective + mkdir -p "$work" "$mdir" + git -C "$work" init -q + printf 'working_dir: %s\ngoal: cc483 dispatch provenance\nfiles:\n - read: README.md\nacceptance:\n - provenance\n' "$work" > "$brief" + pmctl_validate_brief() { return 0; } + pmctl_memory_resolve() { jq -cn --arg repo "$work" --arg mdir "$mdir" '{schema_version:1,status:"resolved",repo_root:$repo,project_key:"cc483-key",memory_dir:$mdir,resolution_source:"env",readable:true,writable:true,reason:null}'; } + pmctl_dispatch_extract_goal() { printf 'cc483 dispatch provenance\n'; } + pmctl_context_pack() { printf '%s\n' '{"schema_version":2,"task_id":"pm-run","memories":[{"ref":"canonical.md:7"}]}' ; } + pmctl_dispatch_run() { printf '%s\n' "$*" > "$trace"; printf 'run-test-provenance\n'; } + pmctl_dispatch_wait() { return 0; } + pmctl_pm_run "$REPO_ROOT" --adapter codex --brief-file "$brief" --cd "$work" --host opencode --json > "$out" || code=$? + unset -f pmctl_validate_brief pmctl_memory_resolve pmctl_dispatch_extract_goal pmctl_context_pack pmctl_dispatch_run pmctl_dispatch_wait + effective="$(jq -r '.dispatch_brief // empty' "$out" 2>/dev/null || true)" + if [[ "$code" -eq 0 ]] \ + && jq -e '.memory_provenance.host == "opencode" and .memory_provenance.provider == "pmctl" and .memory_provenance.hit_count == 1 and .memory_provenance.refs == ["canonical.md:7"]' "$out" >/dev/null \ + && [[ -f "$effective" ]] \ + && grep -q '^canonical_memory_provenance:$' "$effective" \ + && grep -q 'canonical.md:7' "$effective" \ + && grep -q -- "--brief-file $effective" "$trace"; then + pass "$name" + else + fail "$name" "code=$code effective=$effective out=$(<"$out") trace=$(cat "$trace" 2>/dev/null || true)" + fi +} + # Behavior: run rejects an invalid handover before it can launch a dispatch. # Steps: invoke the CLI with a malformed brief and assert usage exit 2 and no executor requirement. case_run_rejects_invalid_brief() { @@ -572,6 +652,8 @@ case_prepare_deduplicates_focus_tickets case_prepare_rejects_empty_request case_prepare_rejects_non_git_workdir case_prepare_hydrates_shared_memory +case_prepare_emits_cross_host_memory_provenance +case_prepare_native_conflict_stays_auxiliary case_prepare_rejects_invalid_explicit_memory case_prepare_reports_no_memory_hits case_prepare_reports_memory_query_failure @@ -583,6 +665,7 @@ case_prepare_invalid_memory_cleans_snapshot case_unknown_subcommand_shows_usage case_run_uses_validate_detached_wait case_run_emits_human_contract +case_run_carries_memory_provenance_into_dispatch_brief case_run_rejects_invalid_brief case_run_requires_adapter_brief_and_workdir case_run_rejects_non_git_workdir diff --git a/scripts/uninstall-guards-codex.sh b/scripts/uninstall-guards-codex.sh index 2850cf58..92c688c6 100755 --- a/scripts/uninstall-guards-codex.sh +++ b/scripts/uninstall-guards-codex.sh @@ -67,6 +67,8 @@ fi hook_cmd="$REPO_ROOT/scripts/hook-codex-command-guard.sh" hook_cmd_q="$(printf '%q' "$hook_cmd")" +memory_hook_cmd="$REPO_ROOT/scripts/guard-inject-memory.sh" +memory_hook_cmd_q="$(printf '%q' "$memory_hook_cmd")" tmp_new="$(mktemp)" trap 'rm -f "$tmp_new"' EXIT @@ -77,8 +79,8 @@ trap 'rm -f "$tmp_new"' EXIT # to a different checkout or tool. Compare both the escaped form (current # installer output) and the raw unescaped form (installs written before the # shell-escape fix), so an older install still uninstalls cleanly. -jq --arg cmd "$hook_cmd" --arg cmd_q "$hook_cmd_q" ' - def is_managed: (. // "") | (. == $cmd or . == $cmd_q); +jq --arg cmd "$hook_cmd" --arg cmd_q "$hook_cmd_q" --arg memory_cmd "$memory_hook_cmd" --arg memory_cmd_q "$memory_hook_cmd_q" ' + def is_managed: (. // "") | (. == $cmd or . == $cmd_q or . == $memory_cmd or . == $memory_cmd_q); ( [.hooks // {} | keys[]] ) as $event_types | reduce $event_types[] as $et ( .;