diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4500264c..a342c0e7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/BACKLOG.md b/BACKLOG.md index af9805b2..f3515710 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -33,6 +33,10 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | 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-487 | 🔵 active | GitHub Actions `test-guards` 非確定性掛起:逐 case breadcrumb + bounded background writer/wait + orphan cleanup,避免 main CI 無輸出佔用 runner 近一小時 | ops/test | 2026-07-14 | feedback:2026-07-14 | P1 | hygiene | +| CC-488 | ✅ done | Codex canonical memory lifecycle 收口:Stop writer、自然語言更新路由、explicit config 與 live/private-memory migration 驗收 | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design | +| CC-489 | ⏸ deferred | `scripts/` domain ownership 重整:host/runtime/ops/test entrypoints 移至對應模組,由 manifest/registry 統一發現並以相容 shim 分批遷移 | arch | 2026-07-14 | feedback:2026-07-14 | P2 | design | +| CC-490 | 🔵 active | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design | +| CC-491 | 🔵 active | PR-gate pre-flight 機械式 evidence contract:傳遞 command、selected suites、逐項結果與 tree fingerprint,讓 reviewer reuse 已驗證結果並禁止無條件重跑 | ops/gate | 2026-07-14 | feedback:2026-07-14 | P1 | design | | 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 | @@ -641,6 +645,8 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed **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,不併入本票。 +**Post-close audit (2026-07-14)**: 上述「Codex prompt/session hooks」表述過廣。PR #399 的 Codex installer 已接上 `UserPromptSubmit` canonical read hook,但未安裝 `Stop` session writer;`hosts/codex/host.yaml` 也仍以 `session_lifecycle provider:none / confidence:assumed` 誠實標示。writer API 與 Claude Stop migration 已完成,不等於 Codex lifecycle write binding 已完成。此 closure gap、live install/config migration 與「更新 memory」自然語言路由的可測試契約改由 [[CC-488]] 承接,不重開本票。 + **See**: pr:#399 --- @@ -675,6 +681,112 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed --- +## CC-488 — Codex canonical memory lifecycle 與更新路由收口 ✅ 2026-07-14 + +**Problem**: PR #399 已建立跨 host 的 strict resolver、locked `pmctl memory append-episode` writer API,並讓 Codex installer 能安裝 `UserPromptSubmit` canonical read hook;但目前 live `~/.codex/hooks.json` 仍只有 `PreToolUse`,Codex installer 尚未安裝 `Stop` writer,manifest 也未宣告已驗證的 `session_lifecycle`。此外,使用者以自然語言要求「更新專案 memory」時,仍可能由 agent 寫入 Codex 私有 `.codex/memories/...`,而不是 canonical `episodes.jsonl`;live resolver 亦仍為 `resolution_source: legacy`。因此資料面健康不代表 Codex 的讀、寫、選址與操作路由已形成可測試的 end-to-end contract。 + +**Boundary**: +1. [[CC-483]] 保持 closed;本票只承接其 post-close Codex lifecycle/routing closure,不重做 resolver、writer API 或 provider provenance。 +2. live reinstall、`~/.pm-dispatch/config` 設定與錯誤私有紀錄遷移是部署/資料修復步驟,必須在 product contract 與 isolated tests 通過後執行,不能用手動修機器掩蓋 installer/doctor 缺口。 +3. 不把 [[CC-486]] planner bug 或 [[CC-487]] CI hang 混入本票;它們各自維持獨立狀態。 + +**Requirement**: +1. 以拋棄式 `CODEX_HOME` 和真實 Codex hook payload probe 驗證 `Stop` 的 `cwd`、`session_id`、重複觸發與 headless trust 行為;證據不足前不得把 manifest capability 從 `assumed/none` 升級。 +2. Codex install/uninstall 必須對稱管理 canonical session writer:保留 foreign hooks、exact checkout identity、含空白路徑可執行、重裝不重複、卸載只移除本 checkout 項目。共用 `guard-session-summary.sh` 不得有 Claude、Codex 或其他 CLI 預設值;必須由各 host-specific installer/薄 adapter 明示且驗證 provenance,避免目前或未來 host 的 episode 被錯誤歸因。 +3. doctor 必須分別檢查 command guard、prompt read hook 與 Stop write hook;只有實際 wiring 加 live probe 通過後,`hosts/codex/host.yaml` 才能宣告 `session_lifecycle` 的 `host_hook` provider、coverage 與 confidence。 +4. 為「更新專案 memory/請更新 memory」建立 Codex host instruction/command contract:canonical 目的地必須先由 repo project identity 解析,再由 Codex-owned adapter 明示實際 source host,呼叫 `pmctl memory append-episode --repo-root --host codex --summary ...`。此處 `codex` 是 adapter provenance,不是 canonical 預設;Claude、OpenCode 或未來 CLI 必須各自明示自己的 host。Codex native memory 只能是明示 auxiliary,不得作為 project memory 的寫入 fallback。若 Codex 沒有可安裝、可強制的 instruction surface,必須提供可機械呼叫的 wrapper/command seam 並把 unsupported 邊界寫進 doctor/contract;只補提醒文字不算完成。 +5. 新增 filesystem-diff regression/live E2E:從自然語言「更新專案 memory」情境出發,確認 canonical `episodes.jsonl` 新增一筆 `host=codex` episode,Codex 私有 memory root 沒有新增或修改 project 記錄;invalid explicit config 必須 fail closed,不能轉寫 private/legacy store。 +6. 將 live canonical dir 寫入 `~/.pm-dispatch/config` 的 `memory.projects..dir`,使 Claude、Codex、OpenCode 對同 repo 均回報 `resolution_source: config`、相同 project key/physical dir、readable/writable。此步必須先記錄現有 resolved path,禁止建立第二份 memory 或複製資料;失效 config 的跨 host probe 必須一致 fail closed。 +7. 以「先 canonical、後刪除」順序遷移本次錯誤記錄:來源檔雖位於 Codex 私有 memory,但 canonical 目的地只由 repo project identity 決定,不由目前 CLI 決定。遷移時必須明示實際產生該事件的 source host(本次歷史事件可證明為 Codex;若 provenance 無法證明則拒絕猜測,改由操作者明示 `generic`),再用 `pmctl memory append-episode --repo-root --host ` 寫入等價且可驗證的 episode。確認 canonical 成功與唯一性後,才移除 `.codex/memories/extensions/ad_hoc/notes/20260714-115550-pr400-merged-workflow.md`;保留遷移前後 evidence,失敗時不得先刪來源。這是一次 source-adapter cleanup,不代表 canonical memory 預設 host=codex。 + +**Acceptance**: +1. isolated install 產生 `PreToolUse`、`UserPromptSubmit`、`Stop` 三條預期 wiring;idempotent install、symmetric uninstall、foreign/sibling checkout preservation 與 spaced-path cases 全綠。 +2. Codex Stop live smoke 只新增一次 canonical skeleton episode,`host=codex`、session id/cwd 正確;doctor 與 manifest declared/probed/effective 三層一致。 +3. 「更新 memory」E2E 只有 canonical episodes 改變,Codex 私有 memory 無 project write;explicit invalid path 非零退出且兩邊都不寫。 +4. live 三 host resolve 均為 `config` 且指向目前 75 筆、`issues_count: 0` 的同一實體 store;錯誤私有 note 已在 canonical append 驗證後清除。 +5. focused Codex host/doctor/memory/guard/install tests、`./cli/pmctl backlog lint`、`git diff --check` 通過;涉及 hook concurrency 的驗證不以降低 [[CC-487]] 斷言或跳過 case 方式完成。 + +**Execution order**: (A) Stop payload/provenance probe → (B) installer/uninstaller/doctor/manifest + tests → (C) update-memory routing contract + filesystem-diff E2E → (D) isolated full host acceptance → (E) live reinstall → (F) explicit config 三 host verification → (G) canonical append 後清理私有 note。任何階段 fail closed 就停止,不進行後續 live mutation。 + +**Dependencies**: 建在 [[CC-480]] strict resolution 與 [[CC-483]] canonical writer/provider work 上;與 [[CC-452]] 的一般 guard hardening 共用安全約束,但不得等待其全部 hygiene scope 才修正 Codex host provenance。[[CC-487]] 若仍 active,只影響 full-suite/CI sign-off 的解讀,不改變本票 focused product acceptance。 + +**Source**: 2026-07-14 live audit:canonical doctor 75 entries、0 issues、read/write healthy;Codex live hook 缺 `UserPromptSubmit`/`Stop`,resolver source 為 legacy,且發現一筆誤寫到 Codex private memory 的 PR #400 workflow note。 + +**Outcome**: Codex installer 現在對稱管理 `PreToolUse` command guard、`UserPromptSubmit` canonical injection、`Stop` canonical skeleton writer,以及 `$CODEX_HOME/AGENTS.md` marker-delimited update-memory guidance;共享 Stop writer 要求明示 `--host`,沒有 CLI-specific 預設值,各 host adapter 必須注入自己的實際 provenance。explicit update seam 位於 manifest-owned `hosts/codex/bin/memory-update.sh`,因此該 Codex-owned adapter 明示委派 strict `pmctl memory append-episode --host codex`;其他 CLI 必須由自己的 adapter 明示 host,canonical store 仍只按 repo project identity 選址。invalid explicit path fail closed,filesystem-diff regression 證實不修改 Codex native memory。真實 Codex 0.144.3 headless probe 捕捉到 Stop payload 的 `session_id`/`cwd`/`hook_event_name`,manifest/doctor 因而可誠實宣告 `session_lifecycle host_hook/partial/probed`。Live installer 已重裝;`~/.pm-dispatch/config` 明示既有 canonical dir,Claude/Codex/OpenCode preparation 均回報同 project key、同實體路徑、`resolution_source=config`、readable/writable/hydrated。錯誤 PR #400 私有 note 的 canonical `writer_host=codex` 只記錄該歷史事件確由 Codex session 產生,不是系統預設;確認唯一落盤後才移除來源。Refactor/reuse audit 為 `CHANGED`:install/uninstall 重複的 Codex `AGENTS.md` marker strip、空白正規化與 contract render 已收斂到 host-owned `hosts/codex/lib/memory-contract.sh`,並新增 malformed-marker fail-closed 回歸。驗證:Codex host 41/41、host manifest 89/89、guards 297/297、memory 69/69、doctor 56/56、install 88/88、affected planner 4/4、lint-scripts、backlog lint、`git diff --check` 全綠;`~/.claude/statusline-chain.conf` 安裝前後 `cmp` 相同且 SHA-256 固定為 `6eec303a31b7f161fecec88eb6b8467f236d77c17ded09c3c85435090da9eeaf`。 + +**PR-gate R1(2026-07-14, NO-GO)**: Claude full-tier gate `gate-20260714-134455-1466632` 找到一個 blocking fixture-isolation gap:live `~/.pm-dispatch/config` 使 `test-guards.sh` 的 inject/session fixtures 誤解析到真實 canonical store,造成 263 pass / 34 fail,包含本票新增的 Codex Stop provenance case 實際寫入 0 筆。已修為 suite-level `PM_DISPATCH_CONFIG_FILE` missing-path isolation,重跑 297/297。architecture advise 同步處理:`lint-scripts.sh` 現掃描 `scripts/*.sh`、`hosts/*/bin/*.sh`、`hosts/*/lib/*.sh`(119 files),CI 另對 `./hosts` 執行 shellcheck。risk reviewer 指出的全域單值 config 跨 repo bleed 經 live read-only probe 確認:JapanJob 與 qa-testing-rules 會解析到 pm-dispatch memory;此 resolver/schema redesign 獨立列為 [[CC-490]],不混入本票 host lifecycle diff。 + +**See**: [[CC-483]], [[CC-489]], [[CC-490]] + +--- + +## CC-489 — `scripts/` domain ownership 與 manifest-driven entrypoint 重整 ⏸ deferred + +**Problem**: repository 目前把 host adapters、install/uninstall、doctor modules、memory hooks、gate/runtime supervisors、維運工具、lint 與所有 test runners 集中在單一 `scripts/`。檔名雖有前綴,但 ownership、依賴方向與「新增功能應放哪裡」無法從目錄結構判斷;host manifest 已能描述 module path,實體程式卻仍多數留在共享 scripts 根目錄,長期會增加跨 host 漂移、路徑硬編與搬移成本。 + +**Decision / target shape**: +1. host 專屬 executable、hook adapter、doctor 與 install module 歸 `hosts//{bin,hooks,lib}`;`hosts//host.yaml` 是它們的發現單一來源。CC-488 新增的 Codex explicit-memory writer 直接落在 `hosts/codex/bin/`,不再新增 `scripts/codex-*` debt。 +2. host-neutral canonical business logic 留在共用 runtime/CLI layer;例如 memory resolver/writer 不複製到各 host,host 目錄只保留薄 adapter。通用 ops/release 工具與 test harness 另有明確 domain,不因搬目錄而複製實作。 +3. 先產出 current→target path map、依賴方向與穩定入口清單,再按 domain 分批搬遷;禁止一次性全庫 rename。對已公開/已安裝路徑保留有期限、帶 deprecation 訊息的 shim,manifest/registry 與 tests 先切新路徑,最後移除 shim。 +4. 新增 layer/path lint:host-specific 名稱不得再出現在共享 core/runtime entrypoints;新增 host module 必須由 manifest 引用;suite 必須由 test registry 發現,不能靠散落硬編路徑。 + +**Acceptance**: (a) architecture map 說明每類 executable 的 owner 與允許依賴方向;(b) Codex、Claude、OpenCode install/doctor/uninstall 從各自 manifest 發現 module,shared dispatcher 不列 host-specific path;(c) staged migration 每一刀均通過 install parity、uninstall preservation、doctor、full runner 與 release smoke;(d) 最終 `scripts/` 只保留明確定義的相容入口或通用 ops entrypoints,不再作為所有 shell code 的默認垃圾桶。 + +**Boundary / sequencing**: 本票在 [[CC-488]] lifecycle product contract 完成後執行。CC-488 只遵守新檔案 placement 與 manifest discovery 原則,不藉機搬完既有 Codex/Claude/OpenCode scripts;避免把路徑遷移 regression 混入 canonical memory correctness。 + +**Source**: 2026-07-14 使用者指出所有 script 集中於 `scripts/` 造成後續維護困難,要求特定內容放回對應位置並統一讀取。 + +--- + +## CC-490 — project-scoped explicit memory config,避免跨 repo canonical bleed 🔵 active + +**Problem**: `~/.pm-dispatch/config` 的 `dispatch.memory_dir` 是全域單值,但 resolver 對每個 repo 都無條件套用。CC-488 gate R1 後的 live read-only probe 已確認:pm-dispatch、JapanJob、qa-testing-rules 雖有不同 stable `project_key`,三者目前都回報 `resolution_source=config` 並解析到 pm-dispatch 的 canonical memory dir。任何其他 repo 的 canonical append 因此可能成功但寫入錯誤專案,屬靜默 cross-project data bleed;同一問題亦使本機 fixture 在未隔離 config 時誤寫 live store。 + +**Boundary**: 本票處理 config schema/resolver 的 project scoping 與 live migration;不重做 [[CC-488]] 的 Codex hook/session/update routing,也不把多 repo memory 合併成一個共享資料庫。CC-488 的三 host 驗收只證明「同一 repo 跨 host」一致,不能當成「跨 repo 共用同一 dir」的授權。 + +**Requirement**: +1. explicit config 必須以 stable project identity 選址(例如 project-key keyed mapping 或等價 repo-scoped section),不得在未匹配 repo 時套用另一專案路徑。 +2. unmatched repo 應回到自身 legacy discovery 或明確 unavailable;matched explicit path 失效仍維持 fail closed,不得退回另一 repo/native store。 +3. 定義舊 `dispatch.memory_dir` 單值的相容與 migration 規則;在多 repo 環境不得繼續默認為全域 override,doctor/config lint 必須能指出 unsafe legacy-global 設定。 +4. 以 pm-dispatch、JapanJob、qa-testing-rules 三個不同 project key 做 isolated + live read-only E2E,證明同 repo Claude/Codex/OpenCode 仍同址、不同 repo 不同址;append 測試必須以 filesystem diff 證明零 cross-project write。 + +**Immediate safety note**: 在本票落地前,`dispatch.memory_dir` 只能視為 single-repo/single-purpose config;多 repo 操作不得把它當成安全的 machine-wide default。測試一律用 `PM_DISPATCH_CONFIG_FILE` 隔離,不得讀取 operator live config。 + +**Implementation (2026-07-14, in progress)**: config schema 改為 `memory.projects..dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。isolated regression 已覆蓋零 cross-project append;live config 已遷移為 pm-dispatch scoped mapping,JapanJob read-only probe 回到自身 legacy memory,config lint 0 issues。full-suite 與 gate 完成前本票維持 active。 + +**Acceptance**: 三 repo resolve 的 project key 與 memory dir 對應正確;跨 host 同 repo continuity 不退化;invalid matched config fail closed;unmatched repo 不使用 pm-dispatch memory;doctor/config diagnostics、memory/pm/guard regressions與 live migration evidence 全綠。 + +**Source**: CC-488 Claude full-tier gate R1 risk/QA findings;2026-07-14 live probe confirmed JapanJob (`01a9ed...`) and qa-testing-rules (`100334...`) both resolved to pm-dispatch memory through the global config value. + +--- + +## CC-491 — PR-gate pre-flight 機械式 evidence 與 reviewer reuse contract 🔵 active + +**Problem**: `pr-gate.sh --test-cmd` 目前只把 pre-flight 結果以 `Pre-flight test run: pass|fail` 提供給 reviewer,沒有傳遞實際 command、selected suites、逐 suite 結果或被驗證工作樹的 fingerprint。QA reviewer 因而無法判斷哪些 behavioral units 已有可信證據;即使 pre-flight 已通過,仍可能手工列舉相同 suite 再跑一次。`gate-20260714-145345-2998745` 中 9 個已通過的 suite 被 QA 重複執行,部分因 180 秒 timeout 又重跑,最終單一 sequential reviewer session 耗盡 1200 秒,只完成 critic、留下 inconclusive partial artifact。 + +**Boundary**: 本票建立 pre-flight producer → PR-gate → reviewer 的 evidence/reuse contract;不重寫 [[CC-481]] 的 direct-impact planner、不把 authoritative full suite 搬回 gate lifecycle,也不禁止 reviewer 對「既有 evidence 未覆蓋」的新行為做最小補充驗證。 + +**Requirements**: +1. 每次 pre-flight 必須產生 versioned、machine-readable result。generic `--test-cmd` 至少記錄經安全處理的 command identity、exit status、started/finished time、timeout、log path/digest 與工作樹 identity;使用 `scripts/run-tests.sh` 時必須再帶 selection mode、changed paths、selected suites、逐 suite pass/fail/timeout/duration,以及 aggregate status。 +2. 工作樹 identity 必須能判定 evidence 是否 stale,至少綁定 repo/project identity、base/head 與 dirty/untracked content fingerprint;pre-flight 後若受測內容改變,PR-gate 不得把舊 PASS 呈現為 current evidence。 +3. PR-gate 必須機械驗證 result schema、artifact digest、fingerprint 與實際 pre-flight exit status,再把結構化 evidence 摘要及 artifact pointer 放進 reviewer brief;不得只由自然語言宣稱 `pass`,也不得要求 reviewer 從自由格式 log 猜測已跑項目。 +4. QA reviewer 收到 current PASS evidence 後,必須先建立 behavioral-unit → existing-suite evidence 對照;已涵蓋 suite 不得 reflexively rerun。只有 evidence 未涵蓋、stale/invalid 或具體 flake 疑點時才能補跑最小 suite,且須在結果中記錄 gap、理由、command 與新增 evidence。 +5. Reviewer 不得以手寫 `for`/`&&` 清單取代 repo planner,也不得在 source working tree 建立 `.qa-test-*` 等暫存輸出;補充測試應使用 repo runner 的 selection/parallelism contract,輸出歸 gate run artifact directory。單獨指定一個 suite 時才允許 sequential execution。 +6. detached/foreground、artifact relocation 與 timeout 路徑都必須保留同一 evidence schema;partial/timeout gate 要能指出 pre-flight 已完成、哪些 reviewer 額外執行了什麼,以及重複 suite 數量,不得把 partial artifact 誤當 GO。 + +**Acceptance**: +- focused pre-flight 透過 `run-tests.sh` 後,reviewer brief 可機械讀出 changed paths、selected suites、逐 suite結果與一致的 tree fingerprint;QA 對完全涵蓋且 current 的 PASS evidence 執行 0 個重複 suite。 +- 新增一個未被 planner mapping 涵蓋的 behavioral unit 時,QA 只補跑能覆蓋該 gap 的最小測試並附理由;不得重跑其餘已通過 suite。 +- pre-flight 後修改 tracked 或 untracked 受測內容會使 evidence 判為 stale,gate/reviewer 不得 reuse 舊 PASS。 +- generic 不支援 rich result 的 `--test-cmd` 仍有可驗證的 basic artifact,並明確標示 coverage opaque,不偽造 selected-suite evidence。 +- regression 覆蓋 pass/fail/timeout、artifact tamper、tree drift、run-dir relocation,以及本次「9 個 suite 被重跑導致 reviewer timeout」案例。 + +**Source**: 2026-07-14 `gate-20260714-145345-2998745` post-mortem;pre-flight 已 PASS,但 brief 僅提供布林狀態,QA 重複執行相同 focused suites並使 sequential full-tier gate timeout/inconclusive。 + +**See**: [[CC-470]], [[CC-481]], [[CC-485]]. + +--- + ## CC-484 — JapanJob/qa-testing-rules pmctl context refresh 失效 ✅ 2026-07-13 **Problem**: JapanJob 的 session 與 qa-testing-rules 實際工作流沒有順利用 `pmctl context` 刷新內容。[[CC-455]] 曾修正 context plane 預設 repo root 跟隨 caller CWD,但目前 live 行為仍可能在 repo-root resolution、worktree/project key、context.db 位置、mtime skip、session hook/prepare wiring 或 update/query 順序其中一層失效;只看 unit test 不能確認是哪一層。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 330ed8b1..f57cbca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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 ` 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. diff --git a/cli/pmctl b/cli/pmctl index 8a0dac09..1581b4c2 100755 --- a/cli/pmctl +++ b/cli/pmctl @@ -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" diff --git a/docs/host-contract.md b/docs/host-contract.md index 9eb74229..3ec2d98b 100644 --- a/docs/host-contract.md +++ b/docs/host-contract.md @@ -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 @@ -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. diff --git a/docs/memory-system.md b/docs/memory-system.md index 7a3a2201..41a180d6 100644 --- a/docs/memory-system.md +++ b/docs/memory-system.md @@ -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..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//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. @@ -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`. @@ -234,13 +252,15 @@ 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 +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` | @@ -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 diff --git a/hosts/claude/host.yaml b/hosts/claude/host.yaml index 7e814fe4..cb1b1488 100644 --- a/hosts/claude/host.yaml +++ b/hosts/claude/host.yaml @@ -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 diff --git a/hosts/codex/bin/memory-update.sh b/hosts/codex/bin/memory-update.sh new file mode 100755 index 00000000..e585f660 --- /dev/null +++ b/hosts/codex/bin/memory-update.sh @@ -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 --summary + [--session-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[@]}" diff --git a/hosts/codex/host.yaml b/hosts/codex/host.yaml index 0edc0930..096eff40 100644 --- a/hosts/codex/host.yaml +++ b/hosts/codex/host.yaml @@ -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 @@ -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. @@ -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 diff --git a/hosts/codex/lib/memory-contract.sh b/hosts/codex/lib/memory-contract.sh new file mode 100644 index 00000000..02ef8916 --- /dev/null +++ b/hosts/codex/lib/memory-contract.sh @@ -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='' +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" < --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 +} diff --git a/scripts/guard-session-summary.sh b/scripts/guard-session-summary.sh index ec410aed..2ffae4b1 100755 --- a/scripts/guard-session-summary.sh +++ b/scripts/guard-session-summary.sh @@ -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 \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 @@ -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 diff --git a/scripts/install-guards-codex.sh b/scripts/install-guards-codex.sh index 038c5fb7..284fb40f 100755 --- a/scripts/install-guards-codex.sh +++ b/scripts/install-guards-codex.sh @@ -29,6 +29,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # shellcheck source=scripts/lib/host-manifest.sh . "$SCRIPT_DIR/lib/host-manifest.sh" +# shellcheck source=hosts/codex/lib/memory-contract.sh +. "$REPO_ROOT/hosts/codex/lib/memory-contract.sh" if ! command -v jq >/dev/null 2>&1; then echo "install-guards-codex: jq is required but not found on PATH" >&2 @@ -42,23 +44,38 @@ if [[ ! -f "$manifest" ]]; then fi hooks_path_template="" +instructions_path_template="" while IFS=$'\t' read -r id path fmt managed; do - [[ "$id" == "hooks" ]] || continue - [[ "$fmt" == "codex-hooks-json" ]] || continue [[ "$managed" == "true" ]] || continue - hooks_path_template="$path" + if [[ "$id" == "hooks" && "$fmt" == "codex-hooks-json" ]]; then + hooks_path_template="$path" + elif [[ "$id" == "instructions" && "$fmt" == "codex-agents-md" ]]; then + instructions_path_template="$path" + fi done < <(host_manifest_install_targets "$manifest") if [[ -z "$hooks_path_template" ]]; then echo "install-guards-codex: hosts/codex/host.yaml has no managed codex-hooks-json install_target — nothing to wire" >&2 exit 2 fi +if [[ -z "$instructions_path_template" ]]; then + echo "install-guards-codex: hosts/codex/host.yaml has no managed codex-agents-md install_target — nothing to wire" >&2 + exit 2 +fi hooks_file="$(host_manifest_expand_path "$hooks_path_template")" +instructions_file="$(host_manifest_expand_path "$instructions_path_template")" hook_cmd="$REPO_ROOT/scripts/hook-codex-command-guard.sh" memory_hook_cmd="$REPO_ROOT/scripts/guard-inject-memory.sh" +session_hook_cmd="$REPO_ROOT/scripts/guard-session-summary.sh" +memory_update_module="$(host_manifest_scalar "$manifest" memory_update_module)" +if [[ -z "$memory_update_module" || "$memory_update_module" == "null" ]]; then + echo "install-guards-codex: hosts/codex/host.yaml has no memory_update_module" >&2 + exit 2 +fi +memory_update_cmd="$REPO_ROOT/$memory_update_module" -if [[ ! -x "$hook_cmd" || ! -x "$memory_hook_cmd" ]]; then +if [[ ! -x "$hook_cmd" || ! -x "$memory_hook_cmd" || ! -x "$session_hook_cmd" || ! -x "$memory_update_cmd" ]]; then echo "install-guards-codex: managed hook missing or not executable" >&2 exit 2 fi @@ -70,10 +87,14 @@ fi # stored verbatim (no churn for existing installs). hook_cmd_q="$(printf '%q' "$hook_cmd")" memory_hook_cmd_q="$(printf '%q' "$memory_hook_cmd")" +session_hook_cmd_q="$(printf '%q' "$session_hook_cmd") --host codex" +memory_update_cmd_q="$(printf '%q' "$memory_update_cmd")" tmp_new="$(mktemp)" tmp_current="$(mktemp)" -trap 'rm -f "$tmp_new" "$tmp_current"' EXIT +tmp_instructions_new="$(mktemp)" +tmp_instructions_current="$(mktemp)" +trap 'rm -f "$tmp_new" "$tmp_current" "$tmp_instructions_new" "$tmp_instructions_current"' EXIT if [[ -f "$hooks_file" ]]; then cp "$hooks_file" "$tmp_current" @@ -84,35 +105,72 @@ 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" --arg memory_cmd "$memory_hook_cmd_q" ' +jq --arg cmd "$hook_cmd_q" --arg memory_cmd "$memory_hook_cmd_q" --arg session_cmd "$session_hook_cmd_q" ' .hooks = (.hooks // {}) | .hooks.PreToolUse = (.hooks.PreToolUse // []) | .hooks.UserPromptSubmit = (.hooks.UserPromptSubmit // []) | + .hooks.Stop = (.hooks.Stop // []) | ([.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}]}] 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 + (if $memory_already != null then . else .hooks.UserPromptSubmit += [{"hooks": [{"type": "command", "command": $memory_cmd}]}] end) | + ([.hooks.Stop[]? | .hooks[]?.command] | index($session_cmd)) as $session_already | + if $session_already != null then . else .hooks.Stop += [{"hooks": [{"type": "command", "command": $session_cmd}]}] end ' "$tmp_current" > "$tmp_new" -if cmp -s "$tmp_current" "$tmp_new"; then +if [[ -f "$instructions_file" ]]; then + cp "$instructions_file" "$tmp_instructions_current" +else + : > "$tmp_instructions_current" + [[ "$DRY_RUN" -eq 1 ]] && echo "install-guards-codex: would create $instructions_file" +fi + +if ! codex_memory_contract_strip "$tmp_instructions_current" "$tmp_instructions_new"; then + echo "install-guards-codex: malformed managed markers in $instructions_file — refusing to modify" >&2 + exit 2 +fi +codex_memory_contract_append "$tmp_instructions_new" "$memory_update_cmd_q" + +hooks_changed=0 +instructions_changed=0 +cmp -s "$tmp_current" "$tmp_new" || hooks_changed=1 +cmp -s "$tmp_instructions_current" "$tmp_instructions_new" || instructions_changed=1 +if [[ "$hooks_changed" -eq 0 && "$instructions_changed" -eq 0 ]]; then echo "install-guards-codex: already wired, nothing to do" exit 0 fi if [[ "$DRY_RUN" -eq 1 ]]; then - echo "install-guards-codex: would apply the following change to $hooks_file:" - diff -u "$tmp_current" "$tmp_new" || true + if [[ "$hooks_changed" -eq 1 ]]; then + echo "install-guards-codex: would apply the following change to $hooks_file:" + diff -u "$tmp_current" "$tmp_new" || true + fi + if [[ "$instructions_changed" -eq 1 ]]; then + echo "install-guards-codex: would apply the following change to $instructions_file:" + diff -u "$tmp_instructions_current" "$tmp_instructions_new" || true + fi exit 0 fi mkdir -p "$(dirname "$hooks_file")" +mkdir -p "$(dirname "$instructions_file")" [[ -f "$hooks_file" ]] || printf '{}\n' > "$hooks_file" -backup="$hooks_file.bak.$(date +%Y%m%d-%H%M%S)" -cp "$hooks_file" "$backup" -mv "$tmp_new" "$hooks_file" +timestamp="$(date +%Y%m%d-%H%M%S)" +if [[ "$hooks_changed" -eq 1 ]]; then + backup="$hooks_file.bak.$timestamp" + cp "$hooks_file" "$backup" + mv "$tmp_new" "$hooks_file" + echo "install-guards-codex: wrote $hooks_file" + echo "install-guards-codex: backup at $backup" +fi +if [[ "$instructions_changed" -eq 1 ]]; then + if [[ -f "$instructions_file" ]]; then + instructions_backup="$instructions_file.bak.$timestamp" + cp "$instructions_file" "$instructions_backup" + echo "install-guards-codex: backup at $instructions_backup" + fi + mv "$tmp_instructions_new" "$instructions_file" + echo "install-guards-codex: wrote $instructions_file" +fi trap - EXIT -echo "install-guards-codex: wrote $hooks_file" -echo "install-guards-codex: backup at $backup" diff --git a/scripts/install-guards.sh b/scripts/install-guards.sh index 22798f75..88b79639 100755 --- a/scripts/install-guards.sh +++ b/scripts/install-guards.sh @@ -130,7 +130,7 @@ fi pm_cmd="$repo_root/scripts/guard-pm-write.sh" stop_cmd="$repo_root/scripts/guard-log-claude-usage.sh" old_stop_cmd="$repo_root/hooks/guard-log-claude-usage.sh" -session_cmd="$repo_root/scripts/guard-session-summary.sh" +session_path="$repo_root/scripts/guard-session-summary.sh" inject_cmd="$repo_root/scripts/guard-inject-memory.sh" ctx_inject_cmd="$repo_root/scripts/guard-inject-context.sh" statusline_cmd="$repo_root/scripts/guard-save-rate-limits.sh" @@ -187,11 +187,11 @@ write_statusline_chain() { mv "$chain_tmp" "$statusline_chain_conf" } -if [ ! -x "$pm_cmd" ] || [ ! -x "$stop_cmd" ] || [ ! -x "$session_cmd" ] || [ ! -x "$inject_cmd" ] || [ ! -x "$ctx_inject_cmd" ] || [ ! -x "$statusline_cmd" ]; then +if [ ! -x "$pm_cmd" ] || [ ! -x "$stop_cmd" ] || [ ! -x "$session_path" ] || [ ! -x "$inject_cmd" ] || [ ! -x "$ctx_inject_cmd" ] || [ ! -x "$statusline_cmd" ]; then echo "install-guards: hook scripts missing or not executable" >&2 echo " $pm_cmd" >&2 echo " $stop_cmd" >&2 - echo " $session_cmd" >&2 + echo " $session_path" >&2 echo " $inject_cmd" >&2 echo " $ctx_inject_cmd" >&2 echo " $statusline_cmd" >&2 @@ -234,7 +234,7 @@ fi # path to remove it, so it must stay in raw (unescaped) form. pm_cmd_q="$(printf '%q' "$pm_cmd")" stop_cmd_q="$(printf '%q' "$stop_cmd")" -session_cmd_q="$(printf '%q' "$session_cmd")" +session_cmd_q="$(printf '%q' "$session_path") --host claude" inject_cmd_q="$(printf '%q' "$inject_cmd")" ctx_inject_cmd_q="$(printf '%q' "$ctx_inject_cmd")" statusline_cmd_q="$(printf '%q' "$statusline_cmd")" @@ -258,6 +258,8 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ --argjson bg_guards "$_bg_json" \ --arg profile "$PROFILE" \ ' + def without_host_arg: sub(" --host (claude|codex|opencode|generic)$"; ""); + # Ensure .hooks.PreToolUse exists as an array. .hooks //= {} | .hooks.PreToolUse //= [] | @@ -338,7 +340,7 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ # Helper: an entry already exists if any matcher block has a managed hook with the same command basename. ( [ .hooks.PreToolUse[]? | (.hooks // [])[]? | select((.command | split("/") | last) == ($pm | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") ] | length ) as $pm_present | ( [ .hooks.Stop[]? | (.hooks // [])[]? | select((.command | split("/") | last) == ($stop | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") ] | length ) as $stop_present | - ( [ .hooks.Stop[]? | (.hooks // [])[]? | select((.command | split("/") | last) == ($session | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") ] | length ) as $session_present | + ( [ .hooks.Stop[]? | (.hooks // [])[]? | select(((.command | without_host_arg | split("/") | last) == ($session | without_host_arg | split("/") | last)) and ((.command | without_host_arg | split("/") | .[-2]) == "scripts")) ] | length ) as $session_present | ( [ .hooks.UserPromptSubmit[]? | (.hooks // [])[]? | select((.command | split("/") | last) == ($inject | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") ] | length ) as $inject_present | ( [ .hooks.UserPromptSubmit[]? | (.hooks // [])[]? | select((.command | split("/") | last) == ($ctx_inject | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") ] | length ) as $ctx_inject_present | @@ -365,7 +367,7 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ .hooks.Stop |= map( .hooks |= map( if ((.command | split("/") | last) == ($stop | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") then .command = $stop - elif ((.command | split("/") | last) == ($session | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") then .command = $session + elif (((.command | without_host_arg | split("/") | last) == ($session | without_host_arg | split("/") | last)) and ((.command | without_host_arg | split("/") | .[-2]) == "scripts")) then .command = $session else . end ) ) | diff --git a/scripts/lib/doctor-host-claude.sh b/scripts/lib/doctor-host-claude.sh index a8a15407..0d547ff5 100644 --- a/scripts/lib/doctor-host-claude.sh +++ b/scripts/lib/doctor-host-claude.sh @@ -68,7 +68,9 @@ _doctor_host_claude_hook_present() { def managed_hook: (.command? // "") as $cmd | ($cmd | normalize_path) as $ncmd | - (($ncmd | split("/") | last) == $basename and ($ncmd | split("/") | .[-2]) == "scripts"); + ($ncmd | sub(" --host (claude|codex|opencode|generic)$"; "")) as $path | + (($path | split("/") | last) == $basename and ($path | split("/") | .[-2]) == "scripts") and + (if $basename == "guard-session-summary.sh" then ($ncmd | endswith("guard-session-summary.sh --host claude")) else true end); ([ ((.hooks // {}).PreToolUse[]? | (.hooks // [])[]? | select(managed_hook)), ((.hooks // {}).PostToolUse[]? | (.hooks // [])[]? | select(managed_hook)), diff --git a/scripts/lib/doctor-host-codex.sh b/scripts/lib/doctor-host-codex.sh index 23b4e150..d4956873 100644 --- a/scripts/lib/doctor-host-codex.sh +++ b/scripts/lib/doctor-host-codex.sh @@ -41,7 +41,7 @@ _doctor_host_codex_hooks() { "codex hooks.json exists but is not valid JSON ($codex_home/hooks.json)" return fi - if _doctor_host_codex_target_installed "$codex_home/hooks.json" codex-hooks-json; then + if _doctor_host_codex_hook_present "$codex_home/hooks.json" command_guard; then emit_capability host.codex.hooks ok codex command_guard \ host_hook blocking partial evolving probed \ "codex hook surface wired ($codex_home/hooks.json; command coverage full, file-write needs patch parsing)" @@ -50,13 +50,54 @@ _doctor_host_codex_hooks() { none none none evolving probed \ "codex hook surface not wired ($codex_home/hooks.json exists but does not contain this checkout's managed hook; opt-in via install.sh --enable-codex-command-guard)" fi + if _doctor_host_codex_hook_present "$codex_home/hooks.json" memory_injection; then + emit_check host.codex.memory-injection ok "canonical UserPromptSubmit memory injection wired" + else + emit_check host.codex.memory-injection ok "canonical UserPromptSubmit memory injection not wired (opt-in host wiring)" + fi + if _doctor_host_codex_hook_present "$codex_home/hooks.json" session_lifecycle; then + emit_capability host.codex.session-lifecycle ok codex session_lifecycle \ + host_hook none partial evolving probed \ + "canonical Stop session writer wired with host=codex" + else + emit_capability host.codex.session-lifecycle ok codex session_lifecycle \ + none none none evolving probed \ + "canonical Stop session writer not wired (opt-in host wiring)" + fi else emit_capability host.codex.hooks ok codex command_guard \ none none none evolving probed \ "codex hook surface not wired (no $codex_home/hooks.json; opt-in via install.sh --enable-codex-command-guard)" + emit_check host.codex.memory-injection ok "canonical UserPromptSubmit memory injection not wired (no hooks.json)" + emit_capability host.codex.session-lifecycle ok codex session_lifecycle \ + none none none evolving probed \ + "canonical Stop session writer not wired (no hooks.json)" fi } +_doctor_host_codex_hook_present() { + local hooks_file="$1" kind="$2" command command_q event matcher="" + case "$kind" in + command_guard) + command="$REPO_ROOT/scripts/hook-codex-command-guard.sh"; event="PreToolUse"; matcher="Bash" ;; + memory_injection) + command="$REPO_ROOT/scripts/guard-inject-memory.sh"; event="UserPromptSubmit" ;; + session_lifecycle) + command="$REPO_ROOT/scripts/guard-session-summary.sh --host codex" + command_q="$(printf '%q' "$REPO_ROOT/scripts/guard-session-summary.sh") --host codex" + event="Stop" + ;; + *) return 1 ;; + esac + [[ -n "${command_q:-}" ]] || command_q="$(printf '%q' "$command")" + jq -e --arg event "$event" --arg matcher "$matcher" --arg cmd "$command" --arg cmd_q "$command_q" ' + (.hooks[$event] // [])[]? + | select(($matcher == "") or (.matcher == $matcher)) + | (.hooks // [])[]?.command + | select(. == $cmd or . == $cmd_q) + ' "$hooks_file" >/dev/null 2>&1 +} + # Per-target installed check: for the codex-hooks-json format this verifies # the managed hook COMMAND is actually wired inside the hooks file, not merely # that the file exists — a hooks.json holding only unrelated entries (e.g. a @@ -72,13 +113,15 @@ _doctor_host_codex_target_installed() { if [[ "$fmt" == "codex-hooks-json" ]]; then [[ -f "$expanded" ]] || return 1 command -v jq >/dev/null 2>&1 || return 1 - local hook_cmd="$REPO_ROOT/scripts/hook-codex-command-guard.sh" - local hook_cmd_q - hook_cmd_q="$(printf '%q' "$hook_cmd")" - jq -e --arg cmd "$hook_cmd" --arg cmd_q "$hook_cmd_q" ' - (.hooks.PreToolUse // [])[]? | select(.matcher == "Bash") | (.hooks // [])[]?.command - | select(. == $cmd or . == $cmd_q) - ' "$expanded" >/dev/null 2>&1 + _doctor_host_codex_hook_present "$expanded" command_guard \ + && _doctor_host_codex_hook_present "$expanded" memory_injection \ + && _doctor_host_codex_hook_present "$expanded" session_lifecycle + return $? + fi + if [[ "$fmt" == "codex-agents-md" ]]; then + [[ -f "$expanded" ]] || return 1 + grep -Fqx '' "$expanded" \ + && grep -Fqx '' "$expanded" return $? fi [[ -e "$expanded" ]] diff --git a/scripts/lib/memory-dir.sh b/scripts/lib/memory-dir.sh index 530ca902..6500b0cc 100644 --- a/scripts/lib/memory-dir.sh +++ b/scripts/lib/memory-dir.sh @@ -13,11 +13,19 @@ if ! declare -F pm_config_load >/dev/null 2>&1; then . "$(dirname "${BASH_SOURCE[0]}")/pmctl-config.sh" 2>/dev/null || true fi +if ! declare -F _sw_worktree_project_key >/dev/null 2>&1; then + # shellcheck source=scripts/lib/state-paths.sh + # shellcheck disable=SC1091 + . "$(dirname "${BASH_SOURCE[0]}")/state-paths.sh" 2>/dev/null || true +fi + # Installer/migrator callers support the routing log directory override. # Hook callers source memory.sh directly and must NOT inherit this routing-specific # behavior; they always use project memory from CLAUDE_CONFIG_DIR. find_memory_dir() { - declare -F pm_config_load >/dev/null 2>&1 && [[ -z "${PM_CFG_MEMORY_DIR:-}" ]] && pm_config_load + local cwd="$1" project_key="" + declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$cwd" 2>/dev/null || true)" + declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key" local override if override="$(_pm_memory_dir_override)"; then printf '%s' "$override"; return 0 @@ -26,7 +34,6 @@ find_memory_dir() { if [[ -n "$routing_dir" && -d "$routing_dir" ]]; then printf '%s' "$routing_dir"; return 0 fi - local cwd="$1" local config_dir="${2:-${CLAUDE_CONFIG_DIR:-${HOME}/.claude}}" local projects_dir current candidate parent projects_dir="$config_dir/projects" diff --git a/scripts/lib/pmctl-config.sh b/scripts/lib/pmctl-config.sh index fafa1860..0c3b58db 100644 --- a/scripts/lib/pmctl-config.sh +++ b/scripts/lib/pmctl-config.sh @@ -7,8 +7,12 @@ # PM_CFG_DEFAULT_MODEL — dispatch.default_model (alias/id), or "" # PM_CFG_AUTO_PACK — dispatch.auto_pack (on|off), or "" # PM_CFG_LIFECYCLE — dispatch.lifecycle (foreground|detached), or "" -# PM_CFG_MEMORY_DIR — dispatch.memory_dir (absolute path), or "" -# PM_CFG_MEMORY_DIR_INVALID — 1 when dispatch.memory_dir was present but invalid +# PM_CFG_MEMORY_DIR — memory.projects..dir for the requested +# project (absolute path), or "" +# PM_CFG_MEMORY_DIR_INVALID — 1 when the matched project entry is invalid, or +# when only unsafe legacy dispatch.memory_dir exists +# PM_CFG_MEMORY_CONFIG_STATUS — none|matched|matched-invalid|legacy-global +# PM_CFG_LEGACY_MEMORY_DIR — deprecated machine-wide dispatch.memory_dir # PM_CFG_USAGE_LOG_PATH — dispatch.usage_log_path (absolute path to log-usage.sh), or "" # # Config file: ${PM_DISPATCH_CONFIG_FILE:-~/.pm-dispatch/config} @@ -21,11 +25,31 @@ PM_CFG_AUTO_PACK="" PM_CFG_LIFECYCLE="" PM_CFG_MEMORY_DIR="" PM_CFG_MEMORY_DIR_INVALID=0 +PM_CFG_MEMORY_CONFIG_STATUS="none" +PM_CFG_LEGACY_MEMORY_DIR="" +PM_CFG_LEGACY_MEMORY_DIR_INVALID=0 PM_CFG_USAGE_LOG_PATH="" +# Resolve the same stable identity used by project state. Git worktrees share +# their primary checkout's key; non-git diagnostic fixtures fall back to a +# canonical-path hash instead of the unsafe state-store "global" partition. +pm_config_project_key() { + local repo_root="$1" project_key="" + if declare -F _sw_worktree_project_key >/dev/null 2>&1; then + project_key="$(_SW_REPO_ROOT="$repo_root" _sw_worktree_project_key 2>/dev/null || true)" + fi + if [[ -z "$project_key" || "$project_key" == "global" ]] && declare -F _portable_sha1 >/dev/null 2>&1; then + repo_root="$(cd "$repo_root" 2>/dev/null && pwd -P || printf '%s' "$repo_root")" + project_key="$(printf '%s\n' "$repo_root" | _portable_sha1 2>/dev/null || true)" + fi + [[ "$project_key" =~ ^[0-9a-f]{40}$ ]] || return 1 + printf '%s\n' "$project_key" +} + pm_config_load() { + local _memory_project_key="${1:-}" local _cfg_path="${PM_DISPATCH_CONFIG_FILE:-${HOME}/.pm-dispatch/config}" - local _line_no=0 _line _key _value + local _line_no=0 _line _key _value _memory_match_count=0 PM_CFG_TIMEOUT="" PM_CFG_DEFAULT_MODEL="" @@ -33,6 +57,9 @@ pm_config_load() { PM_CFG_LIFECYCLE="" PM_CFG_MEMORY_DIR="" PM_CFG_MEMORY_DIR_INVALID=0 + PM_CFG_MEMORY_CONFIG_STATUS="none" + PM_CFG_LEGACY_MEMORY_DIR="" + PM_CFG_LEGACY_MEMORY_DIR_INVALID=0 PM_CFG_USAGE_LOG_PATH="" [[ -r "$_cfg_path" ]] || return 0 @@ -82,10 +109,33 @@ pm_config_load() { ;; dispatch.memory_dir) if [[ "$_value" == /* ]]; then - PM_CFG_MEMORY_DIR="$_value" + PM_CFG_LEGACY_MEMORY_DIR="$_value" else - PM_CFG_MEMORY_DIR_INVALID=1 - printf 'pm-dispatch: config: warning: malformed value for dispatch.memory_dir in %s:%d (must be absolute path); ignoring\n' "$_cfg_path" "$_line_no" >&2 + PM_CFG_LEGACY_MEMORY_DIR_INVALID=1 + fi + ;; + memory.projects.*.dir) + if [[ "$_key" =~ ^memory\.projects\.([0-9a-f]{40})\.dir$ ]]; then + if [[ -n "$_memory_project_key" && "${BASH_REMATCH[1]}" == "$_memory_project_key" ]]; then + _memory_match_count=$((_memory_match_count + 1)) + if [[ "$_memory_match_count" -gt 1 ]]; then + PM_CFG_MEMORY_DIR="" + PM_CFG_MEMORY_CONFIG_STATUS="matched-invalid" + PM_CFG_MEMORY_DIR_INVALID=1 + printf 'pm-dispatch: config: warning: duplicate project memory key in %s:%d: %s\n' "$_cfg_path" "$_line_no" "$_key" >&2 + elif [[ "$_value" == /* ]]; then + PM_CFG_MEMORY_DIR="$_value" + PM_CFG_MEMORY_CONFIG_STATUS="matched" + PM_CFG_MEMORY_DIR_INVALID=0 + else + PM_CFG_MEMORY_DIR="" + PM_CFG_MEMORY_CONFIG_STATUS="matched-invalid" + PM_CFG_MEMORY_DIR_INVALID=1 + printf 'pm-dispatch: config: warning: malformed value for %s in %s:%d (must be absolute path)\n' "$_key" "$_cfg_path" "$_line_no" >&2 + fi + fi + elif [[ -n "$_memory_project_key" ]]; then + printf 'pm-dispatch: config: warning: malformed project memory key in %s:%d: %s\n' "$_cfg_path" "$_line_no" "$_key" >&2 fi ;; dispatch.usage_log_path) @@ -97,4 +147,12 @@ pm_config_load() { ;; esac done < "$_cfg_path" + + if [[ -n "$_memory_project_key" && "$PM_CFG_MEMORY_CONFIG_STATUS" == "none" \ + && ( -n "$PM_CFG_LEGACY_MEMORY_DIR" || "$PM_CFG_LEGACY_MEMORY_DIR_INVALID" -eq 1 ) ]]; then + PM_CFG_MEMORY_CONFIG_STATUS="legacy-global" + PM_CFG_MEMORY_DIR_INVALID=1 + printf 'pm-dispatch: config: warning: unsafe global dispatch.memory_dir in %s is ignored; migrate it to memory.projects.%s.dir\n' \ + "$_cfg_path" "$_memory_project_key" >&2 + fi } diff --git a/scripts/lib/pmctl-context.sh b/scripts/lib/pmctl-context.sh index 40c0545e..3c65e4b2 100755 --- a/scripts/lib/pmctl-context.sh +++ b/scripts/lib/pmctl-context.sh @@ -23,7 +23,7 @@ if [[ "$(type -t find_memory_dir 2>/dev/null)" != function ]]; then fi # Source pmctl-config.sh explicitly (not pmctl-dispatch.sh or an adapter module, -# so the "MUST NOT source adapters" rule does not apply) so dispatch.memory_dir +# so the "MUST NOT source adapters" rule does not apply) so project-scoped memory # resolution does not depend on cli/pmctl's lib-load ordering happening to have # already sourced it via pmctl-dispatch.sh. if [[ "$(type -t pm_config_load 2>/dev/null)" != function ]]; then @@ -76,8 +76,10 @@ _ctx_memory_db_path() { # when memory.sh is unavailable or no memory dir exists up the tree. _ctx_resolve_memory_dir() { local cwd="$1" + local project_key="" declare -F find_memory_dir >/dev/null 2>&1 || return 1 - declare -F pm_config_load >/dev/null 2>&1 && [[ -z "${PM_CFG_MEMORY_DIR:-}" ]] && pm_config_load + declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$cwd" 2>/dev/null || true)" + declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key" find_memory_dir "$cwd" 2>/dev/null } diff --git a/scripts/lib/pmctl-memory-config.sh b/scripts/lib/pmctl-memory-config.sh new file mode 100644 index 00000000..24bbb238 --- /dev/null +++ b/scripts/lib/pmctl-memory-config.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Project-scoped canonical-memory config management. +# Source this file; public entrypoint: pmctl_memory_config. + +_pm_memory_config_usage() { + cat <<'EOF' +Usage: + pmctl memory config set --repo-root --memory-dir [--json] + pmctl memory config migrate --repo-root [--json] + pmctl memory config lint [--json] + +Config key: memory.projects..dir = + +`migrate` converts the deprecated machine-wide dispatch.memory_dir entry into +one project-scoped entry. Writes are atomic and preserve unrelated config. +EOF +} + +_pm_memory_config_path() { + printf '%s\n' "${PM_DISPATCH_CONFIG_FILE:-${HOME}/.pm-dispatch/config}" +} + +_pm_memory_config_identity() { + local requested="$1" repo_root project_key + repo_root="$(cd "$requested" 2>/dev/null && pwd -P)" || { + printf 'pmctl memory config: --repo-root must name an existing directory\n' >&2 + return 2 + } + repo_root="$(git -C "$repo_root" rev-parse --show-toplevel 2>/dev/null)" || { + printf 'pmctl memory config: --repo-root must be inside a git worktree\n' >&2 + return 2 + } + declare -F pm_config_project_key >/dev/null 2>&1 || { + printf 'pmctl memory config: stable project-key resolver unavailable\n' >&2 + return 1 + } + project_key="$(pm_config_project_key "$repo_root")" + [[ "$project_key" =~ ^[0-9a-f]{40}$ ]] || { + printf 'pmctl memory config: failed to derive a stable project key\n' >&2 + return 1 + } + printf '%s\t%s\n' "$repo_root" "$project_key" +} + +_pm_memory_config_write_guard() { + local config="$1" parent nlink + parent="$(dirname "$config")" + if [[ -L "$parent" || ( -e "$parent" && ! -d "$parent" ) ]]; then + printf 'pmctl memory config: refusing non-directory or symlinked config parent: %s\n' "$parent" >&2 + return 1 + fi + if [[ -e "$config" ]]; then + [[ -f "$config" && ! -L "$config" ]] || { + printf 'pmctl memory config: refusing non-regular or symlinked config: %s\n' "$config" >&2 + return 1 + } + nlink="$(stat -c '%h' "$config" 2>/dev/null || stat -f '%l' "$config" 2>/dev/null || printf '1')" + [[ "$nlink" == "1" ]] || { + printf 'pmctl memory config: refusing hardlinked config: %s\n' "$config" >&2 + return 1 + } + fi + mkdir -p "$parent" || return 1 + chmod 700 "$parent" 2>/dev/null || true +} + +_pm_memory_config_set_value_inner() { + local config="$1" key="$2" value="$3" remove_legacy="${4:-0}" + local tmp + tmp="$(mktemp "$(dirname "$config")/.config.tmp.XXXXXX")" || return 1 + chmod 600 "$tmp" 2>/dev/null || true + if [[ -r "$config" ]]; then + awk -v target="$key" -v remove_legacy="$remove_legacy" ' + { + line=$0; parsed=line + sub(/[[:space:]]*#.*/, "", parsed) + split(parsed, fields, "=") + k=fields[1] + gsub(/^[[:space:]]+|[[:space:]]+$/, "", k) + if (k == target) next + if (remove_legacy == "1" && k == "dispatch.memory_dir") next + print line + } + ' "$config" > "$tmp" || { rm -f "$tmp"; return 1; } + fi + printf '%s = %s\n' "$key" "$value" >> "$tmp" || { rm -f "$tmp"; return 1; } + mv "$tmp" "$config" +} + +_pm_memory_config_set_value() { + local config="$1" + _pm_memory_config_write_guard "$config" || return 1 + declare -F serialize_with_lock >/dev/null 2>&1 || { + printf 'pmctl memory config: lock helper unavailable\n' >&2 + return 1 + } + serialize_with_lock "$config" _pm_memory_config_set_value_inner "$@" +} + +_pm_memory_config_emit() { + local json="$1" action="$2" repo_root="$3" project_key="$4" memory_dir="$5" config="$6" + if [[ "$json" -eq 1 ]]; then + jq -cn --arg action "$action" --arg repo_root "$repo_root" --arg project_key "$project_key" \ + --arg memory_dir "$memory_dir" --arg config "$config" \ + '{schema_version:1,status:"ok",action:$action,repo_root:$repo_root,project_key:$project_key,memory_dir:$memory_dir,config_file:$config}' + else + printf 'status: ok\naction: %s\nrepo_root: %s\nproject_key: %s\nmemory_dir: %s\nconfig_file: %s\n' \ + "$action" "$repo_root" "$project_key" "$memory_dir" "$config" + fi +} + +_pm_memory_config_set() { + local repo_root="" memory_dir="" json=0 identity project_key config + while [[ $# -gt 0 ]]; do + case "$1" in + --repo-root) [[ $# -ge 2 ]] || return 2; repo_root="$2"; shift 2 ;; + --memory-dir) [[ $# -ge 2 ]] || return 2; memory_dir="$2"; shift 2 ;; + --json) json=1; shift ;; + *) printf 'pmctl memory config set: unknown argument: %s\n' "$1" >&2; return 2 ;; + esac + done + [[ -n "$repo_root" && -n "$memory_dir" ]] || { _pm_memory_config_usage >&2; return 2; } + [[ "$memory_dir" == /* && -d "$memory_dir" ]] || { + printf 'pmctl memory config set: --memory-dir must be an existing absolute directory\n' >&2 + return 2 + } + [[ "$memory_dir" != *$'\n'* && "$memory_dir" != *'#'* ]] || { + printf 'pmctl memory config set: --memory-dir contains characters unsupported by the config format\n' >&2 + return 2 + } + identity="$(_pm_memory_config_identity "$repo_root")" || return $? + repo_root="${identity%%$'\t'*}"; project_key="${identity#*$'\t'}" + memory_dir="$(cd "$memory_dir" && pwd -P)" + config="$(_pm_memory_config_path)" + # A successful scoped set supersedes and removes the unsafe global key so the + # config is immediately lint-clean rather than leaving a latent cross-project + # selector behind. + _pm_memory_config_set_value "$config" "memory.projects.$project_key.dir" "$memory_dir" 1 || return 1 + _pm_memory_config_emit "$json" set "$repo_root" "$project_key" "$memory_dir" "$config" +} + +_pm_memory_config_migrate() { + local repo_root="" json=0 identity project_key config memory_dir + while [[ $# -gt 0 ]]; do + case "$1" in + --repo-root) [[ $# -ge 2 ]] || return 2; repo_root="$2"; shift 2 ;; + --json) json=1; shift ;; + *) printf 'pmctl memory config migrate: unknown argument: %s\n' "$1" >&2; return 2 ;; + esac + done + [[ -n "$repo_root" ]] || { _pm_memory_config_usage >&2; return 2; } + identity="$(_pm_memory_config_identity "$repo_root")" || return $? + repo_root="${identity%%$'\t'*}"; project_key="${identity#*$'\t'}" + config="$(_pm_memory_config_path)" + declare -F pm_config_load >/dev/null 2>&1 || return 1 + pm_config_load "$project_key" + memory_dir="${PM_CFG_LEGACY_MEMORY_DIR:-}" + if [[ -z "$memory_dir" && "${PM_CFG_MEMORY_CONFIG_STATUS:-none}" == "matched" \ + && -d "${PM_CFG_MEMORY_DIR:-}" ]]; then + memory_dir="$(cd "$PM_CFG_MEMORY_DIR" && pwd -P)" + _pm_memory_config_emit "$json" migrate-noop "$repo_root" "$project_key" "$memory_dir" "$config" + return 0 + fi + [[ -n "$memory_dir" && -d "$memory_dir" ]] || { + printf 'pmctl memory config migrate: no valid dispatch.memory_dir to migrate in %s\n' "$config" >&2 + return 1 + } + memory_dir="$(cd "$memory_dir" && pwd -P)" + _pm_memory_config_set_value "$config" "memory.projects.$project_key.dir" "$memory_dir" 1 || return 1 + _pm_memory_config_emit "$json" migrate "$repo_root" "$project_key" "$memory_dir" "$config" +} + +_pm_memory_config_lint() { + local json=0 config issues_file issue_count=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --json) json=1; shift ;; + *) printf 'pmctl memory config lint: unknown argument: %s\n' "$1" >&2; return 2 ;; + esac + done + config="$(_pm_memory_config_path)" + issues_file="$(mktemp)" || return 1 + if [[ -r "$config" ]]; then + awk ' + { + line=$0; parsed=line + sub(/[[:space:]]*#.*/, "", parsed) + if (parsed !~ /=/) next + split(parsed, fields, "=") + key=fields[1]; value=substr(parsed, index(parsed, "=")+1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + if (key == "dispatch.memory_dir") print NR "\tunsafe-legacy-global\t" key + else if (key ~ /^memory\.projects\./ && key !~ /^memory\.projects\.[0-9a-f]{40}\.dir$/) print NR "\tmalformed-project-key\t" key + else if (key ~ /^memory\.projects\.[0-9a-f]{40}\.dir$/) { + if (seen[key]++) print NR "\tduplicate-project-key\t" key + if (value !~ /^\//) print NR "\tnon-absolute-memory-dir\t" key + } + } + ' "$config" > "$issues_file" + local line_no=0 line parsed key value + while IFS= read -r line || [[ -n "$line" ]]; do + line_no=$((line_no + 1)) + parsed="${line%%#*}" + [[ "$parsed" == *"="* ]] || continue + key="${parsed%%=*}"; value="${parsed#*=}" + key="${key#"${key%%[![:space:]]*}"}"; key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}"; value="${value%"${value##*[![:space:]]}"}" + if [[ "$key" =~ ^memory\.projects\.[0-9a-f]{40}\.dir$ && "$value" == /* && ! -d "$value" ]]; then + printf '%s\tmissing-memory-dir\t%s\n' "$line_no" "$key" >> "$issues_file" + fi + done < "$config" + fi + issue_count="$(wc -l < "$issues_file" | tr -d ' ')" + if [[ "$json" -eq 1 ]]; then + jq -Rn --arg config "$config" --argjson count "$issue_count" ' + [inputs | split("\t") | {line:(.[0]|tonumber),code:.[1],key:.[2]}] as $issues | + {schema_version:1,status:(if $count == 0 then "ok" else "issues" end),config_file:$config,issues_count:$count,issues:$issues} + ' < "$issues_file" + else + printf 'config_file: %s\nissues_count: %s\n' "$config" "$issue_count" + while IFS=$'\t' read -r line code key; do + [[ -n "$line" ]] && printf ' line %s: %s (%s)\n' "$line" "$code" "$key" + done < "$issues_file" + fi + rm -f "$issues_file" + [[ "$issue_count" -eq 0 ]] +} + +pmctl_memory_config() { + local action="${1:-}" + [[ $# -gt 0 ]] && shift + case "$action" in + set) _pm_memory_config_set "$@" ;; + migrate) _pm_memory_config_migrate "$@" ;; + lint) _pm_memory_config_lint "$@" ;; + -h|--help|"") _pm_memory_config_usage ;; + *) printf 'pmctl memory config: unknown action: %s\n' "$action" >&2; return 2 ;; + esac +} diff --git a/scripts/lib/pmctl-memory.sh b/scripts/lib/pmctl-memory.sh index dc4b1bd3..5d9fab99 100644 --- a/scripts/lib/pmctl-memory.sh +++ b/scripts/lib/pmctl-memory.sh @@ -24,7 +24,7 @@ if [[ "$(type -t serialize_with_lock 2>/dev/null)" != function ]]; then . "$(dirname "${BASH_SOURCE[0]}")/portable.sh" fi -# Explicitly own the dispatch.memory_dir config dependency (not pmctl-dispatch.sh +# Explicitly own the project-scoped memory 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. if [[ "$(type -t pm_config_load 2>/dev/null)" != function ]]; then @@ -41,10 +41,19 @@ if [[ "$(type -t _sw_worktree_project_key 2>/dev/null)" != function ]]; then . "$(dirname "${BASH_SOURCE[0]}")/state-paths.sh" 2>/dev/null || true fi -# Loads ~/.pm-dispatch/config's dispatch.memory_dir into PM_CFG_MEMORY_DIR -# when no caller has already populated it. +if [[ "$(type -t pmctl_memory_config 2>/dev/null)" != function ]]; then + # shellcheck source=scripts/lib/pmctl-memory-config.sh + # shellcheck disable=SC1091 + . "$(dirname "${BASH_SOURCE[0]}")/pmctl-memory-config.sh" 2>/dev/null || true +fi + +# Selects ~/.pm-dispatch/config's project-scoped memory entry for repo_root. +# Reload on every call so a long-lived shell cannot leak one repo's selection +# into another repo. _pmctl_memory_load_config_override() { - declare -F pm_config_load >/dev/null 2>&1 && [[ -z "${PM_CFG_MEMORY_DIR:-}" ]] && pm_config_load + local repo_root="${1:-$PWD}" project_key="" + declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$repo_root" 2>/dev/null || true)" + declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key" return 0 } @@ -53,7 +62,7 @@ _pmctl_memory_load_config_override() { pmctl_memory_dir() { local cwd="${1:-$PWD}" local mem_dir - _pmctl_memory_load_config_override + _pmctl_memory_load_config_override "$cwd" mem_dir="$(find_memory_dir "$cwd")" || return 1 printf '%s\n' "$mem_dir" } @@ -137,7 +146,7 @@ pmctl_memory_resolve() { else project_key="$repo_root" fi - _pmctl_memory_load_config_override + _pmctl_memory_load_config_override "$repo_root" if [[ -n "${PM_MEMORY_DIR:-}" ]]; then source="env" @@ -148,14 +157,16 @@ pmctl_memory_resolve() { else memory_dir="$(cd "${PM_MEMORY_DIR}" && pwd -P)"; status="resolved"; rc=0 fi + elif [[ "${PM_CFG_MEMORY_CONFIG_STATUS:-none}" == "legacy-global" ]]; then + source="config-legacy-global"; status="invalid-explicit"; reason="unsafe global dispatch.memory_dir must be migrated to a project-scoped entry"; rc=3 elif [[ "${PM_CFG_MEMORY_DIR_INVALID:-0}" -eq 1 ]]; then - source="config"; status="invalid-explicit"; reason="dispatch.memory_dir must be an absolute path"; rc=3 + source="config"; status="invalid-explicit"; reason="matched project memory path must be an absolute path"; rc=3 elif [[ -n "${PM_CFG_MEMORY_DIR:-}" ]]; then source="config" if [[ "${PM_CFG_MEMORY_DIR}" != /* ]]; then - status="invalid-explicit"; reason="dispatch.memory_dir must be an absolute path"; rc=3 + status="invalid-explicit"; reason="matched project memory path must be an absolute path"; rc=3 elif [[ ! -d "${PM_CFG_MEMORY_DIR}" ]]; then - status="invalid-explicit"; reason="dispatch.memory_dir does not exist or is not a directory"; rc=3 + status="invalid-explicit"; reason="matched project memory path does not exist or is not a directory"; rc=3 else memory_dir="$(cd "${PM_CFG_MEMORY_DIR}" && pwd -P)"; status="resolved"; rc=0 fi @@ -174,9 +185,9 @@ pmctl_memory_resolve() { _pmctl_memory_append_episode_usage() { cat <<'EOF' -Usage: pmctl memory append-episode --repo-root --summary +Usage: pmctl memory append-episode --repo-root --host --summary [--session-id ] [--date ] - [--host ] [--skeleton] + [--skeleton] [--allow-non-git] [--json] Append one JSONL episode through the strict canonical resolver. An invalid @@ -246,7 +257,7 @@ _pmctl_memory_append_episode_inner() { # 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 repo_root="$PWD" summary="" session_id="" episode_date="" host="" local mode="summary" allow_non_git=0 json=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -275,6 +286,11 @@ pmctl_memory_append_episode() { esac done + [[ -n "$host" ]] || { + printf 'pmctl memory append-episode: --host is required; provenance must name the actual initiating host\n' >&2 + return 2 + } + [[ "$mode" == "skeleton" && -n "$session_id" ]] || [[ -n "${summary//[[:space:]]/}" ]] || { printf 'pmctl memory append-episode: --summary must not be empty\n' >&2 return 2 @@ -480,7 +496,7 @@ pmctl_memory_doctor() { done local mem_dir="" - _pmctl_memory_load_config_override + _pmctl_memory_load_config_override "$repo_root" if ! mem_dir="$(find_memory_dir "$repo_root")"; then mem_dir="" fi @@ -743,7 +759,7 @@ pmctl_memory_shard() { done local mem_dir="" - _pmctl_memory_load_config_override + _pmctl_memory_load_config_override "$repo_root" mem_dir="$(find_memory_dir "$repo_root")" || { printf 'pmctl memory shard: no memory directory found\n' >&2; return 1; } local ep="$mem_dir/episodes.jsonl" @@ -807,7 +823,7 @@ pmctl_memory_rebuild_summary() { done local mem_dir="" - _pmctl_memory_load_config_override + _pmctl_memory_load_config_override "$repo_root" mem_dir="$(find_memory_dir "$repo_root")" || { printf 'pmctl memory rebuild-summary: no memory directory found\n' >&2; return 1; } local ep="$mem_dir/episodes.jsonl" diff --git a/scripts/lib/test-memory-config-fixtures.sh b/scripts/lib/test-memory-config-fixtures.sh new file mode 100644 index 00000000..545e505f --- /dev/null +++ b/scripts/lib/test-memory-config-fixtures.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Shared project-scoped memory-config fixture helpers. +# Source from tests after their temporary root is initialized. + +memory_fixture_project_key() { + local repo="$1" root + root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null || (cd "$repo" && pwd -P))" + if command -v sha1sum >/dev/null 2>&1; then + printf '%s\n' "$root" | sha1sum | awk '{print $1}' + else + printf '%s\n' "$root" | shasum -a 1 | awk '{print $1}' + fi +} + +write_project_memory_config() { + local config="$1" repo="$2" memory_dir="$3" + printf 'memory.projects.%s.dir = %s\n' "$(memory_fixture_project_key "$repo")" "$memory_dir" > "$config" +} diff --git a/scripts/lint-scripts.sh b/scripts/lint-scripts.sh index 317e41f9..6a141c5e 100755 --- a/scripts/lint-scripts.sh +++ b/scripts/lint-scripts.sh @@ -1,30 +1,42 @@ #!/usr/bin/env bash -# Validate hygiene of every scripts/*.sh: -# - file is executable (mode +x) +# Validate hygiene of shell entrypoints and host-owned shell modules: +# - entrypoints are executable (mode +x) # - first line is a shebang (#!...) # - parses cleanly under `bash -n` -# - declares `set -uo pipefail` or `set -euo pipefail` near the top +# - entrypoints declare `set -uo pipefail` or `set -euo pipefail` near the top +# +# Stable scan roots: +# scripts/*.sh shared entrypoints +# hosts/*/bin/*.sh host-owned entrypoints +# hosts/*/lib/*.sh sourced host modules (not required to be +x or +# change their caller's shell options) # # Exit 0 if all clean; 1 if any violation. set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" -scripts_dir="$repo_root/scripts" - -if [ ! -d "$scripts_dir" ]; then - echo "lint-scripts: $scripts_dir not found" >&2 +if [ ! -d "$repo_root/scripts" ] || [ ! -d "$repo_root/hosts" ]; then + echo "lint-scripts: expected scripts/ and hosts/ under $repo_root" >&2 exit 2 fi violations=0 checked=0 -for f in "$scripts_dir"/*.sh; do +shopt -s nullglob +files=( + "$repo_root"/scripts/*.sh + "$repo_root"/hosts/*/bin/*.sh + "$repo_root"/hosts/*/lib/*.sh +) +shopt -u nullglob + +for f in "${files[@]}"; do [ -e "$f" ] || continue checked=$((checked + 1)) - name="$(basename "$f")" + name="${f#"$repo_root"/}" - if [ ! -x "$f" ]; then + if [[ "$name" != hosts/*/lib/*.sh ]] && [ ! -x "$f" ]; then echo "FAIL: $name not executable (chmod +x)" >&2 violations=$((violations + 1)) fi @@ -44,10 +56,12 @@ for f in "$scripts_dir"/*.sh; do violations=$((violations + 1)) fi - # Look for `set -...` in the first 50 lines; require at least one of -u/-e/-o. - set_line="$(head -n50 "$f" | grep -E '^set -[a-z]+( pipefail)?$' || true)" - if [ -z "$set_line" ]; then - echo "WARN: $name has no \`set -uo pipefail\`-style line in first 50 lines" >&2 + # Sourced libraries inherit their caller's strict mode and must not mutate it. + if [[ "$name" != hosts/*/lib/*.sh ]]; then + set_line="$(head -n50 "$f" | grep -E '^set -[a-z]+( pipefail)?$' || true)" + if [ -z "$set_line" ]; then + echo "WARN: $name has no \`set -uo pipefail\`-style line in first 50 lines" >&2 + fi fi done @@ -61,4 +75,4 @@ if [ "$checked" -eq 0 ]; then exit 1 fi -echo "lint-scripts: OK ($checked script files checked)" +echo "lint-scripts: OK ($checked shell files checked)" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index e2e40eca..6decd84e 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -185,6 +185,18 @@ map_path() { case "$path" in scripts/lib/test-result.sh|core/schema/test-result.schema.json) add_suite test-run-tests; behavioral=1 ;; + scripts/lib/pmctl-config.sh) + add_suite test-pmctl-dispatch; add_suite test-pmctl-memory; add_suite test-pmctl-context; behavioral=1 ;; + scripts/lib/memory.sh|scripts/lib/memory-dir.sh) + add_suite test-pmctl-memory; add_suite test-pmctl-context; add_suite test-migrate; add_suite test-guards; behavioral=1 ;; + scripts/lib/pmctl-memory-config.sh) + add_suite test-pmctl-memory; behavioral=1 ;; + scripts/install-guards-codex.sh|scripts/uninstall-guards-codex.sh|scripts/lib/doctor-host-codex.sh) + add_suite test-host-write-codex; add_suite test-doctor; behavioral=1 ;; + scripts/install-guards.sh|scripts/uninstall-guards.sh|scripts/lib/doctor-host-claude.sh) + add_suite test-install; add_suite test-doctor; behavioral=1 ;; + scripts/lint-scripts.sh) + add_suite lint-scripts; add_suite test-run-tests; behavioral=1 ;; esac case "$path" in @@ -222,7 +234,7 @@ map_path() { README.md|CHANGELOG.md|docs/*) add_suite test-check-docs-freshness; behavioral=1 ;; .github/*) - add_suite lint-scripts ;; + add_suite lint-scripts; behavioral=1 ;; esac [[ "$behavioral" -eq 1 ]] || COVERAGE_GAPS["$path"]=1 diff --git a/scripts/test-doctor.sh b/scripts/test-doctor.sh index 6aeb2538..4443d845 100755 --- a/scripts/test-doctor.sh +++ b/scripts/test-doctor.sh @@ -87,7 +87,7 @@ write_minimal_settings() { "PostToolUse": [], "Stop": [ {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-log-claude-usage.sh"}]}, - {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-session-summary.sh"}]} + {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-session-summary.sh --host claude"}]} ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-memory.sh"}]}, @@ -113,7 +113,7 @@ write_stale_path_settings() { "PostToolUse": [], "Stop": [ {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-log-claude-usage.sh"}]}, - {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-session-summary.sh"}]} + {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-session-summary.sh --host claude"}]} ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-inject-memory.sh"}]}, @@ -139,7 +139,7 @@ write_sibling_prefix_settings() { "PostToolUse": [], "Stop": [ {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-log-claude-usage.sh"}]}, - {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-session-summary.sh"}]} + {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-session-summary.sh --host claude"}]} ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-inject-memory.sh"}]}, @@ -165,7 +165,7 @@ write_full_settings() { "PostToolUse": [], "Stop": [ {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-log-claude-usage.sh"}]}, - {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-session-summary.sh"}]} + {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-session-summary.sh --host claude"}]} ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-memory.sh"}]}, @@ -1143,10 +1143,7 @@ case_doctor_codex_hooks_wired_tuple() { write_full_settings "$home" create_memory_dir_for_pwd "$home" write_manifest "$home" - mkdir -p "$home/.codex" - jq -n --arg cmd "$REPO_ROOT/scripts/hook-codex-command-guard.sh" \ - '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":$cmd}]}]}}' \ - > "$home/.codex/hooks.json" + CODEX_HOME="$home/.codex" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 path="$(make_stub_bin "$tmp_root/bin-codex-hooks-wired" claude codex)" out="$(HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" \ @@ -1331,10 +1328,7 @@ case_doctor_codex_manifest_parity_present_ok() { write_full_settings "$home" create_memory_dir_for_pwd "$home" write_manifest "$home" - mkdir -p "$home/.codex" - jq -n --arg cmd "$REPO_ROOT/scripts/hook-codex-command-guard.sh" \ - '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":$cmd}]}]}}' \ - > "$home/.codex/hooks.json" + CODEX_HOME="$home/.codex" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 path="$(make_stub_bin "$tmp_root/bin-codex-parity-present" claude codex)" out="$(HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" \ @@ -1622,7 +1616,7 @@ case_doctor_windows_path_hooks_present() { "PostToolUse": [], "Stop": [ {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-log-claude-usage.sh"}]}, - {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-session-summary.sh"}]} + {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-session-summary.sh --host claude"}]} ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-inject-memory.sh"}]}, @@ -1672,7 +1666,7 @@ case_doctor_windows_path_hooks_stale() { "PostToolUse": [], "Stop": [ {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-log-claude-usage.sh"}]}, - {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-session-summary.sh"}]} + {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-session-summary.sh --host claude"}]} ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-inject-memory.sh"}]}, @@ -1894,7 +1888,7 @@ case_doctor_claude_config_dir() { local config_dir="$tmp_root/config-dir-valid" mkdir -p "$home_bare" mkdir -p "$config_dir/.pm-dispatch" - printf '{\n "hooks": {\n "PreToolUse": [\n {"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "%s/scripts/guard-pm-write.sh"}]},\n {"matcher": "Bash", "hooks": [{"type": "command", "command": "%s/adapters/codex/bash-guard.sh"}]}\n ],\n "PostToolUse": [],\n "Stop": [\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-log-claude-usage.sh"}]},\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-session-summary.sh"}]}\n ],\n "UserPromptSubmit": [\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-inject-memory.sh"}]},\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-inject-context.sh"}]}\n ]\n },\n "statusLine": {"command": "%s/scripts/guard-save-rate-limits.sh"}\n}\n' \ + printf '{\n "hooks": {\n "PreToolUse": [\n {"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "%s/scripts/guard-pm-write.sh"}]},\n {"matcher": "Bash", "hooks": [{"type": "command", "command": "%s/adapters/codex/bash-guard.sh"}]}\n ],\n "PostToolUse": [],\n "Stop": [\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-log-claude-usage.sh"}]},\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-session-summary.sh --host claude"}]}\n ],\n "UserPromptSubmit": [\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-inject-memory.sh"}]},\n {"hooks": [{"type": "command", "command": "%s/scripts/guard-inject-context.sh"}]}\n ]\n },\n "statusLine": {"command": "%s/scripts/guard-save-rate-limits.sh"}\n}\n' \ "$REPO_ROOT" "$REPO_ROOT" \ "$REPO_ROOT" "$REPO_ROOT" "$REPO_ROOT" "$REPO_ROOT" "$REPO_ROOT" > "$config_dir/settings.json" # Add abs-path allowlist entries for all dispatch scripts directly into config_dir. diff --git a/scripts/test-guards.sh b/scripts/test-guards.sh index a897f6ca..aea86486 100755 --- a/scripts/test-guards.sh +++ b/scripts/test-guards.sh @@ -30,11 +30,20 @@ MEM_HOOK_REAL="$SCRIPT_DIR/guard-inject-memory.sh" export MEM_HOOK_REAL CTX_HOOK="$SCRIPT_DIR/guard-inject-context.sh" SESSION_HOOK="$SCRIPT_DIR/guard-session-summary.sh" +session_hook_claude() { "$SESSION_HOOK" --host claude "$@"; } # shellcheck source=scripts/lib/test-harness.sh . "$SCRIPT_DIR/lib/test-harness.sh" th_init --format=indent-2sp-quiet "$@" +# Memory-hook fixtures must never inherit the operator's live canonical-memory +# selection. CC-488 intentionally installs ~/.pm-dispatch/config, so relying on +# a clean HOME makes these tests pass in CI but target the live store locally. +# Point config discovery at a guaranteed-missing fixture path; individual cases +# can still opt into PM_MEMORY_DIR explicitly when testing strict resolution. +unset PM_CFG_MEMORY_DIR PM_MEMORY_DIR +export PM_DISPATCH_CONFIG_FILE="$tmp_root/no-pm-dispatch-config" + # Sandbox audit logs. export PM_GUARD_LOG_DIR="$(mktemp -d)" TEST_LOG_FILE="$PM_GUARD_LOG_DIR/hooks.log" @@ -3215,7 +3224,7 @@ session_hook_happy_path() { mkdir -p "$cwd" write_inject_memory "$dir" "$cwd" $'- alpha\n' payload="{\"cwd\":\"$cwd\",\"session_id\":\"sess-001\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? episodes="$dir/projects/$(inject_encoded_path "$cwd")/memory/episodes.jsonl" entry="$(cat "$episodes" 2>/dev/null || true)" @@ -3230,6 +3239,52 @@ session_hook_happy_path() { rm -rf "$dir" } +session_hook_codex_host_provenance() { + # Verifies the shared Stop writer records explicit Codex provenance without + # relying on a CLI-specific default in the shared writer. + # Steps: + # 1. Create an isolated canonical memory fixture + # 2. Invoke the Stop hook with --host codex and a stable session id + # 3. Assert the skeleton is canonical, deduplicated, and writer_host=codex + local name="session-hook/codex-host-provenance" + should_run "$name" || return 0 + local dir cwd payload episodes entry line_count + dir="$(mktemp -d)" + cwd="$dir/workspace" + mkdir -p "$cwd" + write_inject_memory "$dir" "$cwd" $'- alpha\n' + payload="{\"cwd\":\"$cwd\",\"session_id\":\"codex-session-001\"}" + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" --host codex 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" --host codex 2>/dev/null + episodes="$dir/projects/$(inject_encoded_path "$cwd")/memory/episodes.jsonl" + entry="$(cat "$episodes" 2>/dev/null || true)" + line_count="$(wc -l < "$episodes" 2>/dev/null || echo 0)" + if [[ "$line_count" -eq 1 && "$entry" == *'"session_id":"codex-session-001"'* \ + && "$entry" == *'"writer_host":"codex"'* ]]; then + PASS=$((PASS+1)) + [[ "${VERBOSE:-}" ]] && printf ' PASS %s\n' "$name" + else + FAIL=$((FAIL+1)) + FAILED_CASES+=("$name") + printf ' FAIL %s — lines=%s entry=%q\n' "$name" "$line_count" "$entry" + fi + rm -rf "$dir" +} + +session_hook_requires_explicit_host() { + local name="session-hook/requires-explicit-host" + should_run "$name" || return 0 + local output status=0 + output="$(printf '{}' | "$SESSION_HOOK" 2>&1)" || status=$? + if [[ "$status" -eq 2 && "$output" == *"--host is required"* ]]; 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 +} + session_hook_duplicate_no_summary() { # Verifies same session_id with empty summary is NOT appended again. # Steps: @@ -3247,7 +3302,7 @@ session_hook_duplicate_no_summary() { printf '{"date":"2026-01-01T00:00:00+00:00","cwd":"%s","session_id":"session-abc","summary":""}\n' "$cwd" \ > "$dir/projects/$encoded/memory/episodes.jsonl" payload="{\"cwd\":\"$cwd\",\"session_id\":\"session-abc\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$dir/projects/$encoded/memory/episodes.jsonl") if [[ "$status" == "0" && "$line_count" -eq 1 ]]; then @@ -3278,7 +3333,7 @@ session_hook_duplicate_has_summary() { printf '{"date":"2026-01-01T00:00:00+00:00","cwd":"%s","session_id":"session-abc","summary":"Done work."}\n' "$cwd" \ > "$dir/projects/$encoded/memory/episodes.jsonl" payload="{\"cwd\":\"$cwd\",\"session_id\":\"session-abc\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$dir/projects/$encoded/memory/episodes.jsonl") if [[ "$status" == "0" && "$line_count" -eq 1 ]]; then @@ -3309,7 +3364,7 @@ session_hook_new_session_appends() { printf '{"date":"2026-01-01T00:00:00+00:00","cwd":"%s","session_id":"old-sess","summary":"Old work."}\n' "$cwd" \ > "$dir/projects/$encoded/memory/episodes.jsonl" payload="{\"cwd\":\"$cwd\",\"session_id\":\"new-sess\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$dir/projects/$encoded/memory/episodes.jsonl") if [[ "$status" == "0" && "$line_count" -eq 2 ]]; then @@ -3336,7 +3391,7 @@ session_hook_no_memory_dir() { cwd="$dir/workspace" mkdir -p "$cwd" payload="{\"cwd\":\"$cwd\",\"session_id\":\"s1\"}" - output=$(printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null) + output=$(printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null) status=$? if [[ "$status" == "0" && -z "$output" ]]; then PASS=$((PASS+1)) @@ -3364,7 +3419,7 @@ session_hook_invalid_explicit_no_legacy_write() { 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=$? + PM_MEMORY_DIR="$dir/missing-memory" CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null)" || status=$? if [[ "$status" -eq 0 && -z "$output" && ! -e "$episodes" ]]; then PASS=$((PASS+1)) [[ "${VERBOSE:-}" ]] && printf ' PASS %s\n' "$name" @@ -3383,7 +3438,7 @@ session_hook_malformed_payload() { local name="session-hook/malformed-payload" should_run "$name" || return 0 local output status - output=$(printf 'not json' | "$SESSION_HOOK" 2>/dev/null) + output=$(printf 'not json' | session_hook_claude 2>/dev/null) status=$? if [[ "$status" == "0" && -z "$output" ]]; then PASS=$((PASS+1)) @@ -3403,7 +3458,7 @@ session_hook_empty_stdin() { local name="session-hook/empty-stdin" should_run "$name" || return 0 local output status - output=$(printf '' | "$SESSION_HOOK" 2>/dev/null) + output=$(printf '' | session_hook_claude 2>/dev/null) status=$? if [[ "$status" == "0" && -z "$output" ]]; then PASS=$((PASS+1)) @@ -3430,7 +3485,7 @@ session_hook_missing_session_id() { mkdir -p "$cwd" write_inject_memory "$dir" "$cwd" $'- alpha\n' episodes="$dir/projects/$(inject_encoded_path "$cwd")/memory/episodes.jsonl" - printf '%s' "{\"cwd\":\"$cwd\"}" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "{\"cwd\":\"$cwd\"}" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? if [[ "$status" == "0" && ! -f "$episodes" ]]; then PASS=$((PASS+1)) @@ -3460,7 +3515,7 @@ session_hook_non_string_session_id() { mkdir -p "$cwd" write_inject_memory "$dir" "$cwd" $'- alpha\n' episodes="$dir/projects/$(inject_encoded_path "$cwd")/memory/episodes.jsonl" - printf '%s' "{\"cwd\":\"$cwd\",\"session_id\":42}" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "{\"cwd\":\"$cwd\",\"session_id\":42}" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? if [[ "$status" == "0" && ! -f "$episodes" ]]; then PASS=$((PASS+1)) @@ -3475,6 +3530,8 @@ session_hook_non_string_session_id() { } session_hook_happy_path +session_hook_codex_host_provenance +session_hook_requires_explicit_host session_hook_duplicate_no_summary session_hook_duplicate_has_summary session_hook_new_session_appends @@ -3502,7 +3559,7 @@ session_hook_routing_dir_isolation() { mkdir -p "$cwd" write_inject_memory "$dir" "$cwd" $'- alpha\n' payload="{\"cwd\":\"$cwd\",\"session_id\":\"isolation-test\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" CLAUDE_ROUTING_LOG_DIR="$routing_dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" CLAUDE_ROUTING_LOG_DIR="$routing_dir" session_hook_claude 2>/dev/null status=$? project_has_ep=false; routing_has_ep=false [[ -f "$dir/projects/$(inject_encoded_path "$cwd")/memory/episodes.jsonl" ]] && project_has_ep=true @@ -3601,7 +3658,7 @@ session_stop_skips_after_recent_memlog_empty_session_id() { "$recent_iso" "$cwd" > "$episodes" payload="{\"cwd\":\"$cwd\",\"session_id\":\"real-session-id-123\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$episodes" 2>/dev/null || echo 0) rm -rf "$dir" @@ -3639,7 +3696,7 @@ session_stop_appends_after_old_memlog_empty_session_id() { "$old_iso" "$cwd" > "$episodes" payload="{\"cwd\":\"$cwd\",\"session_id\":\"new-real-session\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$episodes" 2>/dev/null || echo 0) rm -rf "$dir" @@ -3676,7 +3733,7 @@ cross_cmd_stop_skips_recent_fractional_iso() { printf '{"date":"%s","cwd":"%s","session_id":"","summary":"Recent fractional entry."}\n' \ "$recent_iso" "$cwd" > "$episodes" payload="{\"cwd\":\"$cwd\",\"session_id\":\"real-session-frac\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$episodes" 2>/dev/null || echo 0) rm -rf "$dir" @@ -3708,7 +3765,7 @@ cross_cmd_stop_appends_old_fractional_iso() { printf '{"date":"2020-01-01T00:00:00.123456+00:00","cwd":"%s","session_id":"","summary":"Old fractional entry."}\n' \ "$cwd" > "$episodes" payload="{\"cwd\":\"$cwd\",\"session_id\":\"new-real-session-frac\"}" - printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" "$SESSION_HOOK" 2>/dev/null + printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$dir" session_hook_claude 2>/dev/null status=$? line_count=$(wc -l < "$episodes" 2>/dev/null || echo 0) rm -rf "$dir" diff --git a/scripts/test-host-manifest.sh b/scripts/test-host-manifest.sh index 8c6cdbe0..24b2244c 100755 --- a/scripts/test-host-manifest.sh +++ b/scripts/test-host-manifest.sh @@ -44,7 +44,7 @@ REQUIRED_KEYS=( CAPABILITY_FIELDS=(provider enforcement coverage stability confidence) # Closed enums (docs/host-contract.md). Space-separated membership strings. -ENUM_FORMAT="claude-settings-json codex-hooks-json codex-config-toml opencode-config-json markdown-managed-block symlink-tree copy-tree" +ENUM_FORMAT="claude-settings-json codex-hooks-json codex-config-toml codex-agents-md opencode-config-json markdown-managed-block symlink-tree copy-tree" ENUM_CAPABILITY="command_guard file_guard session_lifecycle pm_command_interface statusline" ENUM_BINDING_FORM="hook-script config-fragment none" ENUM_PROVIDER="host_hook host_policy host_native cli_wrapper doc_instruction none" @@ -277,6 +277,10 @@ validate_manifest() { echo "$module_key '$module' not found under repo root (must be null or an existing repo-relative path)" fi done + module="$(grep -E '^memory_update_module:' "$manifest" | head -1 | sed 's/^memory_update_module:[[:space:]]*//;s/[[:space:]]*#.*$//' || true)" + if [[ -n "$module" && "$module" != "null" && ! -f "$REPO_ROOT/$module" ]]; then + echo "memory_update_module '$module' not found under repo root" + fi # --- operational files carry no ticket references --------------------------- if grep -qE 'CC-[0-9]+' "$manifest"; then diff --git a/scripts/test-host-write-codex.sh b/scripts/test-host-write-codex.sh index 6c33a55c..1370f91e 100755 --- a/scripts/test-host-write-codex.sh +++ b/scripts/test-host-write-codex.sh @@ -141,6 +141,16 @@ test_host_manifest_declares_codex_write_modules() { && pass "$name" || fail "$name" "unexpected modules: install=$install_module uninstall=$uninstall_module" } +test_host_manifest_declares_codex_memory_update_module() { + local name="host-manifest-declares-codex-memory-update-module" + should_run "$name" || return 0 + local manifest module + manifest="$(host_manifest_file "$REPO_ROOT" codex)" + module="$(host_manifest_scalar "$manifest" memory_update_module)" + [[ "$module" == "hosts/codex/bin/memory-update.sh" && -x "$REPO_ROOT/$module" ]] \ + && pass "$name" || fail "$name" "unexpected or non-executable module: $module" +} + test_host_write_dispatches_codex_from_manifest() { local name="host-write-dispatches-codex-from-manifest" should_run "$name" || return 0 @@ -216,10 +226,13 @@ test_install_guards_codex_wires_hook() { 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 + "$codex_home/hooks.json" >/dev/null 2>&1 \ + && jq -e '.hooks.Stop[] | .hooks[] | select(.command | endswith("guard-session-summary.sh --host codex"))' \ + "$codex_home/hooks.json" >/dev/null 2>&1 \ + && grep -Fq 'pm-dispatch:codex-memory-contract:start' "$codex_home/AGENTS.md"; then pass "$name" else - fail "$name" "expected command guard and canonical-memory prompt hook in $codex_home/hooks.json" + fail "$name" "expected command, prompt, Stop, and AGENTS memory wiring" fi } @@ -235,9 +248,47 @@ test_install_guards_codex_idempotent() { [[ "$before" == "$after" ]] \ && [[ "$(jq '.hooks.PreToolUse | length' "$codex_home/hooks.json")" == "1" ]] \ && [[ "$(jq '.hooks.UserPromptSubmit | length' "$codex_home/hooks.json")" == "1" ]] \ + && [[ "$(jq '.hooks.Stop | length' "$codex_home/hooks.json")" == "1" ]] \ + && [[ "$(grep -Fc 'pm-dispatch:codex-memory-contract:start' "$codex_home/AGENTS.md")" == "1" ]] \ && pass "$name" || fail "$name" "re-running should not duplicate the managed hook entry" } +test_codex_memory_update_writes_only_canonical_episode() { + local name="codex-memory-update-writes-only-canonical-episode" + should_run "$name" || return 0 + local root="$tmp_root/memory-update" repo="$tmp_root/memory-update/repo" + local memory="$tmp_root/memory-update/canonical" native="$tmp_root/memory-update/native" + local marker="codex-canonical-marker-$RANDOM" native_before native_after out + mkdir -p "$repo" "$memory" "$native" + git -C "$repo" init -q + printf 'native sentinel\n' > "$native/note.md" + native_before="$(find "$native" -type f -exec sha256sum {} + | sort)" + out="$(PM_MEMORY_DIR="$memory" CODEX_HOME="$native" "$REPO_ROOT/hosts/codex/bin/memory-update.sh" \ + --repo-root "$repo" --summary "$marker" --json)" + native_after="$(find "$native" -type f -exec sha256sum {} + | sort)" + if jq -e --arg marker "$marker" \ + '.writer_host == "codex" and .authority == "canonical" and .episode.summary == $marker' <<<"$out" >/dev/null \ + && jq -e --arg marker "$marker" 'select(.summary == $marker and .writer_host == "codex")' \ + "$memory/episodes.jsonl" >/dev/null \ + && [[ "$native_before" == "$native_after" ]]; then + pass "$name" + else + fail "$name" "expected one canonical Codex write and no native-memory change" + fi +} + +test_codex_memory_update_invalid_explicit_fails_closed() { + local name="codex-memory-update-invalid-explicit-fails-closed" + should_run "$name" || return 0 + local root="$tmp_root/memory-update-invalid" repo="$tmp_root/memory-update-invalid/repo" rc=0 + mkdir -p "$repo" + git -C "$repo" init -q + PM_MEMORY_DIR="$root/missing" "$REPO_ROOT/hosts/codex/bin/memory-update.sh" \ + --repo-root "$repo" --summary "must not land" --json >/dev/null 2>&1 || rc=$? + [[ "$rc" -eq 3 && ! -e "$root/missing" ]] \ + && pass "$name" || fail "$name" "expected invalid explicit path rc=3 and no write, got rc=$rc" +} + test_install_guards_codex_missing_manifest_target_errors() { local name="install-guards-codex-missing-managed-target-errors" should_run "$name" || return 0 @@ -352,6 +403,45 @@ test_uninstall_guards_codex_preserves_unrelated_hook() { fi } +test_codex_agents_managed_block_preserves_foreign_content() { + local name="codex-agents-managed-block-preserves-foreign-content" + should_run "$name" || return 0 + local codex_home="$tmp_root/agents-preserve/.codex" + mkdir -p "$codex_home" + printf '# Personal guidance\n\n- Keep this exact rule.\n' > "$codex_home/AGENTS.md" + CODEX_HOME="$codex_home" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 + CODEX_HOME="$codex_home" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 + CODEX_HOME="$codex_home" bash "$REPO_ROOT/scripts/uninstall-guards-codex.sh" >/dev/null 2>&1 + if [[ "$(cat "$codex_home/AGENTS.md")" == $'# Personal guidance\n\n- Keep this exact rule.' ]]; then + pass "$name" + else + fail "$name" "foreign AGENTS.md content changed during install/uninstall" + fi +} + +test_codex_agents_malformed_markers_fail_closed() { + local name="codex-agents-malformed-markers-fail-closed" + should_run "$name" || return 0 + local codex_home="$tmp_root/agents-malformed/.codex" + local before="$tmp_root/agents-malformed.before" + local rc=0 + mkdir -p "$codex_home" + printf '%s\n%s\n' \ + '# Personal guidance' \ + '' \ + > "$codex_home/AGENTS.md" + cp "$codex_home/AGENTS.md" "$before" + + CODEX_HOME="$codex_home" bash "$REPO_ROOT/scripts/install-guards-codex.sh" >/dev/null 2>&1 || rc=$? + if [[ "$rc" -eq 2 ]] \ + && cmp -s "$before" "$codex_home/AGENTS.md" \ + && [[ ! -e "$codex_home/hooks.json" ]]; then + pass "$name" + else + fail "$name" "malformed managed markers must fail before modifying AGENTS.md or hooks.json (rc=$rc)" + fi +} + test_uninstall_guards_codex_preserves_in_repo_user_hook() { # Regression: uninstall must remove only the exact managed guard entry, not # every hook command that happens to live under this repo checkout — a @@ -574,6 +664,7 @@ test_host_manifest_scalar_ignores_list_map_field test_host_manifest_expand_path_uses_env_override test_host_manifest_expand_path_default_when_unset test_host_manifest_declares_codex_write_modules +test_host_manifest_declares_codex_memory_update_module test_host_write_dispatches_codex_from_manifest test_host_write_dispatches_opencode_stage3 test_install_generic_host_selector_wires_codex @@ -581,11 +672,15 @@ test_install_unknown_host_fails_before_mutation test_install_guards_codex_dry_run_no_side_effect test_install_guards_codex_wires_hook test_install_guards_codex_idempotent +test_codex_memory_update_writes_only_canonical_episode +test_codex_memory_update_invalid_explicit_fails_closed test_install_guards_codex_missing_manifest_target_errors test_install_guards_codex_unknown_flag_rejected test_install_guards_codex_spaced_repo_root test_uninstall_guards_codex_removes_hook test_uninstall_guards_codex_preserves_unrelated_hook +test_codex_agents_managed_block_preserves_foreign_content +test_codex_agents_malformed_markers_fail_closed test_uninstall_guards_codex_preserves_in_repo_user_hook test_uninstall_guards_codex_preserves_same_basename_other_checkout test_uninstall_guards_codex_idempotent_when_absent diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 1db06cf8..3f6b5abe 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -2185,7 +2185,7 @@ test_session_stop_install_wires_hook() { should_run "$name" || return 0 local home="$tmp_root/$name" local session - session="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-session-summary.sh")" + session="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-session-summary.sh") --host claude" mkdir -p "$home/.claude" printf '{"permissions":{}}\n' > "$home/.claude/settings.json" @@ -2211,7 +2211,7 @@ test_session_stop_uninstall_removes_hook() { should_run "$name" || return 0 local home="$tmp_root/$name" local session - session="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-session-summary.sh")" + session="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-session-summary.sh") --host claude" mkdir -p "$home/.claude" printf '{"permissions":{}}\n' > "$home/.claude/settings.json" @@ -2232,7 +2232,7 @@ test_session_stop_install_idempotent() { should_run "$name" || return 0 local home="$tmp_root/$name" local session - session="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-session-summary.sh")" + session="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-session-summary.sh") --host claude" local count mkdir -p "$home/.claude" printf '{"permissions":{}}\n' > "$home/.claude/settings.json" diff --git a/scripts/test-migrate-routing-log.sh b/scripts/test-migrate-routing-log.sh index 7e065895..ab7aebcc 100755 --- a/scripts/test-migrate-routing-log.sh +++ b/scripts/test-migrate-routing-log.sh @@ -9,6 +9,10 @@ MIGRATOR="$SCRIPT_DIR/migrate-routing-log.sh" . "$SCRIPT_DIR/lib/test-harness.sh" th_init "$@" +# Migrator fixtures must not inherit the operator's project memory config. +export PM_DISPATCH_CONFIG_FILE="$tmp_root/no-operator-config" +unset PM_MEMORY_DIR PM_CFG_MEMORY_DIR PM_CFG_MEMORY_DIR_INVALID PM_CFG_MEMORY_CONFIG_STATUS + write_fixture() { local path="$1" malformed="${2:-0}" mkdir -p "$(dirname "$path")" diff --git a/scripts/test-pmctl-context.sh b/scripts/test-pmctl-context.sh index 6071e646..f85b1a59 100755 --- a/scripts/test-pmctl-context.sh +++ b/scripts/test-pmctl-context.sh @@ -10,8 +10,16 @@ PMCTL="$REPO_ROOT/cli/pmctl" # shellcheck source=scripts/lib/test-harness.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/test-harness.sh" +# shellcheck source=scripts/lib/test-memory-config-fixtures.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/test-memory-config-fixtures.sh" th_init "$@" +# Never inherit the operator's memory mapping. Config-specific cases opt into +# their own fixture file explicitly. +export PM_DISPATCH_CONFIG_FILE="$tmp_root/no-operator-config" +unset PM_MEMORY_DIR PM_CFG_MEMORY_DIR PM_CFG_MEMORY_DIR_INVALID PM_CFG_MEMORY_CONFIG_STATUS + # Skip all tests if sqlite3 is not available (rare but possible on stripped envs) if ! command -v sqlite3 >/dev/null 2>&1; then printf 'SKIP test-pmctl-context: sqlite3 not on PATH\n' @@ -2443,14 +2451,14 @@ case_context_query_source_memory_no_dir_graceful() { } case_context_query_source_memory_config_override() { - local name="pmctl context query --source memory: dispatch.memory_dir config resolves memory dir" + local name="pmctl context query --source memory: project-scoped config resolves memory dir" should_run "$name" || return 0 local repo="$tmp_root/mem-cfgover-repo" cfg="$tmp_root/mem-cfgover-cfg" local override="$tmp_root/mem-cfgover-override" fakehome="$tmp_root/mem-cfgover-home" mkdir -p "$repo" "$fakehome/.pm-dispatch" # $cfg deliberately has no memory dir for $repo. Populate ONLY the override # dir (not under $cfg/projects//memory) so a hit proves resolution went - # through dispatch.memory_dir, not the CLAUDE_CONFIG_DIR walk. + # through the project-scoped config, not the CLAUDE_CONFIG_DIR walk. mkdir -p "$override" cat > "$override/MEMORY.md" <<'MD' # Memory Index @@ -2462,16 +2470,16 @@ name: gate-executor-codex --- The pr-gate flow should prefer the codex executor for separation. MD - printf 'dispatch.memory_dir = %s\n' "$override" > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" "$override" local out err status=0 out="$tmp_root/mem-cfgover.out"; err="$tmp_root/mem-cfgover.err" - HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" context query "$repo" --source memory codex > "$out" 2> "$err" || status=$? + PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" context query "$repo" --source memory codex > "$out" 2> "$err" || status=$? if [[ "$status" -ne 0 ]]; then fail "$name" "query exited $status: $(<"$err")"; return 0; fi if grep -q 'ref: feedback_gate_executor.md' "$out" && grep -q 'source_domain: memory' "$out"; then pass "$name" else - fail "$name" "expected memory card hit resolved via dispatch.memory_dir; got: $(<"$out")" + fail "$name" "expected memory card hit resolved via project-scoped config; got: $(<"$out")" fi } diff --git a/scripts/test-pmctl-memory.sh b/scripts/test-pmctl-memory.sh index a420f596..6a629d0e 100755 --- a/scripts/test-pmctl-memory.sh +++ b/scripts/test-pmctl-memory.sh @@ -13,8 +13,16 @@ PMCTL="$REPO_ROOT/cli/pmctl" # shellcheck source=scripts/lib/memory.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/memory.sh" +# shellcheck source=scripts/lib/test-memory-config-fixtures.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/test-memory-config-fixtures.sh" th_init "$@" +# Operator config is external state. Every case starts isolated and opts into a +# fixture config explicitly when exercising project-scoped selection. +export PM_DISPATCH_CONFIG_FILE="$tmp_root/no-operator-config" +unset PM_MEMORY_DIR PM_CFG_MEMORY_DIR PM_CFG_MEMORY_DIR_INVALID PM_CFG_MEMORY_CONFIG_STATUS + # Snapshot the developer's live project-memory dir up front (a read, for the # baseline) so a dedicated guard case proves the suite never MUTATES it — doctor # must stay read-only AND no case may accidentally point doctor at the live dir. @@ -155,7 +163,7 @@ case_memory_resolve_config_contract() { local repo="$tmp_root/resolve-config-repo" mdir="$tmp_root/config-memory" config="$tmp_root/resolve-config.conf" out="$tmp_root/resolve-config.json" status=0 mkdir -p "$repo" "$mdir" git -C "$repo" init -q - printf 'dispatch.memory_dir = %s\n' "$mdir" > "$config" + write_project_memory_config "$config" "$repo" "$mdir" (unset PM_MEMORY_DIR; PM_DISPATCH_CONFIG_FILE="$config" "$PMCTL" memory resolve --repo-root "$repo" --json) > "$out" 2>/dev/null || status=$? if [[ "$status" -eq 0 ]] && jq -e --arg mdir "$mdir" \ '.status == "resolved" and .resolution_source == "config" and .memory_dir == $mdir' "$out" >/dev/null; then @@ -173,7 +181,7 @@ case_memory_resolve_env_outranks_config() { local repo="$tmp_root/resolve-precedence-repo" env_dir="$tmp_root/precedence-env-memory" cfg_dir="$tmp_root/precedence-config-memory" config="$tmp_root/resolve-precedence.conf" out="$tmp_root/resolve-precedence.json" status=0 mkdir -p "$repo" "$env_dir" "$cfg_dir" git -C "$repo" init -q - printf 'dispatch.memory_dir = %s\n' "$cfg_dir" > "$config" + write_project_memory_config "$config" "$repo" "$cfg_dir" PM_MEMORY_DIR="$env_dir" PM_DISPATCH_CONFIG_FILE="$config" "$PMCTL" memory resolve --repo-root "$repo" --json > "$out" 2>/dev/null || status=$? if [[ "$status" -eq 0 ]] && jq -e --arg env_dir "$env_dir" \ '.resolution_source == "env" and .memory_dir == $env_dir' "$out" >/dev/null; then @@ -192,7 +200,7 @@ case_memory_resolve_invalid_config_no_fallback() { mkdir -p "$repo" git -C "$repo" init -q legacy="$(make_fixture_memory "$cfg" "$repo")" - printf 'dispatch.memory_dir = %s\n' "$missing" > "$config" + write_project_memory_config "$config" "$repo" "$missing" (unset PM_MEMORY_DIR; PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory resolve --repo-root "$repo" --json) > "$out" 2>/dev/null || status=$? if [[ "$status" -eq 3 ]] \ && jq -e '.status == "invalid-explicit" and .resolution_source == "config" and .memory_dir == null' "$out" >/dev/null \ @@ -212,7 +220,7 @@ case_memory_resolve_rejects_relative_explicit_paths() { mkdir -p "$repo" git -C "$repo" init -q PM_MEMORY_DIR="relative-memory" "$PMCTL" memory resolve --repo-root "$repo" --json > "$env_out" 2>/dev/null || env_status=$? - printf 'dispatch.memory_dir = relative-memory\n' > "$config" + write_project_memory_config "$config" "$repo" relative-memory (unset PM_MEMORY_DIR; PM_DISPATCH_CONFIG_FILE="$config" "$PMCTL" memory resolve --repo-root "$repo" --json) > "$cfg_out" 2>/dev/null || cfg_status=$? if [[ "$env_status" -eq 3 && "$cfg_status" -eq 3 ]] \ && jq -e '.resolution_source == "env" and .status == "invalid-explicit" and (.reason | contains("absolute path"))' "$env_out" >/dev/null \ @@ -667,7 +675,7 @@ case_memory_doctor_no_memory_dir() { } case_memory_doctor_config_memory_dir_override() { - local name="pmctl memory doctor: dispatch.memory_dir config resolves an override-only memory dir" + local name="pmctl memory doctor: project-scoped config resolves an override-only memory dir" should_run "$name" || return 0 local cfg="$tmp_root/docover-cfg" repo="$tmp_root/docover-repo" @@ -677,10 +685,10 @@ case_memory_doctor_config_memory_dir_override() { mkdir -p "$cfg/projects" "$repo" "$override" "$fakehome/.pm-dispatch" write_compliant_card "$override/feedback_test.md" test-card printf '# Memory Index\n- [test](feedback_test.md) — hook\n' > "$override/MEMORY.md" - printf 'dispatch.memory_dir = %s\n' "$override" > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" "$override" local out="$tmp_root/docover.json" status=0 - HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory doctor --repo-root "$repo" --json \ + PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory doctor --repo-root "$repo" --json \ > "$out" 2>/dev/null || status=$? if ! assert_exit "$name" "$status" 0; then return 0; fi @@ -936,15 +944,15 @@ case_memory_dir_pm_memory_dir_unset_byte_identical() { } case_memory_dir_config_dispatch_memory_dir_override() { - local name="pmctl memory dir: dispatch.memory_dir config overrides discovery when PM_MEMORY_DIR unset" + local name="pmctl memory dir: project-scoped config overrides discovery when PM_MEMORY_DIR unset" should_run "$name" || return 0 local cfg="$tmp_root/pmcfg-cfg" repo="$tmp_root/pmcfg-repo" override="$tmp_root/pmcfg-override" fakehome="$tmp_root/pmcfg-home" mkdir -p "$repo" "$override" "$fakehome/.pm-dispatch" - printf 'dispatch.memory_dir = %s\n' "$override" > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" "$override" local out status=0 - out="$(HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>/dev/null)" || status=$? + out="$(PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>/dev/null)" || status=$? if ! assert_exit "$name" "$status" 0; then return 0; fi if [[ "$out" != "$override" ]]; then @@ -955,16 +963,16 @@ case_memory_dir_config_dispatch_memory_dir_override() { } case_memory_dir_pm_memory_dir_outranks_config() { - local name="pmctl memory dir: PM_MEMORY_DIR env outranks dispatch.memory_dir config" + local name="pmctl memory dir: PM_MEMORY_DIR env outranks project-scoped config" should_run "$name" || return 0 local cfg="$tmp_root/pmboth-cfg" repo="$tmp_root/pmboth-repo" local env_win="$tmp_root/pmboth-env-win" cfg_lose="$tmp_root/pmboth-cfg-lose" fakehome="$tmp_root/pmboth-home" mkdir -p "$repo" "$env_win" "$cfg_lose" "$fakehome/.pm-dispatch" - printf 'dispatch.memory_dir = %s\n' "$cfg_lose" > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" "$cfg_lose" local out status=0 - out="$(PM_MEMORY_DIR="$env_win" HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>/dev/null)" || status=$? + out="$(PM_MEMORY_DIR="$env_win" PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>/dev/null)" || status=$? if ! assert_exit "$name" "$status" 0; then return 0; fi if [[ "$out" != "$env_win" ]]; then @@ -975,24 +983,24 @@ case_memory_dir_pm_memory_dir_outranks_config() { } case_memory_dir_malformed_config_memory_dir_falls_through() { - local name="pmctl memory dir: malformed (relative) dispatch.memory_dir warns and falls through to legacy resolution" + local name="pmctl memory dir: malformed project-scoped path warns and falls through to legacy resolution" should_run "$name" || return 0 local cfg="$tmp_root/pmbad-cfg" repo="$tmp_root/pmbad-repo" fakehome="$tmp_root/pmbad-home" mkdir -p "$repo" "$fakehome/.pm-dispatch" local mdir; mdir="$(make_fixture_memory "$cfg" "$repo")" - printf 'dispatch.memory_dir = relative/not-absolute\n' > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" relative/not-absolute local out err status=0 err="$tmp_root/pmbad.err" - out="$(HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>"$err")" || status=$? + out="$(PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>"$err")" || status=$? if ! assert_exit "$name" "$status" 0; then return 0; fi if [[ "$out" != "$mdir" ]]; then fail "$name" "expected fallback to legacy dir '$mdir' (malformed override must be ignored), got '$out'" return 0 fi - if ! grep -q 'malformed value for dispatch.memory_dir' "$err"; then + if ! grep -q 'malformed value for memory.projects.' "$err"; then fail "$name" "expected a malformed-value warning on stderr, got: $(<"$err")" return 0 fi @@ -1414,7 +1422,7 @@ case_memory_rebuild_summary_no_memory_dir() { } case_memory_shard_config_memory_dir_override() { - local name="pmctl memory shard: dispatch.memory_dir config resolves an override-only memory dir" + local name="pmctl memory shard: project-scoped config resolves an override-only memory dir" should_run "$name" || return 0 local cfg repo override fakehome @@ -1423,7 +1431,7 @@ case_memory_shard_config_memory_dir_override() { override="$(mktemp -d -p "$tmp_root")" fakehome="$(mktemp -d -p "$tmp_root")" mkdir -p "$cfg/projects" "$fakehome/.pm-dispatch" # cfg has NO memory dir for $repo - printf 'dispatch.memory_dir = %s\n' "$override" > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" "$override" local ep="$override/episodes.jsonl" local i @@ -1433,7 +1441,7 @@ case_memory_shard_config_memory_dir_override() { done local out status=0 - out="$(HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory shard --repo-root "$repo" 2>&1)" || status=$? + out="$(PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory shard --repo-root "$repo" 2>&1)" || status=$? if [[ "$status" -ne 0 ]]; then fail "$name" "expected shard to resolve override-only memory dir; exited $status: $out" @@ -1447,7 +1455,7 @@ case_memory_shard_config_memory_dir_override() { } case_memory_rebuild_summary_config_memory_dir_override() { - local name="pmctl memory rebuild-summary: dispatch.memory_dir config resolves an override-only memory dir" + local name="pmctl memory rebuild-summary: project-scoped config resolves an override-only memory dir" should_run "$name" || return 0 local cfg repo override fakehome @@ -1456,13 +1464,13 @@ case_memory_rebuild_summary_config_memory_dir_override() { override="$(mktemp -d -p "$tmp_root")" fakehome="$(mktemp -d -p "$tmp_root")" mkdir -p "$cfg/projects" "$fakehome/.pm-dispatch" # cfg has NO memory dir for $repo - printf 'dispatch.memory_dir = %s\n' "$override" > "$fakehome/.pm-dispatch/config" + write_project_memory_config "$fakehome/.pm-dispatch/config" "$repo" "$override" local ep="$override/episodes.jsonl" printf '{"date":"2026-05-01","cwd":"%s","session_id":"a","summary":"may entry one"}\n' "$repo" >> "$ep" local out status=0 - out="$(HOME="$fakehome" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory rebuild-summary --repo-root "$repo" 2>&1)" || status=$? + out="$(PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory rebuild-summary --repo-root "$repo" 2>&1)" || status=$? if [[ "$status" -ne 0 ]]; then fail "$name" "expected rebuild-summary to resolve override-only memory dir; exited $status: $out" @@ -1760,6 +1768,19 @@ case_memory_append_episode_cross_host_contract() { fi } +case_memory_append_episode_requires_host() { + local name="pmctl memory append-episode: shared writer requires explicit initiating host" + should_run "$name" || return 0 + local repo="$tmp_root/append-host-required-repo" mdir="$tmp_root/append-host-required-memory" out status=0 + mkdir -p "$repo" "$mdir"; git -C "$repo" init -q + out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --summary must-not-default 2>&1)" || status=$? + if [[ "$status" -eq 2 && "$out" == *"--host is required"* && ! -e "$mdir/episodes.jsonl" ]]; then + pass "$name" + else + fail "$name" "status=$status out=$out" + 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() { @@ -1904,6 +1925,111 @@ case_memory_append_episode_refuses_symlink_lock_dir() { fi } +# Behavior: one config file can safely map independent repositories without a +# machine-wide fallback, and an unmatched append cannot touch another project. +case_memory_config_project_isolation() { + local name="pmctl memory config: project mapping prevents cross-project resolve and append" + should_run "$name" || return 0 + local repo_a="$tmp_root/config-project-a" repo_b="$tmp_root/config-project-b" + local mem_a="$tmp_root/config-memory-a" cfg="$tmp_root/project-config" empty_claude="$tmp_root/project-empty-claude" + local before after out status=0 + mkdir -p "$repo_a" "$repo_b" "$mem_a" "$empty_claude/projects" + git -C "$repo_a" init -q; git -C "$repo_b" init -q + printf 'sentinel = keep\n' > "$cfg" + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config set --repo-root "$repo_a" --memory-dir "$mem_a" --json > "$tmp_root/config-set.json" + before="$(find "$mem_a" -type f -printf '%P:%s:%T@\n' | sort)" + out="$(PM_DISPATCH_CONFIG_FILE="$cfg" CLAUDE_CONFIG_DIR="$empty_claude" "$PMCTL" memory resolve --repo-root "$repo_b" --json 2>/dev/null)" || status=$? + PM_DISPATCH_CONFIG_FILE="$cfg" CLAUDE_CONFIG_DIR="$empty_claude" "$PMCTL" memory append-episode \ + --repo-root "$repo_b" --host codex --summary cross-project-must-fail >/dev/null 2>&1 || true + after="$(find "$mem_a" -type f -printf '%P:%s:%T@\n' | sort)" + if [[ "$status" -eq 1 && "$before" == "$after" ]] \ + && jq -e '.status == "unavailable" and .resolution_source == "none"' <<<"$out" >/dev/null \ + && grep -q '^sentinel = keep$' "$cfg" \ + && grep -q "^memory.projects.$(memory_fixture_project_key "$repo_a").dir = $mem_a$" "$cfg"; then + pass "$name" + else + fail "$name" "status=$status out=$out before=[$before] after=[$after] config=$(<"$cfg")" + fi +} + +# Behavior: deprecated global config is diagnostic and fail-closed until one +# explicit repository migration atomically replaces it with a scoped entry. +case_memory_config_legacy_migration() { + local name="pmctl memory config: unsafe global fails closed then migrates idempotently" + should_run "$name" || return 0 + local repo="$tmp_root/config-migrate-repo" mem="$tmp_root/config-migrate-memory" cfg="$tmp_root/config-migrate.conf" + local out status=0 lint_status=0 + mkdir -p "$repo" "$mem"; git -C "$repo" init -q + printf 'dispatch.default_timeout = 700\ndispatch.memory_dir = %s\n' "$mem" > "$cfg" + out="$(PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory resolve --repo-root "$repo" --json 2>/dev/null)" || status=$? + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config lint --json > "$tmp_root/config-legacy-lint.json" || lint_status=$? + if [[ "$status" -ne 3 || "$lint_status" -ne 1 ]] \ + || ! jq -e '.resolution_source == "config-legacy-global" and .status == "invalid-explicit"' <<<"$out" >/dev/null \ + || ! jq -e '.issues | any(.code == "unsafe-legacy-global")' "$tmp_root/config-legacy-lint.json" >/dev/null; then + fail "$name" "pre-migration status=$status lint=$lint_status out=$out" + return 0 + fi + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config migrate --repo-root "$repo" --json > "$tmp_root/config-migrate.json" || { + fail "$name" "migration failed"; return 0; + } + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config migrate --repo-root "$repo" --json > "$tmp_root/config-migrate-noop.json" || { + fail "$name" "second migration was not idempotent"; return 0; + } + status=0 + out="$(PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory resolve --repo-root "$repo" --json 2>/dev/null)" || status=$? + if [[ "$status" -eq 0 ]] \ + && jq -e --arg mem "$mem" '.resolution_source == "config" and .memory_dir == $mem' <<<"$out" >/dev/null \ + && jq -e '.action == "migrate-noop"' "$tmp_root/config-migrate-noop.json" >/dev/null \ + && ! grep -q '^dispatch.memory_dir[[:space:]]*=' "$cfg" \ + && grep -q '^dispatch.default_timeout = 700$' "$cfg"; then + pass "$name" + else + fail "$name" "post-migration status=$status out=$out config=$(<"$cfg")" + fi +} + +# Behavior: matched malformed/missing project paths are reported, while entries +# for other projects never poison the selected repository. +case_memory_config_lint_and_matched_fail_closed() { + local name="pmctl memory config: lint diagnoses malformed and missing matched paths" + should_run "$name" || return 0 + local repo="$tmp_root/config-lint-repo" cfg="$tmp_root/config-lint.conf" key missing="$tmp_root/config-lint-missing" + local status=0 lint_status=0 out + mkdir -p "$repo"; git -C "$repo" init -q; key="$(memory_fixture_project_key "$repo")" + printf 'memory.projects.bad.dir = /tmp/nope\nmemory.projects.%s.dir = %s\nmemory.projects.%s.dir = %s\n' \ + "$key" "$missing" "$key" "$missing" > "$cfg" + out="$(PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory resolve --repo-root "$repo" --json 2>/dev/null)" || status=$? + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config lint --json > "$tmp_root/config-lint.json" || lint_status=$? + if [[ "$status" -eq 3 && "$lint_status" -eq 1 ]] \ + && jq -e '.status == "invalid-explicit" and .resolution_source == "config"' <<<"$out" >/dev/null \ + && jq -e '.issues | any(.code == "malformed-project-key") and any(.code == "duplicate-project-key") and any(.code == "missing-memory-dir")' "$tmp_root/config-lint.json" >/dev/null; then + pass "$name" + else + fail "$name" "resolve=$status lint=$lint_status out=$out report=$(<"$tmp_root/config-lint.json")" + fi +} + +case_memory_config_linked_worktree_identity() { + local name="pmctl memory config: linked worktree shares primary project mapping" + should_run "$name" || return 0 + local repo="$tmp_root/config-worktree-main" wt="$tmp_root/config-worktree-linked" + local mem="$tmp_root/config-worktree-memory" cfg="$tmp_root/config-worktree.conf" main_json wt_json + mkdir -p "$repo" "$mem"; git -C "$repo" init -q + printf 'seed\n' > "$repo/README.md" + git -C "$repo" add README.md + git -C "$repo" -c user.name=test -c user.email=test@example.invalid commit -qm seed + git -C "$repo" worktree add -q -b config-worktree-branch "$wt" + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config set --repo-root "$repo" --memory-dir "$mem" --json >/dev/null + main_json="$(PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory resolve --repo-root "$repo" --json)" + wt_json="$(PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory resolve --repo-root "$wt" --json)" + if [[ "$(jq -r '.project_key' <<<"$main_json")" == "$(jq -r '.project_key' <<<"$wt_json")" ]] \ + && jq -e --arg mem "$mem" '.resolution_source == "config" and .memory_dir == $mem' <<<"$wt_json" >/dev/null; then + pass "$name" + else + fail "$name" "main=$main_json worktree=$wt_json" + fi +} + # ── Run all cases ────────────────────────────────────────────────────────────── case_memory_resolve_env_contract @@ -1917,12 +2043,17 @@ 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_requires_host 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_config_project_isolation +case_memory_config_legacy_migration +case_memory_config_lint_and_matched_fail_closed +case_memory_config_linked_worktree_identity 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 225a79e4..a98ae69f 100755 --- a/scripts/test-pmctl-pm.sh +++ b/scripts/test-pmctl-pm.sh @@ -10,6 +10,15 @@ PMCTL="$REPO_ROOT/cli/pmctl" . "$SCRIPT_DIR/lib/test-harness.sh" th_init "$@" +# Operator memory/config is external state. Contract-only prepare cases run +# against this checkout, so isolate both project-scoped config and legacy +# CLAUDE_CONFIG_DIR discovery at suite scope. Individual continuity/config +# cases opt into their own PM_MEMORY_DIR or fixture config explicitly. +export PM_DISPATCH_CONFIG_FILE="$tmp_root/no-operator-config" +export CLAUDE_CONFIG_DIR="$tmp_root/isolated-claude-config" +mkdir -p "$CLAUDE_CONFIG_DIR/projects" +unset PM_MEMORY_DIR PM_CFG_MEMORY_DIR PM_CFG_MEMORY_DIR_INVALID PM_CFG_MEMORY_CONFIG_STATUS + # Preparation now reports repo-context freshness. Avoid mutating this checkout's # live derived DB in the many contract-only cases; dedicated fixture cases opt # back into refresh explicitly. diff --git a/scripts/test-run-tests.sh b/scripts/test-run-tests.sh index 8a0aed85..b1f46647 100755 --- a/scripts/test-run-tests.sh +++ b/scripts/test-run-tests.sh @@ -27,7 +27,7 @@ make_fixture() { cat > "$repo/scripts/lib/test-suite-runner.sh" <<'RUNNER' #!/usr/bin/env bash set -euo pipefail -suites=(lint-agents lint-scripts test-lint-frontmatter test-commands test-check-docs-freshness test-guards test-pmctl-context test-pr-gate test-host-manifest test-host-write-codex test-host-write-parity test-core-schemas test-layer-boundaries test-pm-scripts test-run-tests) +suites=(lint-agents lint-scripts test-lint-frontmatter test-commands test-check-docs-freshness test-guards test-migrate test-install test-doctor test-pmctl-dispatch test-pmctl-context test-pmctl-memory test-pr-gate test-host-manifest test-host-write-codex test-host-write-parity test-core-schemas test-layer-boundaries test-pm-scripts test-run-tests) for arg in "$@"; do if [[ "$arg" == --list ]]; then printf '%s\n' "${suites[@]}"; exit 0; fi done @@ -56,6 +56,20 @@ case_direct_library_mapping() { fi } +case_memory_config_mapping_has_no_gap() { + local name=memory-config-mapping-has-no-gap repo out status=0 args + args="$TMP_ROOT/$name.args" + repo="$(make_fixture "$name")" + out=$(RUN_TESTS_ARGS_LOG="$args" "$repo/scripts/run-tests.sh" --path scripts/lib/pmctl-config.sh 2>&1) || status=$? + if [[ "$status" -eq 0 ]] && grep -qx 'test-pmctl-dispatch' "$args" && + grep -qx 'test-pmctl-context' "$args" && grep -qx 'test-pmctl-memory' "$args" && + [[ "$out" != *"coverage gaps"* ]]; then + pass "$name" + else + fail "$name" "status=$status out=$out args=$(cat "$args" 2>/dev/null)" + fi +} + case_docs_mapping_list_only() { local name=docs-mapping-list-only repo out status=0 args args="$TMP_ROOT/$name.args" @@ -248,6 +262,7 @@ case_iteration_result_cannot_verify_as_full() { } case_direct_library_mapping +case_memory_config_mapping_has_no_gap case_docs_mapping_list_only case_agent_mapping_uses_registered_frontmatter_suite case_command_mapping_uses_registered_frontmatter_suite diff --git a/scripts/uninstall-guards-codex.sh b/scripts/uninstall-guards-codex.sh index 92c688c6..91b1e41e 100755 --- a/scripts/uninstall-guards-codex.sh +++ b/scripts/uninstall-guards-codex.sh @@ -25,6 +25,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # shellcheck source=scripts/lib/host-manifest.sh . "$SCRIPT_DIR/lib/host-manifest.sh" +# shellcheck source=hosts/codex/lib/memory-contract.sh +. "$REPO_ROOT/hosts/codex/lib/memory-contract.sh" if ! command -v jq >/dev/null 2>&1; then echo "uninstall-guards-codex: jq is required" >&2 @@ -38,18 +40,23 @@ if [[ ! -f "$manifest" ]]; then fi hooks_path_template="" +instructions_path_template="" while IFS=$'\t' read -r id path fmt managed; do - [[ "$id" == "hooks" ]] || continue - [[ "$fmt" == "codex-hooks-json" ]] || continue [[ "$managed" == "true" ]] || continue - hooks_path_template="$path" + if [[ "$id" == "hooks" && "$fmt" == "codex-hooks-json" ]]; then + hooks_path_template="$path" + elif [[ "$id" == "instructions" && "$fmt" == "codex-agents-md" ]]; then + instructions_path_template="$path" + fi done < <(host_manifest_install_targets "$manifest") [[ -n "$hooks_path_template" ]] || { echo "uninstall-guards-codex: no managed codex-hooks-json target declared — nothing to do"; exit 0; } hooks_file="$(host_manifest_expand_path "$hooks_path_template")" +instructions_file="" +[[ -n "$instructions_path_template" ]] && instructions_file="$(host_manifest_expand_path "$instructions_path_template")" -if [[ ! -f "$hooks_file" ]]; then +if [[ ! -f "$hooks_file" && ( -z "$instructions_file" || ! -f "$instructions_file" ) ]]; then echo "uninstall-guards-codex: $hooks_file not found — nothing to do" exit 0 fi @@ -60,7 +67,7 @@ fi # validated. A malformed/unrelated hooks.json must not abort a normal # pm-dispatch uninstall under `set -e` — warn and skip instead of erroring, # same as the "not wired, nothing to do" no-op path below. -if ! jq empty "$hooks_file" 2>/dev/null; then +if [[ -f "$hooks_file" ]] && ! jq empty "$hooks_file" 2>/dev/null; then echo "uninstall-guards-codex: $hooks_file is not valid JSON — skipping (unrelated/unmanaged Codex state, not modified)" >&2 exit 0 fi @@ -69,9 +76,12 @@ 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")" +session_hook_cmd="$REPO_ROOT/scripts/guard-session-summary.sh --host codex" +session_hook_cmd_q="$(printf '%q' "$REPO_ROOT/scripts/guard-session-summary.sh") --host codex" tmp_new="$(mktemp)" -trap 'rm -f "$tmp_new"' EXIT +tmp_instructions_new="$(mktemp)" +trap 'rm -f "$tmp_new" "$tmp_instructions_new"' EXIT # Match only the exact managed command identity THIS checkout's installer # writes (install-guards-codex.sh:59) — not a basename+parent-dir heuristic, @@ -79,38 +89,81 @@ 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" --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 ( - .; - if (.hooks[$et] | type) == "array" then - .hooks[$et] |= map( - if (.hooks | type) == "array" then - .hooks |= map(select((.command | is_managed) | not)) - else . end - ) | - .hooks[$et] |= map(select((.hooks // [] | length) > 0)) | - if (.hooks[$et] | length) == 0 then del(.hooks[$et]) else . end - else . end - ) | - if (.hooks // {} | length) == 0 then del(.hooks) else . end - ' "$hooks_file" > "$tmp_new" - -if cmp -s "$hooks_file" "$tmp_new"; then +if [[ -f "$hooks_file" ]]; then + 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" \ + --arg session_cmd "$session_hook_cmd" --arg session_cmd_q "$session_hook_cmd_q" ' + def is_managed: (. // "") | (. == $cmd or . == $cmd_q or . == $memory_cmd or . == $memory_cmd_q or . == $session_cmd or . == $session_cmd_q); + ( [.hooks // {} | keys[]] ) as $event_types | + reduce $event_types[] as $et ( + .; + if (.hooks[$et] | type) == "array" then + .hooks[$et] |= map( + if (.hooks | type) == "array" then + .hooks |= map(select((.command | is_managed) | not)) + else . end + ) | + .hooks[$et] |= map(select((.hooks // [] | length) > 0)) | + if (.hooks[$et] | length) == 0 then del(.hooks[$et]) else . end + else . end + ) | + if (.hooks // {} | length) == 0 then del(.hooks) else . end + ' "$hooks_file" > "$tmp_new" +else + printf '{}\n' > "$tmp_new" +fi + +instructions_changed=0 +instructions_remove=0 +if [[ -n "$instructions_file" && -f "$instructions_file" ]]; then + if ! codex_memory_contract_strip "$instructions_file" "$tmp_instructions_new"; then + echo "uninstall-guards-codex: malformed managed markers in $instructions_file — preserving file" >&2 + cp "$instructions_file" "$tmp_instructions_new" + fi + cmp -s "$instructions_file" "$tmp_instructions_new" || instructions_changed=1 + [[ "$instructions_changed" -eq 1 && ! -s "$tmp_instructions_new" ]] && instructions_remove=1 +fi + +hooks_changed=0 +[[ -f "$hooks_file" ]] && ! cmp -s "$hooks_file" "$tmp_new" && hooks_changed=1 +if [[ "$hooks_changed" -eq 0 && "$instructions_changed" -eq 0 ]]; then echo "uninstall-guards-codex: not wired, nothing to do" exit 0 fi if [[ "$DRY_RUN" -eq 1 ]]; then - echo "uninstall-guards-codex: would apply the following change:" - diff -u "$hooks_file" "$tmp_new" || true + if [[ "$hooks_changed" -eq 1 ]]; then + echo "uninstall-guards-codex: would apply the following change to $hooks_file:" + diff -u "$hooks_file" "$tmp_new" || true + fi + if [[ "$instructions_changed" -eq 1 ]]; then + echo "uninstall-guards-codex: would apply the following change to $instructions_file:" + if [[ "$instructions_remove" -eq 1 ]]; then + diff -u "$instructions_file" /dev/null || true + else + diff -u "$instructions_file" "$tmp_instructions_new" || true + fi + fi exit 0 fi -backup="$hooks_file.bak.$(date +%Y%m%d-%H%M%S)" -cp "$hooks_file" "$backup" -mv "$tmp_new" "$hooks_file" +timestamp="$(date +%Y%m%d-%H%M%S)" +if [[ "$hooks_changed" -eq 1 ]]; then + backup="$hooks_file.bak.$timestamp" + cp "$hooks_file" "$backup" + mv "$tmp_new" "$hooks_file" + echo "uninstall-guards-codex: wrote $hooks_file" + echo "uninstall-guards-codex: backup at $backup" +fi +if [[ "$instructions_changed" -eq 1 ]]; then + instructions_backup="$instructions_file.bak.$timestamp" + cp "$instructions_file" "$instructions_backup" + if [[ "$instructions_remove" -eq 1 ]]; then + rm -f "$instructions_file" + echo "uninstall-guards-codex: removed empty managed $instructions_file" + else + mv "$tmp_instructions_new" "$instructions_file" + echo "uninstall-guards-codex: wrote $instructions_file" + fi + echo "uninstall-guards-codex: backup at $instructions_backup" +fi trap - EXIT -echo "uninstall-guards-codex: wrote $hooks_file" -echo "uninstall-guards-codex: backup at $backup"