@opengsd/gsd-core 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -86,21 +86,19 @@ FIX_REPORT_PATH="${PHASE_DIR}/${PADDED_PHASE}-REVIEW-FIX.md"
86
86
  </step>
87
87
 
88
88
  <step name="check_config_gate">
89
- Check if code review is active via the capability registry:
89
+ Check if code review is active via `workflow.code_review` (the capability's on/off toggle — independent of `workflow.code_review_point`, the loop-point selector; a manual invocation must work regardless of which automatic point is currently configured):
90
90
 
91
91
  ```bash
92
- EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw)
92
+ CODE_REVIEW_ENABLED=$(gsd_run query config-get workflow.code_review --raw 2>/dev/null || echo "true")
93
93
  ```
94
94
 
95
- Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
96
-
97
- If no active code-review step hook exists:
95
+ If `CODE_REVIEW_ENABLED` is not `"true"`:
98
96
  ```
99
97
  Code review fix skipped (code-review capability inactive)
100
98
  ```
101
99
  Exit workflow.
102
100
 
103
- Default is active through the Capability Registry schema — only skip when the registry resolves no active code-review step hook. This check runs AFTER phase validation so invalid phase errors are shown first.
101
+ Default is active (`workflow.code_review` schema default is `true`) — only skip when explicitly disabled. This check runs AFTER phase validation so invalid phase errors are shown first.
104
102
 
105
103
  Note: This reuses the code-review capability activation rather than introducing a separate code-review-fix capability. Rationale: fixes are meaningless without review, so a single activation boundary makes sense. If independent control is needed later, a separate key can be added in v2.
106
104
  </step>
@@ -666,7 +664,7 @@ echo "════════════════════════
666
664
 
667
665
  <success_criteria>
668
666
  - [ ] Phase validated before config gate check
669
- - [ ] Capability gate checked (execute:post code-review hook)
667
+ - [ ] Capability gate checked (`workflow.code_review` config key)
670
668
  - [ ] REVIEW.md existence verified (error if missing)
671
669
  - [ ] REVIEW.md status checked (skip if clean/skipped)
672
670
  - [ ] Agent spawned with correct config (review_path, fix_scope, fix_report_path)
@@ -335,12 +335,6 @@ All subsequent commits go to this branch. User handles merging.
335
335
  From init JSON: `phase_dir`, `plan_count`, `incomplete_count`.
336
336
 
337
337
  Report: "Found {plan_count} plans in {phase_dir} ({incomplete_count} incomplete)"
338
-
339
- **Update STATE.md for phase start:**
340
- ```bash
341
- gsd_run query state.begin-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME}" --plans "${PLAN_COUNT}"
342
- ```
343
- This updates Status, Last Activity, Current focus, Current Position, and plan counts in STATE.md so frontmatter and body text reflect the active phase immediately.
344
338
  </step>
345
339
 
346
340
  <step name="discover_and_group_plans">
@@ -432,6 +426,12 @@ Report:
432
426
  | 1 | 01-01, 01-02 | {from plan objectives, 3-8 words} |
433
427
  | 2 | 01-03 | ... |
434
428
  ```
429
+
430
+ **Update STATE.md for phase start — only now that at least one plan will be dispatched (#5843).** Every all-filtered arm above has already exited or jumped to `aggregate_results` / `update_roadmap`; reaching here means the post-filter list is non-empty. A run that dispatches nothing must leave STATE.md Current Position, Status and Last activity unchanged.
431
+ ```bash
432
+ gsd_run query state.begin-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME}" --plans "${PLAN_COUNT}"
433
+ ```
434
+ This updates Status, Last Activity, Current focus, Current Position, and plan counts in STATE.md so frontmatter and body text reflect the active phase immediately.
435
435
  </step>
436
436
 
437
437
  <step name="cross_ai_delegation">
@@ -1184,7 +1184,21 @@ Route on `$STATUS`: if `passed`, proceed to update_roadmap. Otherwise keep the p
1184
1184
 
1185
1185
  **Step A: Persist human verification items as UAT file.**
1186
1186
 
1187
- Create `{phase_dir}/{phase_num}-UAT.md` using UAT template format:
1187
+ Merge or skip — never blindly create (#5845). A `{phase_num}-UAT.md` may already hold a human's recorded results (from `/gsd:verify-work`, or from an earlier run before gap-closure) that cannot be re-derived. Check first:
1188
+
1189
+ ```bash
1190
+ UAT_FILE="{phase_dir}/{phase_num}-UAT.md"
1191
+ if test -f "$UAT_FILE"; then echo "UAT_EXISTS"; else echo "UAT_ABSENT"; fi
1192
+ ```
1193
+
1194
+ **If `UAT_EXISTS`:** read the file and merge in place. Never reset, overwrite, or replace it, and do not rename it aside (`phase uat-passed` reads `{phase_num}-UAT.round1.md` in the phase directory as a live blocker).
1195
+ - Keep every recorded result exactly as written, including `result:` values other than `[pending]`, `source:`/`coverage_id:` lines, `## Gaps`, and `## Deferred Follow-Ups`. Keep `status` and `started` unchanged (`/gsd:verify-work` resume recomputes the status from the rows).
1196
+ - For each `human_verification` item in VERIFICATION.md, compare its description to the existing `### N. {description}` test entries. Items not already present (matched by description, case-insensitive with whitespace collapsed) are appended under `## Tests` as the next numbered entry with `expected:` from VERIFICATION.md and `result: [pending]`. Items already present are left untouched.
1197
+ - Recompute the `## Summary` counts (`total`, `passed`, `issues`, `pending`, `skipped`, `blocked`) from the merged test list; set `updated` to now.
1198
+ - If nothing to append (every item already present), do not write the file and do not commit; continue to Step B.
1199
+ - Otherwise commit the merged file with the command below.
1200
+
1201
+ **If `UAT_ABSENT`:** create `{phase_dir}/{phase_num}-UAT.md` using UAT template format:
1188
1202
 
1189
1203
  ```markdown
1190
1204
  ---
@@ -0,0 +1,101 @@
1
+ # Plan:Pre Generic Step and Gate Dispatch (#5982)
2
+
3
+ Implements @gsd-core/references/loop-hook-dispatch.md for `plan:pre`. Read `activeHooks` from `PLAN_PRE_HOOKS_JSON` in-context. This runs in **manual and auto-chain mode alike**, before the planner is spawned — a resolved hook is dispatched, never silently dropped.
4
+
5
+ **Dedicated sections.** These `capId`s own a `plan:pre` step or gate that a named section of `plan-phase.md` already runs with its own mode-specific behavior. Skip their `step` and `gate` entries here so none runs twice; dispatch **every other** active `step` and `gate`, first-party or third-party, in array order. (Contributions are injected at §5.55 and §8, not here.)
6
+
7
+ | capId | Section | What that section owns |
8
+ |---|---|---|
9
+ | `research` | §5 | the research step, behind §5's research decision |
10
+ | `drift` | §4.6 | the context-drift gate (§4.6) and the codebase-drift gate (§5.65), both non-blocking |
11
+ | `ui` | §5.6 | the `ui-phase` step and the `ui.plan-gate` gate (Branches 2–6) |
12
+ | `ai-integration` | §5.6 | the AI-integration step (pipeline: skill; manual: recommendation) |
13
+ | `pattern-mapper` | §7.8 | the pattern-mapper agent step, which needs `RESEARCH_PATH` |
14
+ | `intel` | §7.9 | the `intel api-surface` command step, which sets `API_SURFACE_PATH` |
15
+
16
+ If no active `step` or `gate` remains after that, `PLAN_PRE_PRODUCTS` stays empty; return to §5.6. Otherwise make sure the phase values the dispatch lines carry are set — §1 already parsed them from `INIT`, so each is only filled when empty:
17
+
18
+ ```bash
19
+ _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; _gsd_id_ok() { case "$("$1" runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') return 0;; *) return 1;; esac; }; _gsd_homes() { set -- "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}" "$HOME/.gemini/antigravity-ide" "$HOME/.gemini/antigravity-cli" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}" "${CLINE_CONFIG_DIR:-$HOME/.cline}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}" "${CODEX_HOME:-$HOME/.codex}" "${COPILOT_CONFIG_DIR:-${COPILOT_HOME:-$HOME/.copilot}}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}" "${HERMES_HOME:-$HOME/.hermes}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}" "${KIMI_CONFIG_DIR:-$HOME/.config/agents}" "$HOME/.agents" "${KIMI_CODE_HOME:-$HOME/.kimi-code}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" "${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}" "${TRAE_CONFIG_DIR:-$HOME/.trae}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}" "${ZCODE_CONFIG_DIR:-$HOME/.zcode}" "${GROK_AGENTS_HOME:-$HOME/.agents}"; for _h; do _gsd_at "$_h/gsd-core/bin/${_GSD_SHIM_NAME}" && return 0; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif _gsd_homes; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; [ -n "$_G" ] && _gsd_id_ok "$_G"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and no identity-proving gsd_run is on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; _gsd_id_ok gsd_run && GSD_IDENTITY_STATUS=ok; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
20
+ IFS= read -rd '' GSD_ARGS <<'GSD_ARGS_END' || true
21
+ $ARGUMENTS
22
+ GSD_ARGS_END
23
+ GSD_ARGS=${GSD_ARGS%$'\n'}
24
+ GSD_WS=$(echo " $GSD_ARGS" | sed -nE 's/.* --ws +([A-Za-z0-9][A-Za-z0-9._-]*).*/--ws \1/p' | head -n 1)
25
+ PHASE_NUMBER="${PHASE_NUMBER:-$(gsd_run query init.plan-phase ${GSD_WS:+--ws=${GSD_WS##* }} "$PHASE" --pick phase_number 2>/dev/null)}"
26
+ PHASE_DIR="${PHASE_DIR:-$(gsd_run query init.plan-phase ${GSD_WS:+--ws=${GSD_WS##* }} "$PHASE" --pick phase_dir 2>/dev/null)}"
27
+ PHASE_DIR="${PHASE_DIR:-$(gsd_run query init.plan-phase ${GSD_WS:+--ws=${GSD_WS##* }} "$PHASE" --pick expected_phase_dir 2>/dev/null)}"
28
+ PHASE_REQ_IDS="${PHASE_REQ_IDS:-$(gsd_run query init.plan-phase ${GSD_WS:+--ws=${GSD_WS##* }} "$PHASE" --pick phase_req_ids 2>/dev/null)}"
29
+ PHASE_REQ_IDS="${PHASE_REQ_IDS:-TBD}"
30
+ AUTO_CHAIN=$(gsd_run query check auto-mode --pick auto_chain_active 2>/dev/null)
31
+ AUTO_CHAIN="${AUTO_CHAIN:-false}"
32
+ echo "PHASE_DIR=${PHASE_DIR}"
33
+ ```
34
+
35
+ ## Steps
36
+
37
+ For each remaining active entry where `kind == "step"` (exactly one of `ref.skill`, `ref.agent`, `ref.command` is set):
38
+
39
+ - **`ref.skill`** → `Skill(skill="gsd-${ref.skill}", args="${PHASE} ${GSD_WS}")`, appending `--auto` to `args` only when `AUTO_CHAIN` is `true`. Prepend `gsd-` to `ref.skill`.
40
+ - **`ref.agent`** → print `◆ Spawning ${ref.agent}... (runs in a subagent — no output until it returns; expected, not a freeze)`, then `Agent(prompt=filled_hook_fragment, subagent_type=ref.agent, model="{researcher_model}")` with the hook's `fragment.inline` as the prompt body, phase fields filled. Omit `model=` when `researcher_model` is `"inherit"` or empty (#2517).
41
+ - **`ref.command`** → validate it IN-CONTEXT before any shell use: the value must match `^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*)*$`. Check it yourself — never by pasting it into a shell command to be tested there. A value that fails is a malformed manifest: record a warning naming the `capId`, skip it, continue. Only a value that passed is run:
42
+
43
+ ```bash
44
+ # shellcheck disable=SC2086 # the validated command is a family and subcommand: splitting it into two words is intended
45
+ gsd_run ${ref.command} --phase "${PHASE_NUMBER}" --raw
46
+ ```
47
+
48
+ Wait for each result before the next hook. A step is advisory: on error record a warning naming the `capId` and continue, unless `onError == "halt"` — then surface the error and stop the plan-phase workflow.
49
+
50
+ ## Gates
51
+
52
+ For each remaining active entry where `kind == "gate"`, in array order. ⚠ **Validate `check` before shell use** (third-party manifest input; `loop-hook-dispatch.md` § `gate`): replace every `.` in `check.query` with `-`, check that normalized value against `^[a-z][a-z0-9]*(-[a-z0-9]+)*( [a-z][a-z0-9]*(-[a-z0-9]+)*)*$` in-context, and write the dot-normalized query wherever `${hook.check.query}` appears below. Serialize `check.predicate` to compact one-line JSON and pass it as a **single argv element** through the quoted heredoc below — never re-quote it into a shell string, where an apostrophe would close the literal. A malformed check is routed per `onError` in step 1, never run.
53
+
54
+ ```bash
55
+ # named-query gate (the validated query may be several words; splitting it is intended):
56
+ # shellcheck disable=SC2086
57
+ GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}" --raw) && CHECK_EXIT=0 || CHECK_EXIT=$?
58
+ ```
59
+ OR, for a `predicate` gate (ADR-2008):
60
+ ```bash
61
+ IFS= read -rd '' PREDICATE_JSON <<'GSD_PREDICATE_END' || true
62
+ <hook.check.predicate as compact one-line JSON>
63
+ GSD_PREDICATE_END
64
+ PREDICATE_JSON=${PREDICATE_JSON%$'\n'}
65
+ GATE_RESULT=$(gsd_run check predicate --predicate "$PREDICATE_JSON" --phase-dir "${PHASE_DIR}" --phase-number "${PHASE_NUMBER}" --phase-req-ids "${PHASE_REQ_IDS}" --raw) && CHECK_EXIT=0 || CHECK_EXIT=$?
66
+ ```
67
+
68
+ A gate carrying neither — including an `agentVerdict` check, which has no runner at `plan:pre` — cannot be evaluated here: record a warning naming the `capId` and treat it as a check-command failure in step 1, **never** as a silent pass.
69
+
70
+ **Step 1 — did the check command fail?** (malformed check, non-zero `CHECK_EXIT` including `69` UNAVAILABLE, empty output, or unparseable JSON — its `block` is not a verdict):
71
+ - `onError == "halt"` → surface `⚠ plan:pre gate check failed ({hook.capId})` with the error and stop the plan-phase workflow.
72
+ - otherwise → record a warning naming the `capId` and continue to the next gate.
73
+
74
+ **Step 2 — read `GATE_RESULT.block`:**
75
+ - `blocking == true` and `block == true` → output `⚠ Planning blocked by capability gate ({hook.capId}): {message}` (`check.message`, plus the result's own `message` when it has one) and **EXIT the plan-phase workflow. Do not spawn the planner.**
76
+ - `blocking == false` → advisory: surface the message when `block` is `true`, and continue.
77
+ - `block == false` → continue silently.
78
+
79
+ ## Products for the planner (#5960)
80
+
81
+ A step dispatched above hands its declared `produces` files to the planner, so a capability's plan:pre output (for example MemPalace's `MEMORY-RECALL.md`) is read, and only when its step was active this run. Validate each `produces` entry IN-CONTEXT against `^[A-Za-z0-9][A-Za-z0-9._-]*$` (a bare file name: no `/`, no leading `.`); record a warning naming the `capId` and drop a name that fails. Then put the remaining names of the dispatched steps, space-separated, in place of the placeholder:
82
+
83
+ ```bash
84
+ # Plan:pre products (#5960)
85
+ IFS= read -rd '' _PHASE_DIR_IN <<'GSD_PHASE_DIR_END' || true
86
+ <PHASE_DIR from the phase-value block above>
87
+ GSD_PHASE_DIR_END
88
+ PHASE_DIR="${PHASE_DIR:-${_PHASE_DIR_IN%$'\n'}}"
89
+ PLAN_PRE_PRODUCES='<validated produces names of the steps dispatched above, space-separated>'
90
+ [ -n "${PLAN_PRE_PRODUCES}" ] && [ -z "${PHASE_DIR}" ] && echo "WARNING: plan:pre products not collected — PHASE_DIR is empty" >&2
91
+ PLAN_PRE_PRODUCTS=""
92
+ # shellcheck disable=SC2046 # validated bare file names: splitting the substitution is intended
93
+ for _f in $(printf '%s' "$PLAN_PRE_PRODUCES"); do
94
+ if [ -n "${PHASE_DIR}" ] && [ -f "${PHASE_DIR}/${_f}" ]; then PLAN_PRE_PRODUCTS="${PLAN_PRE_PRODUCTS:+${PLAN_PRE_PRODUCTS}, }${PHASE_DIR}/${_f}"; fi
95
+ done
96
+ echo "PLAN_PRE_PRODUCTS=${PLAN_PRE_PRODUCTS}"
97
+ ```
98
+
99
+ `PLAN_PRE_PRODUCTS` is listed in the §8 planner `<required_reading>`; it stays empty when no dispatched step wrote its file. A dedicated-section capability's output (RESEARCH.md, PATTERNS.md, UI-SPEC.md, ...) reaches the planner through its own section, not this list. Shell variables do not survive between tool calls, so fill the `PHASE_DIR` value the phase-value block printed into the quoted heredoc as well.
100
+
101
+ When every remaining hook has been handled, return to §5.6.
@@ -99,7 +99,7 @@ CONTEXT_WINDOW=$(gsd_run query config-get context_window --raw 2>/dev/null || ec
99
99
  MVP_MODE_CFG=$(gsd_run query config-get workflow.mvp_mode --raw 2>/dev/null || echo "false")
100
100
  ```
101
101
 
102
- When the tdd capability's `workflow.tdd_mode` is active (resolved via the plan:pre render-hooks), the planner agent is instructed to apply `type: tdd` to eligible tasks using heuristics from `gsd-core/references/tdd.md`. The TDD guidance is injected via the tdd capability's contribution hook at §5.6; no inline config-get is needed.
102
+ When the tdd capability's `workflow.tdd_mode` is active (resolved via the plan:pre render-hooks), the planner agent is instructed to apply `type: tdd` to eligible tasks using heuristics from `gsd-core/references/tdd.md`. The TDD guidance is injected via the tdd capability's contribution hook at §8; no inline config-get is needed.
103
103
 
104
104
  When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior-phase CONTEXT.md/SUMMARY.md files plus any phases in the current phase's `Depends on:` field (explicit deps load regardless of recency).
105
105
 
@@ -357,9 +357,9 @@ If `section_manifest` is `null` or `"research-only-modifiers"` is in its `includ
357
357
 
358
358
  ### 5.1. Standard Research Decision
359
359
 
360
- **Skip if** `RESEARCH_ONLY=true` (the research-only mode in 5.0 already determined the path: spawn or exit). Without this guard, an LLM following the workflow could fall through into "use existing, skip to step 6" → planner spawn, violating the research-only contract. **CR #3045 finding: this gate makes the early-exit unreachable from any non-research-only branch.**
360
+ **Skip if** `RESEARCH_ONLY=true` (the research-only mode in 5.0 already determined the path: spawn or exit). Without this guard, an LLM following the workflow could fall through into "use existing, continue to step 5.5" → planner spawn, violating the research-only contract. **CR #3045 finding: this gate makes the early-exit unreachable from any non-research-only branch.**
361
361
 
362
- **If `has_research` is true (from init) AND no `--research` flag:** Use existing, skip to step 6.
362
+ **If `has_research` is true (from init) AND no `--research` flag:** Use existing, continue to step 5.5.
363
363
 
364
364
  **If RESEARCH.md missing OR `--research` flag:**
365
365
 
@@ -391,7 +391,7 @@ AskUserQuestion([
391
391
  ])
392
392
  ```
393
393
 
394
- If user selects "Skip research": skip to step 6.
394
+ If user selects "Skip research": continue to step 5.5.
395
395
 
396
396
  **If `--auto` and `research_enabled` is false:** Skip research silently (preserves automated behavior).
397
397
 
@@ -437,7 +437,7 @@ Agent(
437
437
 
438
438
  ### Handle Researcher Return
439
439
 
440
- - **`## RESEARCH COMPLETE`:** Display confirmation, continue to step 6
440
+ - **`## RESEARCH COMPLETE`:** Display confirmation, continue to step 5.5
441
441
  - **`## RESEARCH BLOCKED`:** Display blocker, offer: 1) Provide context, 2) Skip research, 3) Abort
442
442
 
443
443
  <!-- gsd:section id="research-only-early-exit" when="flag:--research-phase" -->
@@ -448,14 +448,14 @@ If `section_manifest` is `null` or `"research-only-early-exit"` is in its `inclu
448
448
 
449
449
  Skip if `nyquist_validation_enabled` is false OR `research_enabled` is false.
450
450
 
451
- If `research_enabled` is false and `nyquist_validation_enabled` is true: warn "Nyquist validation enabled but research disabled — VALIDATION.md cannot be created without RESEARCH.md. Plans will lack validation requirements (Dimension 8)." Continue to step 6.
451
+ If `research_enabled` is false and `nyquist_validation_enabled` is true: warn "Nyquist validation enabled but research disabled — VALIDATION.md cannot be created without RESEARCH.md. Plans will lack validation requirements (Dimension 8)." Continue to step 5.55.
452
452
 
453
453
  **But Nyquist is not applicable for this run** when all of the following are true:
454
454
  - `research_enabled` is false
455
455
  - `has_research` is false
456
456
  - no `--research` flag was provided
457
457
 
458
- In that case: **skip validation-strategy creation entirely**. Do **not** expect `RESEARCH.md` or `VALIDATION.md` for this run, and continue to Step 6.
458
+ In that case: **skip validation-strategy creation entirely**. Do **not** expect `RESEARCH.md` or `VALIDATION.md` for this run, and continue to step 5.55.
459
459
 
460
460
  ```bash
461
461
  grep -l "## Validation Architecture" "${PHASE_DIR}"/*-RESEARCH.md 2>/dev/null || true
@@ -506,7 +506,7 @@ Continue to step 5.6. Security config is passed to the planner in step 8.
506
506
 
507
507
  ## 5.6. Plan:Pre Capability Dispatch and UI Design Contract Gate
508
508
 
509
- > Capability-driven dispatch. Resolves active `plan:pre` hooks via the capability registry; each hook's `when` condition is evaluated by the registry — no inline config-get needed. This section handles skill-based planning preflights such as `ai-integration`, agent-backed hooks through `ref.agent`, and the UI gate whose deterministic check comes from `check.query`.
509
+ > Capability-driven dispatch. Resolves active `plan:pre` hooks via the capability registry; each hook's `when` condition is evaluated by the registry — no inline config-get needed. This section runs the generic step/gate dispatch, the `ai-integration` preflight, and the UI gate whose deterministic check comes from `check.query`.
510
510
  >
511
511
  > **Config semantics (cutover fix):** `workflow.ui_phase` gates UI-SPEC *generation* (step); `workflow.ui_safety_gate` gates the *planning block* (gate). Both-on = identical to OLD §5.6. Intended change: `{ui_phase:true, ui_safety_gate:false}` now auto-generates in pipelines but does NOT block manual planning (each key controls exactly what its description says).
512
512
 
@@ -517,12 +517,9 @@ HOOKS_JSON="$PLAN_PRE_HOOKS_JSON"
517
517
 
518
518
  Read the `activeHooks` array directly from `PLAN_PRE_HOOKS_JSON` / `HOOKS_JSON` (in-context — do NOT invoke a shell pipeline).
519
519
 
520
- **Branch 1 — all plan:pre hooks inactive (`activeHooks` is empty or absent):** Skip to step 6.
520
+ **Branch 1 — all plan:pre hooks inactive (`activeHooks` is empty or absent):** Skip to step 6; `PLAN_PRE_PRODUCTS` stays empty.
521
521
 
522
- **Generic step hook dispatch contract:** For each active entry where `kind == "step"`:
523
- - If `ref.skill` is set, dispatch with `Skill(skill="gsd-${ref.skill}", args="${PHASE} --auto ${GSD_WS}")` when pipeline mode allows auto-chaining. Prepend `gsd-` to `ref.skill` — `ui-phase` → `gsd-ui-phase`.
524
- - If `ref.agent` is set, dispatch with `Agent(prompt=filled_hook_fragment, subagent_type=ref.agent, model="{researcher_model}")`. Use the hook's `fragment.inline` as the prompt body and fill phase fields before spawning.
525
- - The `research` hook is handled by §5.1's research decision. The `pattern-mapper` hook is handled by §7.8 after `RESEARCH_PATH` is known. Future plan:pre agent hooks use the same `ref.agent` fragment contract.
522
+ **Generic dispatch (#5982):** dispatch every active `kind == "step"` hook (`ref.skill`, `ref.agent`, `ref.command`) and evaluate every active `kind == "gate"` hook (`check.query`, `check.predicate`) per @gsd-core/references/loop-hook-dispatch.md, in manual and auto-chain mode: read and execute `gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md` — except the capabilities its table assigns to a dedicated section (research, drift, ui, ai-integration, pattern-mapper, intel). A blocking gate that blocks exits the workflow.
526
523
 
527
524
  **AI integration capability:** If the active `ai-integration` step hook is present, `AI_SPEC_PATH` is empty, and the phase goal contains AI keywords (`agent`, `llm`, `rag`, `chatbot`, `embedding`, `langchain`, `llamaindex`, `crewai`, `langgraph`, `openai`, `anthropic`, `vector`, `llm eval`), then:
528
525
  - In pipeline / `--auto` mode, invoke the hook's `ref.skill` via `Skill(skill="gsd-${ref.skill}", args="${PHASE} --auto ${GSD_WS}")`.
@@ -536,7 +533,7 @@ GATE=$(gsd_run check ui-plan-gate "${PHASE}" --raw) && GATE_EXIT=0 || GATE_EXIT=
536
533
 
537
534
  A non-zero `GATE_EXIT` is a command failure, including `69` (`UNAVAILABLE`: the gate could not read its evidence, so its `frontend: false` is not an answer): surface it and stop; never fall through to Branch 2. Otherwise read `frontend`, `hasUiSpec`, and `block` from `GATE`.
538
535
 
539
- **Branch 2 — no frontend indicators (`frontend` is `false`):** Skip silently to step 6.
536
+ **Branch 2 — no frontend indicators (`frontend` is `false`):** Skip silently to step 5.65.
540
537
 
541
538
  **Branch 3 — UI-SPEC already exists (`hasUiSpec` is `true`):**
542
539
 
@@ -545,9 +542,9 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
545
542
  UI_SPEC_PATH="${UI_SPEC_FILE}"
546
543
  ```
547
544
 
548
- Display: `Using UI design contract: ${UI_SPEC_PATH}`. Continue to step 6.
545
+ Display: `Using UI design contract: ${UI_SPEC_PATH}`. Continue to step 5.65.
549
546
 
550
- **Branch 4 — `--skip-ui` in `$ARGUMENTS`:** Skip silently to step 6.
547
+ **Branch 4 — `--skip-ui` in `$ARGUMENTS`:** Skip silently to step 5.65.
551
548
 
552
549
  **Branches 5 & 6 — frontend detected, UI-SPEC missing, no `--skip-ui`.**
553
550
 
@@ -558,7 +555,7 @@ AUTO_CHAIN=$(gsd_run query check auto-mode --pick auto_chain_active 2>/dev/null)
558
555
  AUTO_CHAIN="${AUTO_CHAIN:-false}"
559
556
  ```
560
557
 
561
- **Branch 5 — `AUTO_CHAIN` is `true` (pipeline / `--auto`):** Fire each active UI **step** hook — runs independently of whether a gate is active (covers `{ui_phase:true,ui_safety_gate:false}`). For each entry in `activeHooks` (in array order) where `kind == "step"` and `ref.skill` is set:
558
+ **Branch 5 — `AUTO_CHAIN` is `true` (pipeline / `--auto`):** Fire each active UI **step** hook — runs independently of whether a gate is active (covers `{ui_phase:true,ui_safety_gate:false}`). For each entry in `activeHooks` (in array order) where `kind == "step"`, `capId == "ui"` and `ref.skill` is set:
562
559
 
563
560
  ```
564
561
  Skill(skill="gsd-${ref.skill}", args="${PHASE} --auto ${GSD_WS}")
@@ -571,9 +568,9 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
571
568
  UI_SPEC_PATH="${UI_SPEC_FILE}"
572
569
  ```
573
570
 
574
- Continue to step 6.
571
+ Continue to step 5.65.
575
572
 
576
- **Branch 6 — `AUTO_CHAIN` is `false` (manual): generic gate handling.** For each entry in `activeHooks` where `kind == "gate"` and `blocking` is `true`: if `block:true` (from `GATE`), output the block below and **EXIT the plan-phase workflow**. If no active blocking gate (e.g. `workflow.ui_safety_gate` is off), continue to step 6 — no block.
573
+ **Branch 6 — `AUTO_CHAIN` is `false` (manual): UI gate handling.** For each entry in `activeHooks` where `kind == "gate"`, `capId == "ui"` and `blocking` is `true`: if `block:true` (from `GATE`), output the block below and **EXIT the plan-phase workflow**. If no active blocking gate (e.g. `workflow.ui_safety_gate` is off), continue to step 5.65 — no block.
577
574
 
578
575
  Output this markdown directly (not as a code block):
579
576
 
@@ -637,6 +634,7 @@ SKETCH_FINDINGS_PATH=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/nul
637
634
  # on non-AI phases; gating it there silently starves the planner of the SPEC (#550 review).
638
635
  # init resolves the plain phase SPEC (never the -AI-SPEC.md / -UI-SPEC.md variants) through the one shared predicate.
639
636
  PHASE_DIR_FOR_SPEC=$(_gsd_field "$INIT" phase_dir)
637
+ PHASE_DIR_FOR_SPEC="${PHASE_DIR_FOR_SPEC:-$(_gsd_field "$INIT" expected_phase_dir)}" # §2 may create it after INIT (#5960)
640
638
  SPEC_PATH=$(_gsd_field "$INIT" spec_path)
641
639
  SPEC_FILE="${SPEC_PATH}"
642
640
  # Resolve the phase UI-SPEC separately (the glob above excludes -UI-SPEC.md); it carries the
@@ -780,6 +778,7 @@ Planner prompt:
780
778
  - {SPIKE_FINDINGS_PATH} (Spike Findings — validated patterns, constraints, landmines from experiments, if exists)
781
779
  - {SKETCH_FINDINGS_PATH} (Sketch Findings — validated design decisions, CSS patterns, visual direction, if exists)
782
780
  - {API_SURFACE_PATH} (API Surface — HINT ONLY, when intel capability is active; see <intel_surface_hint> below)
781
+ - {PLAN_PRE_PRODUCTS} (plan:pre capability outputs from this run, e.g. MemPalace's MEMORY-RECALL.md — prior decisions, patterns, surprises; if any)
783
782
  ${CONTEXT_WINDOW >= 500000 ? `
784
783
  **Cross-phase context (1M model enrichment):**
785
784
  - CONTEXT.md files from the 3 most recent completed phases (locked decisions — maintain consistency)
@@ -74,6 +74,7 @@ const os = require('os');
74
74
  const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, agedNoneGovernsDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
75
75
  const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
76
76
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
77
+ const { readConfigFileBounded } = require('./lib/config-read.js');
77
78
 
78
79
  // Required at module top, alongside the other ./lib requires — NOT behind
79
80
  // ensureRuntimeBuild() below. Terminating on a parse/timeout failure must
@@ -208,7 +209,7 @@ function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidate
208
209
  // A throw here (corrupt JSON, EISDIR, permission error) propagates to the
209
210
  // caller's catch as a resolution failure — this function only decides
210
211
  // "confident vs not", never resolution failure.
211
- const raw = fs.readFileSync(configPath, 'utf-8');
212
+ const raw = readConfigFileBounded(configPath, { required: true });
212
213
  const parsed = JSON.parse(raw);
213
214
  if (parsed && typeof parsed === 'object' && 'runtime' in parsed) {
214
215
  const configRuntime = resolveRuntimeNameFromCandidates(parsed.runtime);
@@ -331,7 +332,7 @@ function resolveRegistryIsolation(cwd, configPath) {
331
332
  }
332
333
  if (!ladderAnswered) {
333
334
  try {
334
- const raw = fs.readFileSync(configPath, 'utf-8');
335
+ const raw = readConfigFileBounded(configPath, { required: true });
335
336
  const parsedCfg = JSON.parse(raw);
336
337
  if (parsedCfg && typeof parsedCfg === 'object' && parsedCfg.workflow &&
337
338
  typeof parsedCfg.workflow === 'object' && parsedCfg.workflow.use_worktrees === false) {
@@ -18,9 +18,9 @@
18
18
  // Enabled for all Claude Code installs. This hook is always-on — it is a
19
19
  // no-op when .planning/config.json is absent (ENOENT → exit 0).
20
20
 
21
- const fs = require('fs');
22
21
  const path = require('path');
23
22
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
23
+ const { readConfigFileBounded } = require('./lib/config-read.js');
24
24
 
25
25
  // This hook only injects an advisory config-reload summary; a crash mid-parse
26
26
  // must not block the session or the FileChanged event that triggered it — the
@@ -70,7 +70,7 @@ process.stdin.on('end', () => {
70
70
  // Read the updated config file
71
71
  let config;
72
72
  try {
73
- const raw = fs.readFileSync(filePath, 'utf8');
73
+ const raw = readConfigFileBounded(filePath, { required: true });
74
74
  config = JSON.parse(raw);
75
75
  } catch (e) {
76
76
  if (e && e.code === 'ENOENT') allow(undefined);
@@ -28,6 +28,7 @@ const os = require('os');
28
28
  const path = require('path');
29
29
  const { spawn } = require('child_process');
30
30
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
31
+ const { readConfigFileBounded } = require('./lib/config-read.js');
31
32
 
32
33
  // This hook only injects an advisory context-usage warning; it never blocks
33
34
  // the tool call it rides in on. A crash here (e.g. a malformed bridge file)
@@ -334,7 +335,7 @@ const handleStdinEnd = () => {
334
335
  let thresholds = { warning: WARNING_THRESHOLD, critical: CRITICAL_THRESHOLD };
335
336
  try {
336
337
  const configPath = path.join(cwd, '.planning', 'config.json');
337
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
338
+ const config = JSON.parse(readConfigFileBounded(configPath, { required: true }));
338
339
  if (config.hooks?.context_warnings === false) {
339
340
  allow(undefined);
340
341
  }
@@ -65,6 +65,7 @@ const fs = require('fs');
65
65
  const path = require('path');
66
66
  const os = require('os');
67
67
  const { allow } = require('./lib/hook-exit.js');
68
+ const { readConfigFileBounded } = require('./lib/config-read.js');
68
69
 
69
70
  // Workspace resolution is shared across the Cursor hooks (#2587) — see
70
71
  // hooks/lib/cursor-workspace.js. Staged next to these scripts by
@@ -133,12 +134,12 @@ function realpathOrNull(p, realpathFn) {
133
134
  * i.e. `~/.cursor/worktrees` by default — `getGlobalConfigDir('cursor')`
134
135
  * honors the `CURSOR_CONFIG_DIR` env override and `~` expansion for free).
135
136
  * That is made NECESSARY AND SUFFICIENT below. Do NOT reinstate
136
- * `resolveWorktreeLinkage`'s `linked_worktree_root` mode as an alternative
137
+ * a linked-worktree result of `resolveProjectContext` as an alternative
137
138
  * OR'd proof of isolation — that is precisely the bypass finding 3 closed;
138
139
  * a future "simplification" that merges it back in re-opens unconsented
139
140
  * writes to the human's active checkout.
140
141
  *
141
- * `resolveWorktreeLinkage` is still called, but ONLY as a diagnostic: it
142
+ * `resolveProjectContext` is still called, but ONLY as a diagnostic: its provenance
142
143
  * distinguishes "confidently not isolated" from "git could not answer
143
144
  * (timeout) — cannot determine" so the eventual deny reason stays
144
145
  * actionable. Its result never flips `isolated`.
@@ -195,8 +196,10 @@ function resolveIsolationEvidence(root, { realpath = fs.realpathSync } = {}) {
195
196
  let linkageReason = null;
196
197
  try {
197
198
  // Sibling data/policy module, staged alongside this hook at install time.
198
- const { resolveWorktreeLinkage } = require('../gsd-core/bin/lib/worktree-safety.cjs');
199
- linkageReason = resolveWorktreeLinkage(root).reason;
199
+ const { resolveProjectContext } = require('../gsd-core/bin/lib/project-context.cjs');
200
+ const gitProvenance = resolveProjectContext(root).provenance;
201
+ // A degraded context (git timed out or could not run) is "cannot determine".
202
+ linkageReason = gitProvenance.degraded ? 'git_timed_out' : gitProvenance.reason;
200
203
  } catch {
201
204
  linkageReason = null;
202
205
  }
@@ -421,7 +424,7 @@ function resolveFallbackIsolation(root, configPath) {
421
424
  const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
422
425
 
423
426
  let runtimeId = resolveRuntimeNameFromCandidates(process.env.GSD_RUNTIME);
424
- const rawConfig = fs.readFileSync(configPath, 'utf-8');
427
+ const rawConfig = readConfigFileBounded(configPath, { required: true });
425
428
  const parsedConfig = JSON.parse(rawConfig);
426
429
  if (!runtimeId && parsedConfig && typeof parsedConfig === 'object' && 'runtime' in parsedConfig) {
427
430
  runtimeId = resolveRuntimeNameFromCandidates(parsedConfig.runtime) || null;
@@ -22,8 +22,8 @@
22
22
  // hook source files — these legitimately contain injection-like strings.
23
23
 
24
24
  const path = require('path');
25
- const fs = require('fs');
26
25
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
26
+ const { readConfigFileBounded } = require('./lib/config-read.js');
27
27
 
28
28
  // This is a PostToolUse advisory scanner over content the tool call already
29
29
  // returned; a crash while scanning must not retroactively block that Read/
@@ -343,7 +343,7 @@ process.stdin.on('end', () => {
343
343
  try {
344
344
  const cfgBase = data.cwd || process.cwd();
345
345
  const cfgPath = path.join(cfgBase, '.planning', 'config.json');
346
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
346
+ const cfg = JSON.parse(readConfigFileBounded(cfgPath, { required: true }));
347
347
  blocking = cfg.security?.injection_blocking === true;
348
348
  } catch { /* no config ⇒ advisory */ }
349
349
  }