diff --git a/BACKLOG.md b/BACKLOG.md index db48a6d4..4871cacc 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -37,6 +37,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | 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-492 | ✅ closed 2026-07-15 | Claude UserPromptSubmit context hook timeout envelope:升級 managed timeout、復原 files=0 殘缺 DB,避免 30 秒外層終止 | ops/memory | 2026-07-15 | pr:#403 | P1 | retrieval | | 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 | @@ -791,6 +792,27 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed --- +## CC-492 — Claude UserPromptSubmit context hook timeout envelope 與殘缺 DB 復原 ✅ 2026-07-15 + +**Problem**: Claude Code 的 `UserPromptSubmit` command hook 未明確設定 timeout,實際使用 30 秒預設值;但 `guard-inject-context.sh` 的既有索引刷新與初次建立預算分別為 45/120 秒。JapanJob live session 已觀察到 context hook 在 30.020 秒被外層終止,stdout 被丟棄;同時先前中斷留下「DB 檔存在但 files=0」的殘缺 cache,使 hook 誤判為既有索引並在後續 prompt 重複進入超時路徑。 + +**Requirement**: +1. Claude installer 必須為 managed `guard-inject-context.sh` 寫入明確、且大於內部最大預算並保留 cleanup 餘裕的 handler timeout;重複安裝要升級既有缺值/過短值,不得只處理新 hook。 +2. context hook 必須辨識「DB 存在但沒有任何 committed file row」的未完成 initial build,沿用 initial-build timeout 與失敗清理,不得永久誤分類為 incremental cache。 +3. doctor 必須診斷 managed context hook timeout 缺失或低於契約值;memory injection hook 與非 managed hook 不得被連帶改寫。 +4. 回歸覆蓋 fresh install、existing-hook migration、idempotency、殘缺空 DB timeout cleanup、unrelated hook preservation,以及 handler/internal timeout envelope。 +5. Claude permissions 使用其接受的 `Edit(/**/.gate-results/**)`,installer 升級時移除舊 managed `Write(...)`;uninstaller 同時辨識並移除新 `Edit(...)` 與歷史 `Write(...)`。`/tmp/*` 權限不是 pm-dispatch installer 管理項,不得擅自新增或刪除。 + +**Acceptance**: sandbox install 後 context hook 具有明確 timeout,doctor 通過;模擬已存在的空 DB 與慢速 index 時,hook 在自身預算內 fail-open、清除殘缺 cache,且不再由 Claude 30 秒外層先行終止。permission upgrade 只留下 managed `Edit(.gate-results)`、不產生任何 `Write(...)`;uninstall 可清除新舊兩種 managed spelling 且保留 `/tmp/*` 等非 managed 權限。live Claude 設定升級後,JapanJob 下一次 prompt 不再出現 `UserPromptSubmit hook timed out after 30s`。 + +**Source**: 2026-07-15 JapanJob Claude session `30e97e81-4bcf-4308-aa1c-78c25d451ba3` live diagnosis。 + +**Closed 2026-07-15(Draft PR #403)**: managed context hook timeout 已提升為 150 秒,殘缺 files=0 DB 會重走 initial-build recovery;installer 與 uninstaller 已改用並對稱清理 managed `Edit(.gate-results)`,同時相容歷史 `Write(...)` 且保留非 managed `/tmp/*` 權限。Sequential full Codex gate `gate-20260715-003400-ab8888` 為 `Final: GO`,affected suites 全綠,live Claude doctor 26/26 通過,JapanJob prompt hook 12.77 秒完成並建立 1320-file index;受另一 session 並行刷新 live context DB 影響的 full-suite isolation 斷言,已獨立重跑 `test-pmctl-context` 114/114 通過。 + +**See**: pr:#403 + +--- + ## 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/docs/context-retrieval.md b/docs/context-retrieval.md index 3071d6a4..c1fbe73e 100644 --- a/docs/context-retrieval.md +++ b/docs/context-retrieval.md @@ -110,9 +110,15 @@ when `sqlite3` is absent the hook skips pmctl entirely; when it is available, the first eligible prompt automatically builds the repo-local DB. Initial builds have a 120-second budget (`PM_DISPATCH_PROMPT_CONTEXT_INITIAL_TIMEOUT`), while incremental refreshes use 45 seconds (`PM_DISPATCH_PROMPT_CONTEXT_TIMEOUT`). A -timed-out first build removes only its incomplete derived DB so the next prompt -can retry with the initial-build budget. Unchanged refreshes preserve the FTS -table instead of rebuilding it, keeping the normal prompt path short. +Claude install explicitly gives this handler 150 seconds, leaving cleanup time +outside the largest internal budget instead of inheriting Claude Code's shorter +`UserPromptSubmit` default. Re-running the installer upgrades an older managed +hook in place. A timed-out first build removes only its incomplete derived DB so +the next prompt can retry with the initial-build budget; a schema-only DB with +zero committed file rows is also treated as an interrupted initial build. +Unchanged refreshes preserve the FTS table instead of rebuilding it, keeping the +normal prompt path short. `doctor.sh` reports a managed context hook whose +handler timeout is absent or below this envelope. Set `PM_DISPATCH_DISABLE_PROMPT_CONTEXT=1` to disable the scan entirely. Use this whenever the live context DB must not be touched — for example while the diff --git a/scripts/guard-inject-context.sh b/scripts/guard-inject-context.sh index 335898da..7d7d07ca 100755 --- a/scripts/guard-inject-context.sh +++ b/scripts/guard-inject-context.sh @@ -16,6 +16,11 @@ # test suite is running against this repo). set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +# shellcheck source=scripts/lib/prompt-context-timeouts.sh +. "$SCRIPT_DIR/lib/prompt-context-timeouts.sh" + if [[ "${PM_DISPATCH_DISABLE_PROMPT_CONTEXT:-0}" == "1" ]]; then exit 0 fi @@ -60,12 +65,26 @@ _context_db="$repo_root/.pm-dispatch/ctx/context.db" _initial_build=0 if [[ ! -f "$_context_db" ]]; then _initial_build=1 - _timeout_secs="${PM_DISPATCH_PROMPT_CONTEXT_INITIAL_TIMEOUT:-120}" else - _timeout_secs="${PM_DISPATCH_PROMPT_CONTEXT_TIMEOUT:-45}" + # An interrupted initial build can leave a SQLite schema without committed + # file rows. Only a confirmed numeric zero is treated as incomplete; a locked + # or unreadable DB stays on the non-destructive incremental path. + _indexed_files="$(sqlite3 "$_context_db" 'SELECT count(*) FROM files;' 2>/dev/null || true)" + if [[ "$_indexed_files" == "0" ]]; then + _initial_build=1 + fi +fi +if [[ "$_initial_build" -eq 1 ]]; then + _timeout_secs="${PM_DISPATCH_PROMPT_CONTEXT_INITIAL_TIMEOUT:-$PROMPT_CONTEXT_INITIAL_TIMEOUT_DEFAULT}" +else + _timeout_secs="${PM_DISPATCH_PROMPT_CONTEXT_TIMEOUT:-$PROMPT_CONTEXT_REFRESH_TIMEOUT_DEFAULT}" fi if [[ ! "$_timeout_secs" =~ ^[0-9]+$ ]]; then - [[ "$_initial_build" -eq 1 ]] && _timeout_secs=120 || _timeout_secs=45 + if [[ "$_initial_build" -eq 1 ]]; then + _timeout_secs="$PROMPT_CONTEXT_INITIAL_TIMEOUT_DEFAULT" + else + _timeout_secs="$PROMPT_CONTEXT_REFRESH_TIMEOUT_DEFAULT" + fi fi _runner=(bash "$pmctl_cli") if command -v timeout >/dev/null 2>&1; then diff --git a/scripts/install-guards.sh b/scripts/install-guards.sh index 88b79639..2d13a023 100755 --- a/scripts/install-guards.sh +++ b/scripts/install-guards.sh @@ -39,6 +39,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=scripts/lib/prompt-context-timeouts.sh +. "$SCRIPT_DIR/lib/prompt-context-timeouts.sh" # Use the same canonical Claude runtime config root as install/doctor/guards. # CLAUDE_HOME remains a compatibility alias for standalone legacy callers. @@ -253,6 +255,7 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ --arg session "$session_cmd_q" \ --arg inject "$inject_cmd_q" \ --arg ctx_inject "$ctx_inject_cmd_q" \ + --argjson ctx_inject_timeout "$CLAUDE_PROMPT_CONTEXT_HOOK_TIMEOUT" \ --arg statusline "$statusline_cmd_q" \ --argjson sl_present "$_statusline_already_wired" \ --argjson bg_guards "$_bg_json" \ @@ -374,7 +377,8 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ .hooks.UserPromptSubmit |= map( .hooks |= map( if ((.command | split("/") | last) == ($inject | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") then .command = $inject - elif ((.command | split("/") | last) == ($ctx_inject | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") then .command = $ctx_inject + elif ((.command | split("/") | last) == ($ctx_inject | split("/") | last) and (.command | split("/") | .[-2]) == "scripts") then + .command = $ctx_inject | .timeout = $ctx_inject_timeout else . end ) ) | @@ -430,7 +434,7 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ else . end ) | ( if $ctx_inject_present == 0 then - .hooks.UserPromptSubmit += [{"hooks": [{"type": "command", "command": $ctx_inject}]}] + .hooks.UserPromptSubmit += [{"hooks": [{"type": "command", "command": $ctx_inject, "timeout": $ctx_inject_timeout}]}] else . end ) | ( if $sl_present == 0 then @@ -440,7 +444,7 @@ MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 jq \ ' > "$tmp_new" < "$settings" # --- Permissions merge for reviewer subagents --- -# Reviewer subagents spawned by pr-gate need Write(.gate-results) and Bash(pmctl guard check) +# Reviewer subagents spawned by pr-gate need Edit(.gate-results) and Bash(pmctl guard check) # to write results and run guard checks. Workspace root detection is shared with # uninstall-guards.sh via scripts/lib/gate-workspace.sh. _workspace_root="$(gate_workspace_root "$repo_root" "$HOME")" @@ -459,7 +463,8 @@ _pmctl_guard_tilde="" _tmp_perms="$(mktemp)" trap 'rm -f "$tmp_new" "$_tmp_perms"' EXIT if ! jq \ - --arg write_perm "Write(${_gate_glob})" \ + --arg edit_perm "Edit(${_gate_glob})" \ + --arg legacy_write_perm "Write(${_gate_glob})" \ --arg bash_guard "Bash(pmctl guard check:*)" \ --arg bash_guard_abs "$_pmctl_guard_abs" \ --arg bash_guard_tilde "$_pmctl_guard_tilde" \ @@ -467,7 +472,10 @@ if ! jq \ ' .permissions //= {} | .permissions.allow //= [] | - ([$write_perm, $bash_guard, $bash_guard_abs, $bash_guard_tilde, $bash_mkdir] + # Claude settings accepts Edit path permissions. Remove the legacy Write + # spelling during upgrade instead of preserving an invalid managed entry. + .permissions.allow |= map(select(. != $legacy_write_perm)) | + ([$edit_perm, $bash_guard, $bash_guard_abs, $bash_guard_tilde, $bash_mkdir] | map(select(. != ""))) as $required | .permissions.allow |= ( . as $existing | diff --git a/scripts/lib/doctor-host-claude.sh b/scripts/lib/doctor-host-claude.sh index 0d547ff5..8e3a799d 100644 --- a/scripts/lib/doctor-host-claude.sh +++ b/scripts/lib/doctor-host-claude.sh @@ -87,6 +87,21 @@ _doctor_host_claude_hook_present() { ' "$settings" >/dev/null 2>&1 } +_doctor_host_claude_context_timeout_ok() { + local settings="$1" + # shellcheck disable=SC1091 + # shellcheck source=scripts/lib/prompt-context-timeouts.sh + . "$REPO_ROOT/scripts/lib/prompt-context-timeouts.sh" + jq -e --argjson expected "$CLAUDE_PROMPT_CONTEXT_HOOK_TIMEOUT" ' + [ + (.hooks.UserPromptSubmit[]? | (.hooks // [])[]? | + select(((.command? // "") | gsub("\\\\"; "/") | split("/") | last) == "guard-inject-context.sh")) + ] as $hooks | + ($hooks | length) > 0 and + all($hooks[]; ((.timeout? // 0) | type) == "number" and (.timeout >= $expected)) + ' "$settings" >/dev/null 2>&1 +} + _doctor_host_claude_adapter_bg_present() { local adapter_name="$1" settings="$2" jq -e --arg adapter_name "$adapter_name" ' @@ -247,6 +262,13 @@ _doctor_host_claude_check_hooks() { emit_check hooks warn \ "${#_stale[@]} hook(s) wired from a different checkout (e.g. $(basename "${_stale[0]}"))" \ "bash '${REPO_ROOT}/install.sh' to re-wire hooks to this checkout" + elif ! _doctor_host_claude_context_timeout_ok "$settings"; then + # shellcheck disable=SC1091 + # shellcheck source=scripts/lib/prompt-context-timeouts.sh + . "$REPO_ROOT/scripts/lib/prompt-context-timeouts.sh" + emit_check hooks fail \ + "guard-inject-context.sh timeout missing or below ${CLAUDE_PROMPT_CONTEXT_HOOK_TIMEOUT}s" \ + "bash '${REPO_ROOT}/scripts/install-guards.sh'" else emit_check hooks ok "$_total_hooks hooks present ($profile profile)" fi diff --git a/scripts/lib/prompt-context-timeouts.sh b/scripts/lib/prompt-context-timeouts.sh new file mode 100644 index 00000000..e96793f4 --- /dev/null +++ b/scripts/lib/prompt-context-timeouts.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Shared timeout defaults for the synchronous UserPromptSubmit context hook. +# The Claude handler timeout must remain greater than the hook's largest +# internal timeout so fail-open cleanup runs before Claude discards the hook. + +# shellcheck disable=SC2034 # Public constant consumed by sourcing callers. +PROMPT_CONTEXT_REFRESH_TIMEOUT_DEFAULT=45 +# shellcheck disable=SC2034 # Public constant consumed by sourcing callers. +PROMPT_CONTEXT_INITIAL_TIMEOUT_DEFAULT=120 +# shellcheck disable=SC2034 # Public constant consumed by sourcing callers. +CLAUDE_PROMPT_CONTEXT_HOOK_TIMEOUT=150 diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 6decd84e..814e15cf 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -191,6 +191,8 @@ map_path() { 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/lib/prompt-context-timeouts.sh) + add_suite test-guards; add_suite test-install; add_suite test-doctor; 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) diff --git a/scripts/test-doctor.sh b/scripts/test-doctor.sh index 4443d845..0e131e28 100755 --- a/scripts/test-doctor.sh +++ b/scripts/test-doctor.sh @@ -91,7 +91,7 @@ write_minimal_settings() { ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-memory.sh"}]}, - {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-context.sh"}]} + {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-context.sh", "timeout": 150}]} ] }, "statusLine": {"command": "${REPO_ROOT}/scripts/guard-save-rate-limits.sh"} @@ -117,7 +117,7 @@ write_stale_path_settings() { ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-inject-memory.sh"}]}, - {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-inject-context.sh"}]} + {"hooks": [{"type": "command", "command": "/fake/old-repo/scripts/guard-inject-context.sh", "timeout": 150}]} ] }, "statusLine": {"command": "/fake/old-repo/scripts/guard-save-rate-limits.sh"} @@ -143,7 +143,7 @@ write_sibling_prefix_settings() { ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-inject-memory.sh"}]}, - {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-inject-context.sh"}]} + {"hooks": [{"type": "command", "command": "${sibling}/scripts/guard-inject-context.sh", "timeout": 150}]} ] }, "statusLine": {"command": "${sibling}/scripts/guard-save-rate-limits.sh"} @@ -169,7 +169,7 @@ write_full_settings() { ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-memory.sh"}]}, - {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-context.sh"}]} + {"hooks": [{"type": "command", "command": "${REPO_ROOT}/scripts/guard-inject-context.sh", "timeout": 150}]} ] }, "statusLine": {"command": "${REPO_ROOT}/scripts/guard-save-rate-limits.sh"} @@ -795,6 +795,38 @@ case_doctor_profile_minimal_skip_codex_hooks() { fi } +case_doctor_context_hook_timeout_too_low_fails() { + # Verifies doctor catches the exact live regression where Claude's default + # 30-second handler timeout is shorter than the context hook's own budget. + # Steps: + # 1. Write an otherwise valid minimal install with timeout=30 + # 2. Run doctor in minimal profile + # 3. Assert the hooks check fails with the timeout-envelope diagnosis + local name="doctor-context-hook-timeout-too-low-fails" + should_run "$name" || return 0 + if ! command -v jq >/dev/null 2>&1; then + pass "$name (jq not available - skip)" + return + fi + local home="$tmp_root/home-context-timeout-low" out status=0 path + write_minimal_settings "$home" + jq '(.hooks.UserPromptSubmit[]?.hooks[]? | + select((.command | endswith("guard-inject-context.sh"))).timeout) = 30' \ + "$home/.claude/settings.json" > "$home/.claude/settings.json.tmp" + mv "$home/.claude/settings.json.tmp" "$home/.claude/settings.json" + create_memory_dir_for_pwd "$home" + write_manifest "$home" + path="$(make_stub_bin "$tmp_root/bin-context-timeout-low" claude)" + + out="$(HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" \ + bash "$DOCTOR" --no-color --repo "$REPO_ROOT" --profile minimal 2>&1)" || status=$? + if [[ "$status" -eq 1 && "$out" == *"timeout missing or below 150s"* ]]; then + pass "$name" + else + fail "$name" "expected timeout-envelope failure; status=$status out=$out" + fi +} + case_doctor_windows_auto_profile_codex_on_path() { # Verifies that PROFILE=auto selects minimal on Windows even when codex is in PATH. # Without the Windows platform check, codex_available()=true → _want_full=1 → @@ -1620,7 +1652,7 @@ case_doctor_windows_path_hooks_present() { ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-inject-memory.sh"}]}, - {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-inject-context.sh"}]} + {"hooks": [{"type": "command", "command": "C:\\pm-dispatch\\scripts\\guard-inject-context.sh", "timeout": 150}]} ] }, "statusLine": {"command": "C:\\pm-dispatch\\scripts\\guard-save-rate-limits.sh"} @@ -1670,7 +1702,7 @@ case_doctor_windows_path_hooks_stale() { ], "UserPromptSubmit": [ {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-inject-memory.sh"}]}, - {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-inject-context.sh"}]} + {"hooks": [{"type": "command", "command": "C:\\other-repo\\scripts\\guard-inject-context.sh", "timeout": 150}]} ] }, "statusLine": {"command": "C:\\other-repo\\scripts\\guard-save-rate-limits.sh"} @@ -1888,7 +1920,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 --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' \ + 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", "timeout": 150}]}\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. @@ -2060,6 +2092,7 @@ case_doctor_manifest_bad_version_warn case_doctor_malformed_settings_fail case_doctor_malformed_settings_json case_doctor_profile_minimal_skip_codex_hooks +case_doctor_context_hook_timeout_too_low_fails case_doctor_windows_auto_profile_codex_on_path test_dispatch_allowlist_ok test_dispatch_allowlist_missing diff --git a/scripts/test-guards.sh b/scripts/test-guards.sh index c9d48590..87f2d85d 100755 --- a/scripts/test-guards.sh +++ b/scripts/test-guards.sh @@ -3233,6 +3233,42 @@ STUB rm -rf "$dir" } +ctx_inject_hook_existing_empty_db_reenters_initial_cleanup() { + # Verifies a schema-only DB left by an externally interrupted initial build + # is not misclassified as a healthy incremental cache. + # Steps: + # 1. Seed an existing context.db whose files table has zero rows + # 2. Run a slow fake pmctl with a one-second initial-build timeout + # 3. Assert fail-open exit 0 and removal of the incomplete derived cache + local name="ctx-inject-hook/existing-empty-db-reenters-initial-cleanup" + should_run "$name" || return 0 + command -v sqlite3 >/dev/null 2>&1 || { PASS=$((PASS+1)); return 0; } + command -v timeout >/dev/null 2>&1 || { PASS=$((PASS+1)); return 0; } + local dir repo output status + dir="$(mktemp -d)"; repo="$dir/repo" + mkdir -p "$repo/docs" "$repo/.pm-dispatch/ctx" + git -C "$repo" init -q + printf '## Gate verdict\n\nctxinjectterm knowledge body.\n' > "$repo/docs/notes.md" + sqlite3 "$repo/.pm-dispatch/ctx/context.db" 'create table files(id integer primary key);' + cat > "$dir/slow-existing-pmctl" <<'STUB' +#!/usr/bin/env bash +sleep 30 +STUB + chmod +x "$dir/slow-existing-pmctl" + output=$(printf '%s' "{\"cwd\":\"$repo\",\"prompt\":\"tell me about ctxinjectterm behavior\"}" \ + | PM_DISPATCH_PROMPT_CONTEXT_PMCTL="$dir/slow-existing-pmctl" \ + PM_DISPATCH_PROMPT_CONTEXT_INITIAL_TIMEOUT=1 "$CTX_HOOK" 2>/dev/null) + status=$? + if [[ "$status" == "0" && -z "$output" && ! -e "$repo/.pm-dispatch/ctx/context.db" ]]; then + PASS=$((PASS+1)) + else + FAIL=$((FAIL+1)); FAILED_CASES+=("$name") + printf ' FAIL %s — exit=%s output=%q db=%s\n' "$name" "$status" "$output" \ + "$([[ -e "$repo/.pm-dispatch/ctx/context.db" ]] && echo yes || echo no)" + fi + rm -rf "$dir" +} + ctx_inject_hook_no_hits_silent() { # Verifies an indexed repo with a non-matching prompt stays silent. # Steps: @@ -3373,6 +3409,7 @@ ctx_inject_hook_no_db_auto_builds ctx_inject_hook_marker_round_trip ctx_inject_hook_sqlite_missing_skips_pmctl ctx_inject_hook_initial_timeout_removes_empty_db +ctx_inject_hook_existing_empty_db_reenters_initial_cleanup ctx_inject_hook_no_hits_silent ctx_inject_hook_short_prompt_silent ctx_inject_hook_kill_switch diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 3f6b5abe..3a6ae54a 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -1038,8 +1038,8 @@ test_install_hooks_gate_perms_fresh() { PM_DISPATCH_GATE_WORKSPACE="$ws" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local write_entry="Write(${ws}/**/.gate-results/**)" - for entry in "$write_entry" "Bash(pmctl guard check:*)" \ + local edit_entry="Edit(${ws}/**/.gate-results/**)" + for entry in "$edit_entry" "Bash(pmctl guard check:*)" \ "Bash($home/.local/bin/pmctl guard check:*)" \ "Bash(~/.local/bin/pmctl guard check:*)" "Bash(mkdir -p:*)"; do if ! jq -e --arg e "$entry" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then @@ -1065,9 +1065,9 @@ test_install_hooks_gate_perms_idempotent() { HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_WORKSPACE="$ws" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local write_entry="Write(${ws}/**/.gate-results/**)" + local edit_entry="Edit(${ws}/**/.gate-results/**)" local count - for entry in "$write_entry" "Bash(pmctl guard check:*)" \ + for entry in "$edit_entry" "Bash(pmctl guard check:*)" \ "Bash($home/.local/bin/pmctl guard check:*)" \ "Bash(~/.local/bin/pmctl guard check:*)" "Bash(mkdir -p:*)"; do count="$(jq -r --arg e "$entry" '[.permissions.allow[]? | select(. == $e)] | length' "$settings")" @@ -1079,6 +1079,38 @@ test_install_hooks_gate_perms_idempotent() { pass "$name" } +test_install_hooks_gate_perms_migrates_legacy_write() { + # Verifies an upgrade replaces the historical managed Write spelling with + # Edit while leaving non-managed /tmp permissions untouched. + # Steps: + # 1. Seed legacy Write(.gate-results), Edit(/tmp/*), and Write(/tmp/*) + # 2. Run install-guards.sh once + # 3. Assert only the managed gate permission is migrated + local name="install-guards-gate-perms-migrates-legacy-write" + should_run "$name" || return 0 + local home="$tmp_root/$name" settings="$tmp_root/$name/.claude/settings.json" + local ws="$tmp_root/$name-ws" + mkdir -p "$home/.claude" "$ws" + jq -n --arg legacy "Write(${ws}/**/.gate-results/**)" '{ + hooks:{}, permissions:{allow:[$legacy,"Edit(/tmp/*)","Write(/tmp/*)"]} + }' > "$settings" + + HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_WORKSPACE="$ws" \ + bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 + + if ! jq -e --arg edit "Edit(${ws}/**/.gate-results/**)" \ + --arg legacy "Write(${ws}/**/.gate-results/**)" ' + (.permissions.allow | index($edit)) != null and + (.permissions.allow | index($legacy)) == null and + (.permissions.allow | index("Edit(/tmp/*)")) != null and + (.permissions.allow | index("Write(/tmp/*)")) != null + ' "$settings" >/dev/null; then + fail "$name" "managed gate permission was not migrated without touching /tmp entries" + return + fi + pass "$name" +} + test_install_hooks_gate_perms_dry_run() { # --dry-run shows a diff but does not mutate settings.json. local name="install-guards-gate-perms-dry-run" @@ -1125,7 +1157,7 @@ test_install_hooks_gate_perms_preserves_existing() { } test_install_hooks_gate_perms_workspace_override() { - # PM_DISPATCH_GATE_WORKSPACE is honoured verbatim — the Write glob uses the + # PM_DISPATCH_GATE_WORKSPACE is honoured verbatim — the Edit glob uses the # override path, not the auto-detected repo parent. local name="install-guards-gate-perms-workspace-override" should_run "$name" || return 0 @@ -1138,16 +1170,16 @@ test_install_hooks_gate_perms_workspace_override() { CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_WORKSPACE="$custom_ws" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local expected="Write(${custom_ws}/**/.gate-results/**)" + local expected="Edit(${custom_ws}/**/.gate-results/**)" if ! jq -e --arg e "$expected" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then - fail "$name" "Write glob did not use PM_DISPATCH_GATE_WORKSPACE; expected: $expected" + fail "$name" "Edit glob did not use PM_DISPATCH_GATE_WORKSPACE; expected: $expected" return fi pass "$name" } test_install_hooks_gate_perms_home_fallback() { - # When the auto-detected git root's parent equals HOME, the Write glob falls + # When the auto-detected git root's parent equals HOME, the Edit glob falls # back to $HOME/**/.gate-results/**. Uses PM_DISPATCH_GATE_GIT_ROOT to inject a # fake git root whose parent is the test HOME, exercising the real fallback branch. local name="install-guards-gate-perms-home-fallback" @@ -1161,9 +1193,9 @@ test_install_hooks_gate_perms_home_fallback() { HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_GIT_ROOT="$home/fake-pm" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local expected="Write(${home}/**/.gate-results/**)" + local expected="Edit(${home}/**/.gate-results/**)" if ! jq -e --arg e "$expected" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then - fail "$name" "Write glob did not use HOME fallback; expected: $expected" + fail "$name" "Edit glob did not use HOME fallback; expected: $expected" return fi pass "$name" @@ -1182,9 +1214,9 @@ test_install_hooks_gate_perms_git_failure_fallback() { HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_GIT_ROOT="" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local expected="Write(${home}/**/.gate-results/**)" + local expected="Edit(${home}/**/.gate-results/**)" if ! jq -e --arg e "$expected" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then - fail "$name" "Write glob did not fall back to HOME on git failure; expected: $expected" + fail "$name" "Edit glob did not fall back to HOME on git failure; expected: $expected" return fi pass "$name" @@ -1207,9 +1239,9 @@ test_install_hooks_gate_perms_normal_git_parent() { HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_GIT_ROOT="$fake_git_root" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local expected="Write(${fake_ws}/**/.gate-results/**)" + local expected="Edit(${fake_ws}/**/.gate-results/**)" if ! jq -e --arg e "$expected" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then - fail "$name" "Write glob did not use git-parent workspace; expected: $expected" + fail "$name" "Edit glob did not use git-parent workspace; expected: $expected" return fi pass "$name" @@ -1224,23 +1256,28 @@ test_install_hooks_gate_perms_uninstall_removes() { local settings="$home/.claude/settings.json" local ws="$tmp_root/$name-ws" mkdir -p "$home/.claude" "$ws" - printf '{"hooks":{},"permissions":{"allow":["Bash(git log:*)"]}}\n' > "$settings" + printf '{"hooks":{},"permissions":{"allow":["Bash(git log:*)","Edit(/tmp/*)"]}}\n' > "$settings" # Install HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_WORKSPACE="$ws" \ bash "$REPO_ROOT/scripts/install-guards.sh" >/dev/null 2>&1 - local write_entry="Write(${ws}/**/.gate-results/**)" - if ! jq -e --arg e "$write_entry" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then - fail "$name" "install did not add Write entry; cannot test lifecycle" + local edit_entry="Edit(${ws}/**/.gate-results/**)" + if ! jq -e --arg e "$edit_entry" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then + fail "$name" "install did not add Edit entry; cannot test lifecycle" return fi + # Simulate an older install artifact coexisting with the current spelling; + # uninstall must recognize and remove both. + jq --arg legacy "Write(${ws}/**/.gate-results/**)" \ + '.permissions.allow += [$legacy]' "$settings" > "$settings.tmp" + mv "$settings.tmp" "$settings" # Uninstall HOME="$home" CLAUDE_HOME="$home/.claude" PM_DISPATCH_GATE_WORKSPACE="$ws" \ bash "$REPO_ROOT/scripts/uninstall-guards.sh" >/dev/null 2>&1 - for entry in "$write_entry" "Bash(pmctl guard check:*)" \ + for entry in "$edit_entry" "Write(${ws}/**/.gate-results/**)" "Bash(pmctl guard check:*)" \ "Bash($home/.local/bin/pmctl guard check:*)" \ "Bash(~/.local/bin/pmctl guard check:*)" "Bash(mkdir -p:*)"; do if jq -e --arg e "$entry" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then @@ -1253,6 +1290,10 @@ test_install_hooks_gate_perms_uninstall_removes() { fail "$name" "unrelated permissions.allow entry was incorrectly removed" return fi + if ! jq -e --arg e "Edit(/tmp/*)" '(.permissions.allow // [] | index($e)) != null' "$settings" >/dev/null; then + fail "$name" "non-managed /tmp permission was incorrectly removed" + return + fi pass "$name" } @@ -1911,9 +1952,9 @@ test_userpromptsubmit_install_wires_hook() { return fi if ! jq -e --arg inject "$ctx_inject" \ - '.hooks.UserPromptSubmit[]? | (.hooks // [])[]? | select(.command == $inject)' \ + '.hooks.UserPromptSubmit[]? | (.hooks // [])[]? | select(.command == $inject and .timeout == 150)' \ "$home/.claude/settings.json" >/dev/null; then - fail "$name" "UserPromptSubmit context hook command not found" + fail "$name" "UserPromptSubmit context hook command/timeout contract not found" return fi pass "$name" @@ -1977,6 +2018,44 @@ test_userpromptsubmit_install_idempotent() { fail "$name" "expected one UserPromptSubmit context hook, got $count" return fi + if ! jq -e --arg inject "$ctx_inject" \ + '.hooks.UserPromptSubmit[]? | (.hooks // [])[]? | select(.command == $inject and .timeout == 150)' \ + "$home/.claude/settings.json" >/dev/null; then + fail "$name" "expected context hook timeout=150 after repeated install" + return + fi + pass "$name" +} + +test_userpromptsubmit_install_upgrades_context_timeout() { + # Verifies an existing managed context hook is upgraded in place while an + # unrelated UserPromptSubmit hook keeps its own timeout. + # Steps: + # 1. Seed the managed context hook with timeout=30 plus an unrelated hook + # 2. Run install-guards.sh once + # 3. Assert managed timeout=150 and unrelated timeout remains unchanged + local name="userpromptsubmit-install-upgrades-context-timeout" + should_run "$name" || return 0 + local home="$tmp_root/$name" ctx_inject unrelated + ctx_inject="$(_ti_hook_cmd_path "$REPO_ROOT/scripts/guard-inject-context.sh")" + unrelated="/home/testuser/custom-prompt-hook.sh" + mkdir -p "$home/.claude" + jq -n --arg ctx "$ctx_inject" --arg unrelated "$unrelated" '{ + permissions:{}, hooks:{UserPromptSubmit:[ + {hooks:[{type:"command",command:$ctx,timeout:30}]}, + {hooks:[{type:"command",command:$unrelated,timeout:7}]} + ]} + }' > "$home/.claude/settings.json" + + HOME="$home" bash "$REPO_ROOT/scripts/install-guards.sh" > /dev/null + + if ! jq -e --arg ctx "$ctx_inject" --arg unrelated "$unrelated" ' + ([.hooks.UserPromptSubmit[]?.hooks[]? | select(.command == $ctx and .timeout == 150)] | length) == 1 and + ([.hooks.UserPromptSubmit[]?.hooks[]? | select(.command == $unrelated and .timeout == 7)] | length) == 1 + ' "$home/.claude/settings.json" >/dev/null; then + fail "$name" "managed timeout was not upgraded without changing unrelated hook" + return + fi pass "$name" } @@ -2638,6 +2717,7 @@ test_dispatch_allowlist_uninstall_removes_entries test_dispatch_allowlist_uninstall_dryrun test_install_hooks_gate_perms_fresh test_install_hooks_gate_perms_idempotent +test_install_hooks_gate_perms_migrates_legacy_write test_install_hooks_gate_perms_dry_run test_install_hooks_gate_perms_preserves_existing test_install_hooks_gate_perms_workspace_override @@ -2654,6 +2734,7 @@ test_install_hooks_uninstall_stale_paths_after_rename test_userpromptsubmit_install_wires_hook test_userpromptsubmit_uninstall_removes_hook test_userpromptsubmit_install_idempotent +test_userpromptsubmit_install_upgrades_context_timeout test_userpromptsubmit_uninstall_preserves_unrelated test_stop_hook_migration test_stop_hook_preservation diff --git a/scripts/test-run-tests.sh b/scripts/test-run-tests.sh index b1f46647..f2d5968b 100755 --- a/scripts/test-run-tests.sh +++ b/scripts/test-run-tests.sh @@ -131,6 +131,21 @@ case_guard_family_maps_to_guard_suite() { fi } +case_prompt_context_timeout_contract_maps_all_consumers() { + local name=prompt-context-timeout-contract-maps-all-consumers 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/prompt-context-timeouts.sh 2>&1) || status=$? + if [[ "$status" -eq 0 ]] && grep -qx 'lint-scripts' "$args" && + grep -qx 'test-guards' "$args" && grep -qx 'test-install' "$args" && + grep -qx 'test-doctor' "$args" && [[ "$out" != *"coverage gaps"* ]]; then + pass "$name" + else + fail "$name" "status=$status out=$out args=$(cat "$args" 2>/dev/null)" + fi +} + case_evidence_contract_maps_to_runner_regression() { local name=evidence-contract-maps-to-runner-regression repo out status=0 args args="$TMP_ROOT/$name.args" @@ -268,6 +283,7 @@ case_agent_mapping_uses_registered_frontmatter_suite case_command_mapping_uses_registered_frontmatter_suite case_skill_mapping_uses_registered_frontmatter_suite case_guard_family_maps_to_guard_suite +case_prompt_context_timeout_contract_maps_all_consumers case_evidence_contract_maps_to_runner_regression case_high_fanout_escalates_full case_unknown_path_fails_without_test_evidence diff --git a/scripts/uninstall-guards.sh b/scripts/uninstall-guards.sh index a1e645a1..7d63cb17 100755 --- a/scripts/uninstall-guards.sh +++ b/scripts/uninstall-guards.sh @@ -57,7 +57,7 @@ trap 'rm -f "$tmp_new"' EXIT _chain_target="" [[ -f "$statusline_chain_conf" ]] && _chain_target=$(head -1 "$statusline_chain_conf") -# Compute the reviewer Write glob to include in managed removal. +# Compute the reviewer Edit glob to include in managed removal. # gate_workspace_root is sourced from scripts/lib/gate-workspace.sh; # falls back to inline detection if the lib is absent (copy-mode installs). if command -v gate_workspace_root >/dev/null 2>&1; then @@ -66,8 +66,9 @@ else _gate_ws="${PM_DISPATCH_GATE_WORKSPACE:-$HOME}" fi # The reviewer permission entries are managed install artifacts: they are added -# by install-guards.sh and removed here. Write(.gate-results), Bash(mkdir -p:*), -# and the pmctl guard-check forms are treated as pm-dispatch-owned; re-add +# by install-guards.sh and removed here. Edit(.gate-results), the historical +# Write(.gate-results) spelling, Bash(mkdir -p:*), and the pmctl guard-check +# forms are treated as pm-dispatch-owned; re-add # manually if needed for other tools after uninstall. The guard check is # allow-listed in bare, absolute, and tilde forms (mirror install-guards.sh: an # in-session reviewer subagent may invoke pmctl by absolute path when its PATH @@ -75,6 +76,9 @@ fi _pmctl_bin_dir="${PMCTL_BIN_DIR:-$HOME/.local/bin}" _managed_json="$({ dispatch_allowlist_entries + printf 'Edit(%s/**/.gate-results/**)\n' "$_gate_ws" + # Upgrade compatibility: old installers wrote a permission kind Claude does + # not accept. Uninstall must still remove that historical managed artifact. printf 'Write(%s/**/.gate-results/**)\n' "$_gate_ws" printf 'Bash(pmctl guard check:*)\n' printf 'Bash(%s/pmctl guard check:*)\n' "$_pmctl_bin_dir"