@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -56,6 +56,7 @@
56
56
 
57
57
  const fs = require('fs');
58
58
  const path = require('path');
59
+ const { parseDispatchIdentity } = require('./dispatch-identity.js');
59
60
 
60
61
  // Isolation modes ADR-1239 declares (mirrors gsd-tools.cjs
61
62
  // routeDispatchIsolation / routeRecordDispatchIsolation).
@@ -223,26 +224,40 @@ function readSentinel(cwd, { clock = Date } = {}) {
223
224
  }
224
225
 
225
226
  /**
226
- * #3045 SECURITY F2: extract the `{plan, phase}` a specific Agent()/Task()
227
- * dispatch is FOR, from the one place that data is reliably embedded today —
228
- * the dispatch prompt/description text (`execute-phase.md`'s Agent() block
229
- * uses the literal shape `description="Execute plan {plan_number} of phase
230
- * {phase_number}"`, and the prompt body's `<objective>` repeats "Execute plan
231
- * {plan_number} of phase {phase_number}-{phase_name}." verbatim — the SAME
232
- * text the orchestrator-worktree EXECUTOR_PROMPT template and Cursor's `task`
233
- * field carry, since Cursor dispatches the same prompt content). There is no
234
- * structured per-dispatch kwarg carrying plan/phase identifiers today (#3045
235
- * would need a larger dispatch-protocol change to add one) — this is
236
- * therefore a best-effort, NOT a guaranteed, extraction: a dispatch whose
237
- * text doesn't match the expected shape returns `{ plan: null, phase: null }`
238
- * and the caller must NOT treat that as a mismatch (see
239
- * `sentinelAppliesToDispatch`).
227
+ * #3045 SECURITY F2 / #4594: extract the `{plan, phase}` a specific
228
+ * Agent()/Task() dispatch is FOR. Variadic — accepts any number of text
229
+ * sources (short description, full prompt body, etc) and delegates to
230
+ * `hooks/lib/dispatch-identity.js::parseDispatchIdentity`, the one canonical
231
+ * owner of both the `[gsd:dispatch phase="…" plan="…"]` marker format and its
232
+ * prose fallback (see `.gsd/phase/fix-4594-dispatch-identity-seam/40-design.md`).
233
+ *
234
+ * MARKER-FIRST CONTRACT: producers embed a structured marker carrying the
235
+ * exact shell values the sentinel itself records (`$PHASE_NUMBER`,
236
+ * `$plan_id`), so producer and consumer agree by construction, independent
237
+ * of how the prose reads or whether a model paraphrases the dispatch
238
+ * sentence. Only when no marker is found anywhere in the supplied texts does
239
+ * this fall back to scanning for the prose frame "execute plan <token> of
240
+ * phase <PHASE TOKEN>".
241
+ *
242
+ * The prose fallback is now CORRECT-OR-ABSENT rather than possibly-wrong:
243
+ * the phase token is bounded by the same grammar `src/phase-id.cts` owns
244
+ * (ADR-2121), so a directory-name slug or trailing punctuation can no longer
245
+ * leak into the phase value, and the prose plan token is never reported at
246
+ * all (it lives in a different namespace than the sentinel's phase-prefixed,
247
+ * slugged `plan_id` — reporting it was the #4594 false-mismatch bug).
248
+ *
249
+ * This is still a best-effort, NOT a guaranteed, extraction: a dispatch that
250
+ * carries neither a marker nor a matching prose frame in ANY supplied text
251
+ * returns `{ plan: null, phase: null }`, and the caller MUST NEVER treat
252
+ * that as a mismatch — see `sentinelAppliesToDispatch`, whose whole
253
+ * contract depends on "missing" and "wrong" being distinguishable.
254
+ *
255
+ * Returns only the two-field `{ plan, phase }` shape existing callers
256
+ * depend on — `parseDispatchIdentity`'s `source` field is discarded here.
240
257
  */
241
- function extractDispatchIdentifiers(text) {
242
- if (typeof text !== 'string' || text.length === 0) return { plan: null, phase: null };
243
- const m = /execute\s+plan\s+(\S+)\s+of\s+phase\s+(\S+)/i.exec(text);
244
- if (!m) return { plan: null, phase: null };
245
- return { plan: m[1], phase: m[2] };
258
+ function extractDispatchIdentifiers(...texts) {
259
+ const { phase, plan } = parseDispatchIdentity(...texts);
260
+ return { plan, phase };
246
261
  }
247
262
 
248
263
  /**
@@ -265,6 +280,29 @@ function sentinelAppliesToDispatch(sentinel, dispatchIds) {
265
280
  return true;
266
281
  }
267
282
 
283
+ /**
284
+ * #4594 F3: build the structured "a fresh sentinel was present but did not
285
+ * apply to this dispatch" descriptor, mirroring the exact comparison
286
+ * `sentinelAppliesToDispatch` performs. Returns `null` when the sentinel is
287
+ * absent, stale, malformed, or DOES apply — i.e. exactly when there is
288
+ * nothing to report as discarded. Otherwise returns the nested
289
+ * `{ sentinel: {phase, plan}, dispatch: {phase, plan} }` shape, reusing the
290
+ * `{phase, plan}` pair already flowing through this module end to end rather
291
+ * than renaming its fields into an ad hoc `sentinelPhase`/`dispatchPlan` bag
292
+ * (previously rebuilt identically at two call sites in the guard hooks).
293
+ */
294
+ function buildSentinelDiscard(sentinel, dispatchIds) {
295
+ if (!sentinel || !sentinel.present || sentinel.stale) return null;
296
+ if (sentinelAppliesToDispatch(sentinel, dispatchIds)) return null;
297
+ return {
298
+ sentinel: { phase: sentinel.phase ?? null, plan: sentinel.plan ?? null },
299
+ dispatch: {
300
+ phase: dispatchIds ? (dispatchIds.phase ?? null) : null,
301
+ plan: dispatchIds ? (dispatchIds.plan ?? null) : null,
302
+ },
303
+ };
304
+ }
305
+
268
306
  module.exports = {
269
307
  VALID_ISOLATION,
270
308
  SENTINEL_RELATIVE_PATH,
@@ -274,4 +312,5 @@ module.exports = {
274
312
  readSentinel,
275
313
  extractDispatchIdentifiers,
276
314
  sentinelAppliesToDispatch,
315
+ buildSentinelDiscard,
277
316
  };
@@ -63,8 +63,8 @@
63
63
  const fs = require('fs');
64
64
  const path = require('path');
65
65
  const os = require('os');
66
- const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js');
67
- const { REASON_CODE } = require('./lib/isolation-deny-reason.js');
66
+ const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
67
+ const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
68
68
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
69
69
 
70
70
  // Required at module top, alongside the other ./lib requires — NOT behind
@@ -385,16 +385,20 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
385
385
  projectExists = false;
386
386
  }
387
387
  if (!projectExists) {
388
- return { gsdProject: false, isolation: null, harnessFlag: null, error: null };
388
+ return { gsdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null };
389
389
  }
390
390
 
391
391
  const sentinel = readSentinel(cwd, { clock });
392
- if (sentinel.present && !sentinel.stale && sentinelAppliesToDispatch(sentinel, dispatchIds)) {
392
+ // Hoisted so the "sentinel was present/fresh but did not apply" case below
393
+ // (#4594 row 15 — Postel's-Law finding) can distinguish itself from
394
+ // "absent"/"stale" without re-deriving applicability.
395
+ const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
396
+ if (sentinel.present && !sentinel.stale && applies) {
393
397
  if (sentinel.isolation !== 'harness-worktree') {
394
- return { gsdProject: true, isolation: sentinel.isolation, harnessFlag: null, error: null };
398
+ return { gsdProject: true, isolation: sentinel.isolation, harnessFlag: null, error: null, sentinelDiscarded: null };
395
399
  }
396
400
  if (sentinel.harnessFlag) {
397
- return { gsdProject: true, isolation: 'harness-worktree', harnessFlag: sentinel.harnessFlag, error: null };
401
+ return { gsdProject: true, isolation: 'harness-worktree', harnessFlag: sentinel.harnessFlag, error: null, sentinelDiscarded: null };
398
402
  }
399
403
  // #3045 BLOCKER 2 fix: the sentinel already PROVED this dispatch requires
400
404
  // isolation (it resolved harness-worktree) but carries no usable flag —
@@ -423,6 +427,7 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
423
427
  'dispatch-isolation sentinel resolved "harness-worktree" but recorded no harness_flag — ' +
424
428
  'cannot verify what parameter the dispatch must carry.'
425
429
  ),
430
+ sentinelDiscarded: null,
426
431
  };
427
432
  }
428
433
 
@@ -430,11 +435,19 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
430
435
  // conservative fallback (#3045 finding — must still cover fail-closed case
431
436
  // (a): a project that opted out of worktrees entirely via
432
437
  // workflow.use_worktrees).
438
+ //
439
+ // #4594 row 15: a PRESENT, FRESH sentinel that simply does not apply to
440
+ // THIS dispatch (identifiers disagree) is a distinct case from "absent" or
441
+ // "stale" — record what was discarded so evaluateDispatch can name it in a
442
+ // block reason instead of silently falling through to a registry-resolution
443
+ // message that never mentions the sentinel existed.
444
+ const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
445
+
433
446
  try {
434
447
  const { isolation, harnessFlag } = resolveRegistryIsolation(cwd, configPath);
435
- return { gsdProject: true, isolation, harnessFlag, error: null };
448
+ return { gsdProject: true, isolation, harnessFlag, error: null, sentinelDiscarded };
436
449
  } catch (err) {
437
- return { gsdProject: true, isolation: null, harnessFlag: null, error: err };
450
+ return { gsdProject: true, isolation: null, harnessFlag: null, error: err, sentinelDiscarded };
438
451
  }
439
452
  }
440
453
 
@@ -464,10 +477,16 @@ function evaluateDispatch(data, { clock = Date } = {}) {
464
477
  }
465
478
 
466
479
  const cwd = data.cwd || process.cwd();
467
- // #3045 SECURITY F2: best-effort plan/phase extraction from this
468
- // dispatch's own description text, so a fresh sentinel that disagrees
469
- // with THIS dispatch is treated as inapplicable rather than trusted.
470
- const dispatchIds = extractDispatchIdentifiers(toolInput.description);
480
+ // #3045 SECURITY F2 / #4594: best-effort plan/phase extraction from this
481
+ // dispatch's own text, so a fresh sentinel that disagrees with THIS
482
+ // dispatch is treated as inapplicable rather than trusted. PROMPT FIRST:
483
+ // `description` is short, model-authored free text that only carries usable
484
+ // identity when the model happens to reproduce the dispatch template
485
+ // verbatim, while the canonical `[gsd:dispatch phase="…" plan="…"]` marker
486
+ // (or, failing that, the prose fallback) lives in the prompt body itself —
487
+ // `description` is kept only as a fallback for a marker/prose match that
488
+ // exists solely in it.
489
+ const dispatchIds = extractDispatchIdentifiers(toolInput.prompt, toolInput.description);
471
490
  const state = resolveIsolationState(cwd, { clock, dispatchIds });
472
491
 
473
492
  if (!state.gsdProject) return { action: 'allow' };
@@ -493,7 +512,8 @@ function evaluateDispatch(data, { clock = Date } = {}) {
493
512
  `required — a guard that cannot verify must not answer "safe" (#3050). Retry once the ` +
494
513
  `project configuration is readable.`;
495
514
  const reasonCode = isBuildFailure ? REASON_CODE.RUNTIME_BUILD_FAILED : REASON_CODE.CONFIG_UNREADABLE;
496
- return { action: 'block', reason, reasonCode };
515
+ const fullReason = state.sentinelDiscarded ? reason + describeSentinelDiscard(state.sentinelDiscarded) : reason;
516
+ return { action: 'block', reason: fullReason, reasonCode, sentinelDiscarded: state.sentinelDiscarded };
497
517
  }
498
518
 
499
519
  if (state.isolation !== 'harness-worktree') return { action: 'allow' };
@@ -503,13 +523,14 @@ function evaluateDispatch(data, { clock = Date } = {}) {
503
523
 
504
524
  if (toolInput[parsed.param] === parsed.value) return { action: 'allow' };
505
525
 
506
- const reason =
526
+ let reason =
507
527
  `Agent isolation guard: this project's dispatch isolation resolves to "harness-worktree", ` +
508
528
  `but the Agent() dispatch for subagent_type="${subagentType}" is missing ` +
509
529
  `${parsed.param}="${parsed.value}". Add ${parsed.param}="${parsed.value}" to the Agent() ` +
510
530
  `call so the executor runs in an isolated worktree instead of the primary checkout ` +
511
531
  `(gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md).`;
512
- return { action: 'block', reason, reasonCode: REASON_CODE.HARNESS_FLAG_MISSING };
532
+ if (state.sentinelDiscarded) reason += describeSentinelDiscard(state.sentinelDiscarded);
533
+ return { action: 'block', reason, reasonCode: REASON_CODE.HARNESS_FLAG_MISSING, sentinelDiscarded: state.sentinelDiscarded };
513
534
  }
514
535
 
515
536
  /* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */
@@ -524,7 +545,12 @@ function main() {
524
545
  const data = JSON.parse(input);
525
546
  const decision = evaluateDispatch(data);
526
547
  if (decision.action === 'block') {
527
- const out = { decision: 'block', reason: decision.reason, reason_code: decision.reasonCode };
548
+ const out = {
549
+ decision: 'block',
550
+ reason: decision.reason,
551
+ reason_code: decision.reasonCode,
552
+ sentinel_discarded: decision.sentinelDiscarded ?? null,
553
+ };
528
554
  // Kimi feeds stderr (not stdout) back to the model on exit 2.
529
555
  deny(out, decision.reason);
530
556
  }
@@ -14,6 +14,9 @@
14
14
  // Thresholds:
15
15
  // WARNING (remaining <= 35%): Agent should wrap up current task
16
16
  // CRITICAL (remaining <= 25%): Agent should stop immediately and save state
17
+ // Both fire-points are overridable per project via .planning/config.json
18
+ // (hooks.context_warning_threshold / hooks.context_critical_threshold, #4285);
19
+ // the values above are the defaults used when the keys are absent or unusable.
17
20
  //
18
21
  // Debounce: 5 tool uses between warnings to avoid spam
19
22
  // Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
@@ -30,8 +33,8 @@ const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
30
33
  // context warning is far cheaper than stalling the agent's work (#3911).
31
34
  const ON_CRASH = HOOK_ON_CRASH.ALLOW;
32
35
 
33
- const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
34
- const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
36
+ const WARNING_THRESHOLD = 35; // remaining_percentage <= 35% (default, see resolveThresholds)
37
+ const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25% (default, see resolveThresholds)
35
38
  const STALE_SECONDS = 60; // ignore metrics older than 60s
36
39
  const DEBOUNCE_CALLS = 5; // min tool uses between warnings
37
40
  // How long after a PreCompact readings stay suspect. The watermark records the
@@ -57,6 +60,41 @@ const COMPACT_GRACE_SECONDS = 60;
57
60
  // watermark this far ahead pushes first recovery from +61 to +66 (measured).
58
61
  const WATERMARK_SKEW_SECONDS = 5;
59
62
 
63
+ // Resolve the two fire-points from the project's `.planning/config.json`
64
+ // (#4285). The constants above are the DEFAULTS; a project overrides either one
65
+ // through `hooks.context_warning_threshold` / `hooks.context_critical_threshold`,
66
+ // which is what keeps a tuned fire-point alive across updates — this file is in
67
+ // the MANAGED registry, so an edit to the constants is re-staged away by the
68
+ // next install.
69
+ //
70
+ // TOTAL and never-throwing: this hook must not block the tool call it rides in
71
+ // on, so every unusable input degrades to the default instead of raising.
72
+ // Unusable is decided by Number.isFinite, which is type-strict (the string
73
+ // "30" and true are both rejected, unlike the global isFinite), plus the 0-100
74
+ // domain of the remaining_percentage these are compared against.
75
+ //
76
+ // The PAIR is validated too, and falls back TOGETHER. `critical >= warning` has
77
+ // no coherent reading — critical fires deeper into the window than warning —
78
+ // and honouring one side of an inconsistent pair silently picks which of the
79
+ // operator's two numbers to discard. This also rejects a single override that
80
+ // contradicts the OTHER key's default (warning 20 with critical absent, i.e.
81
+ // 25); the resulting pair is the same nonsense either way. Set-time validation
82
+ // cannot stand in for this check: `config-set` writes one key per call, so
83
+ // tuning both (warning first, then critical) is transiently inconsistent on
84
+ // disk, and refusing it there would block a legitimate configuration.
85
+ function resolveThresholds(hooks) {
86
+ const defaults = { warning: WARNING_THRESHOLD, critical: CRITICAL_THRESHOLD };
87
+ if (!hooks || typeof hooks !== 'object') return defaults;
88
+
89
+ const usable = (value, fallback) =>
90
+ (Number.isFinite(value) && value >= 0 && value <= 100) ? value : fallback;
91
+
92
+ const warning = usable(hooks.context_warning_threshold, WARNING_THRESHOLD);
93
+ const critical = usable(hooks.context_critical_threshold, CRITICAL_THRESHOLD);
94
+
95
+ return critical < warning ? { warning, critical } : defaults;
96
+ }
97
+
60
98
  // One DEFINITION of what counts as a lifecycle event name, shared by the #3709
61
99
  // PreCompact reset and the #2289 output-envelope allowlist. Two call sites, one
62
100
  // rule — so the two cannot drift into disagreeing about what "no event name" is.
@@ -164,14 +202,11 @@ function writeSentinel(target, payload) {
164
202
  }
165
203
 
166
204
  let input = '';
167
- // Timeout guard: if stdin doesn't close within 10s (e.g. pipe issues on
168
- // Windows/Git Bash, or slow Claude Code piping during large outputs),
169
- // exit silently instead of hanging until Claude Code kills the process
170
- // and reports "hook error". See #775, #1162.
171
- const stdinTimeout = setTimeout(() => allow(undefined), 10000);
172
- process.stdin.setEncoding('utf8');
173
- process.stdin.on('data', chunk => input += chunk);
174
- process.stdin.on('end', () => {
205
+ // Assigned by main(); the handler below clears it. Declared out here rather
206
+ // than inside main() because the handler closes over it.
207
+ let stdinTimeout = null;
208
+
209
+ const handleStdinEnd = () => {
175
210
  clearTimeout(stdinTimeout);
176
211
  try {
177
212
  const data = JSON.parse(input);
@@ -266,18 +301,26 @@ process.stdin.on('end', () => {
266
301
  allow(undefined);
267
302
  }
268
303
 
269
- // Check if context warnings are disabled via config.
304
+ // Check if context warnings are disabled via config, and resolve the two
305
+ // fire-points from the same read (#4285 — one config read, not two).
270
306
  // Collapsed existsSync+readFileSync into a single read guarded by try/catch
271
307
  // (ENOENT or parse error → use defaults, same as old "planningDir absent" branch).
272
308
  const cwd = data.cwd || process.cwd();
309
+ let thresholds = { warning: WARNING_THRESHOLD, critical: CRITICAL_THRESHOLD };
273
310
  try {
274
311
  const configPath = path.join(cwd, '.planning', 'config.json');
275
312
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
276
313
  if (config.hooks?.context_warnings === false) {
277
314
  allow(undefined);
278
315
  }
316
+ // After the disable check, not before: a disabled monitor exits above and
317
+ // never reaches a threshold, so resolving first would only add work to the
318
+ // path that does nothing. allow() exits the process (it does not throw),
319
+ // so this line is unreachable when warnings are off.
320
+ thresholds = resolveThresholds(config.hooks);
279
321
  } catch (e) {
280
- // Missing or unparseable config → proceed with defaults (context warnings enabled)
322
+ // Missing or unparseable config → proceed with defaults (context warnings
323
+ // enabled, thresholds at the constants above, which `thresholds` already holds)
281
324
  }
282
325
 
283
326
  // If no metrics file, this is a subagent or fresh session -- exit silently.
@@ -352,7 +395,7 @@ process.stdin.on('end', () => {
352
395
  const usedPct = metrics.used_pct;
353
396
 
354
397
  // No warning needed
355
- if (remaining > WARNING_THRESHOLD) {
398
+ if (remaining > thresholds.warning) {
356
399
  allow(undefined);
357
400
  }
358
401
 
@@ -389,7 +432,7 @@ process.stdin.on('end', () => {
389
432
 
390
433
  warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1;
391
434
 
392
- const isCritical = remaining <= CRITICAL_THRESHOLD;
435
+ const isCritical = remaining <= thresholds.critical;
393
436
  const currentLevel = isCritical ? 'critical' : 'warning';
394
437
 
395
438
  // Emit immediately on first warning, then debounce subsequent ones
@@ -491,4 +534,34 @@ process.stdin.on('end', () => {
491
534
  // exit(0) fail-open behavior exactly (#3911).
492
535
  crash(ON_CRASH, undefined);
493
536
  }
494
- });
537
+ };
538
+
539
+ // The stdin adapter is the only side-effecting statement in this file, so it is
540
+ // the only thing that must not run on `require()`. Gating it lets a test import
541
+ // `resolveThresholds` and drive it directly — the repo's own conclusion for a
542
+ // seam like this (CONTEXT-INDEX, on the ROADMAP Requirements parser: a closure
543
+ // reachable only by spawning the CLI is one "no fast-check property can do").
544
+ // A spawn-per-case property test is not the same test: it would exercise the
545
+ // resolver at whatever pairs survive to an observable severity, not over its
546
+ // whole numeric domain.
547
+ /* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */
548
+ function main() {
549
+ // Timeout guard: if stdin doesn't close within 10s (e.g. pipe issues on
550
+ // Windows/Git Bash, or slow Claude Code piping during large outputs),
551
+ // exit silently instead of hanging until Claude Code kills the process
552
+ // and reports "hook error". See #775, #1162.
553
+ stdinTimeout = setTimeout(() => allow(undefined), 10000);
554
+ process.stdin.setEncoding('utf8');
555
+ process.stdin.on('data', chunk => input += chunk);
556
+ process.stdin.on('end', handleStdinEnd);
557
+ }
558
+
559
+ if (require.main === module) {
560
+ main();
561
+ }
562
+
563
+ // Exported for the #4285 property test only. The two constants ride along so a
564
+ // test asserts the fallback pair against the SOURCE of truth rather than
565
+ // re-hardcoding 35/25 — a test carrying its own copy of the defaults would stay
566
+ // green if the constants were edited.
567
+ module.exports = { resolveThresholds, WARNING_THRESHOLD, CRITICAL_THRESHOLD };
@@ -62,8 +62,8 @@ const { allow } = require('./lib/hook-exit.js');
62
62
  // hooks/lib/cursor-workspace.js. Staged next to these scripts by
63
63
  // writeCursorHooksJson so the require always resolves post-install.
64
64
  const { resolveStatePath } = require('./lib/cursor-workspace.js');
65
- const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js');
66
- const { REASON_CODE } = require('./lib/isolation-deny-reason.js');
65
+ const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
66
+ const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
67
67
  // #3582: gsd-core/bin/lib/*.cjs (runtime-homes.cjs, worktree-safety.cjs,
68
68
  // runtime-name-policy.cjs, capability-registry.cjs — required below, inside
69
69
  // resolveIsolationEvidence and resolveFallbackIsolation) are tsc build
@@ -491,18 +491,25 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
491
491
  `Refusing to allow this subagent to spawn until the runtime library is built — a guard ` +
492
492
  `that cannot verify must not answer "safe" (#3050).`,
493
493
  reasonCode: REASON_CODE.RUNTIME_BUILD_FAILED,
494
+ sentinelDiscarded: null,
494
495
  };
495
496
  }
496
497
 
498
+ // #3045 BLOCKER fix: a fresh sentinel is authoritative for THIS dispatch's
499
+ // actual resolved isolation — see the doc comment above.
500
+ // #3045 SECURITY F2: a fresh sentinel that names a DIFFERENT plan/phase
501
+ // than this dispatch is not applicable to it — fall through to the
502
+ // conservative fallback exactly as a stale sentinel would.
503
+ // Hoisted (readSentinel never throws) so the "present, fresh, but did not
504
+ // apply" case (#4594 row 15) can be reported on every deny path below
505
+ // instead of silently discarded.
506
+ const sentinel = readSentinel(root, { clock });
507
+ const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
508
+ const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
509
+
497
510
  let declaredIsolation;
498
511
  try {
499
- // #3045 BLOCKER fix: a fresh sentinel is authoritative for THIS
500
- // dispatch's actual resolved isolation — see the doc comment above.
501
- // #3045 SECURITY F2: a fresh sentinel that names a DIFFERENT
502
- // plan/phase than this dispatch is not applicable to it — fall through
503
- // to the conservative fallback exactly as a stale sentinel would.
504
- const sentinel = readSentinel(root, { clock });
505
- declaredIsolation = (sentinel.present && !sentinel.stale && sentinelAppliesToDispatch(sentinel, dispatchIds))
512
+ declaredIsolation = (sentinel.present && !sentinel.stale && applies)
506
513
  ? sentinel.isolation
507
514
  : resolveFallbackIsolation(root, configPath);
508
515
  } catch {
@@ -513,8 +520,10 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
513
520
  `dispatch-isolation configuration ('.planning/config.json' exists under "${root}"). ` +
514
521
  `Refusing to allow this subagent to spawn without being able to verify whether ` +
515
522
  `isolation is required — a guard that cannot verify must not answer "safe" (#3050). ` +
516
- `Retry once the project configuration is readable.`,
523
+ `Retry once the project configuration is readable.` +
524
+ (sentinelDiscarded ? describeSentinelDiscard(sentinelDiscarded) : ''),
517
525
  reasonCode: REASON_CODE.CONFIG_UNREADABLE,
526
+ sentinelDiscarded,
518
527
  };
519
528
  }
520
529
 
@@ -530,8 +539,10 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
530
539
  `GSD subagent isolation guard: this project's dispatch isolation resolves to ` +
531
540
  `"harness-worktree", but the subagentStart payload for this dispatch carries no usable ` +
532
541
  `subagent_type. Refusing to allow it to spawn without being able to confirm whether it ` +
533
- `is a GSD executor — a guard that cannot verify must not answer "safe" (#3050).`,
542
+ `is a GSD executor — a guard that cannot verify must not answer "safe" (#3050).` +
543
+ (sentinelDiscarded ? describeSentinelDiscard(sentinelDiscarded) : ''),
534
544
  reasonCode: REASON_CODE.NO_SUBAGENT_TYPE,
545
+ sentinelDiscarded,
535
546
  };
536
547
  }
537
548
 
@@ -547,8 +558,10 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
547
558
  `"harness-worktree", but whether "${root}" is running in an isolated Cursor worktree ` +
548
559
  `could not be determined (git did not respond). Refusing to allow subagent_type=` +
549
560
  `"${subagentType}" to spawn without being able to verify isolation — a guard that ` +
550
- `cannot verify must not answer "safe" (#3050). Retry once git is responsive.`,
561
+ `cannot verify must not answer "safe" (#3050). Retry once git is responsive.` +
562
+ (sentinelDiscarded ? describeSentinelDiscard(sentinelDiscarded) : ''),
551
563
  reasonCode: REASON_CODE.CANNOT_DETERMINE_ISOLATION,
564
+ sentinelDiscarded,
552
565
  };
553
566
  }
554
567
 
@@ -560,8 +573,10 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
560
573
  `which is not an isolated Cursor worktree — it would edit the user's primary checkout ` +
561
574
  `directly, with no consent and no warning. Start an isolated session first (the ` +
562
575
  `"--worktree" CLI flag or the "/worktree" chat command; Cursor manages these worktrees ` +
563
- `under "~/.cursor/worktrees/") and retry.`,
576
+ `under "~/.cursor/worktrees/") and retry.` +
577
+ (sentinelDiscarded ? describeSentinelDiscard(sentinelDiscarded) : ''),
564
578
  reasonCode: REASON_CODE.NOT_ISOLATED_WORKTREE,
579
+ sentinelDiscarded,
565
580
  };
566
581
  }
567
582
 
@@ -611,7 +626,12 @@ function main() {
611
626
  decision = { action: 'allow' };
612
627
  }
613
628
  if (decision.action === 'deny') {
614
- const out = { permission: 'deny', user_message: decision.reason, reason_code: decision.reasonCode };
629
+ const out = {
630
+ permission: 'deny',
631
+ user_message: decision.reason,
632
+ reason_code: decision.reasonCode,
633
+ sentinel_discarded: decision.sentinelDiscarded ?? null,
634
+ };
615
635
  if (additionalContext !== null) out.additional_context = additionalContext;
616
636
  process.stdout.write(JSON.stringify(out));
617
637
  return;
@@ -26,12 +26,24 @@
26
26
  // .env, .secrets, and .env.<suffix> — EXCEPT .env.example / .env.sample /
27
27
  // .env.template / .env.dist, which are the non-secret templates GSD's own
28
28
  // phase prompt tells executors to read.
29
- // Stated cost: this is narrower than the retired `Read(.env.*)` rule — a
30
- // real secret stored in `.env.example` is not protected.
29
+ // Stated cost (#4580): the exemption matches the token's FINAL EXTENSION,
30
+ // not the whole name, so the trusted set is `.env.<anything>.example` /
31
+ // `.sample` / `.template` / `.dist` — an unbounded family, not four fixed
32
+ // names. A real secret named `.env.prod-real-secrets.example` is NOT
33
+ // protected, and renaming any secret to end in one of those four
34
+ // extensions bypasses the guard across Read, Grep and Bash alike. This is
35
+ // the deliberate cost of #4580, which fixed the prior whole-name
36
+ // comparison wrongly refusing committed, secret-free templates like
37
+ // `.env.local.example`.
31
38
  // A token containing `:` is also tested on the part after its LAST `:`,
32
39
  // so `git show HEAD:.env`, `origin/main:config/.env` and `C:\proj\.env`
33
- // are caught without git-specific parsing. No whitespace trimming: the
34
- // commit message `fix: .env parsing` yields ` .env parsing`, not a name.
40
+ // are caught without git-specific parsing. Leading/interior whitespace is
41
+ // still NOT trimmed: the commit message `fix: .env parsing` yields
42
+ // ` .env parsing`, which is prose, not a name. TRAILING dots and spaces ARE
43
+ // stripped from the basename before classification (`.env.`, `.env..`,
44
+ // `.env `, `.env. ` all normalize to `.env`), because Win32 strips trailing
45
+ // dots and spaces from each path component, so these are aliases for the
46
+ // same on-disk file, not distinct names.
35
47
  //
36
48
  // Bash analysis is a two-pass token scan, not a shell:
37
49
  // pass 1 tokenizes with quote state, comments, redirect operators (with fd
@@ -86,6 +98,7 @@
86
98
  'use strict';
87
99
 
88
100
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
101
+ const { finalExtension, normalizeWindowsBasename, lastSegment } = require('./lib/filename-classification.js');
89
102
 
90
103
  // Fail open on a hook-internal error (see header). Declared ONCE so the
91
104
  // outer catch states its policy explicitly (#3911).
@@ -148,21 +161,18 @@ const GLOB_PROBES = [
148
161
  // ---------------------------------------------------------------------------
149
162
 
150
163
  function isSecretBasename(name) {
151
- if (name === '.env' || name === '.secrets') return true;
152
- if (name.startsWith('.env.')) {
153
- const suffix = name.slice('.env.'.length);
154
- return suffix !== '' && !NON_SECRET_ENV_SUFFIXES.has(suffix.toLowerCase());
164
+ // Win32 strips trailing dots/spaces per path component, so `.env.`,
165
+ // `.env ` etc. resolve to the real `.env` on Windows — normalize FIRST so
166
+ // those aliases can't bypass classification.
167
+ const n = normalizeWindowsBasename(name);
168
+ if (n === '.env' || n === '.secrets') return true;
169
+ if (n.startsWith('.env.')) {
170
+ const suffix = n.slice('.env.'.length);
171
+ return suffix !== '' && !NON_SECRET_ENV_SUFFIXES.has(finalExtension(suffix).toLowerCase());
155
172
  }
156
173
  return false;
157
174
  }
158
175
 
159
- // Last `/`- or `\`-separated segment, ignoring trailing separators.
160
- function lastSegment(tok) {
161
- const s = tok.replace(/[\\/]+$/, '');
162
- const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
163
- return i === -1 ? s : s.slice(i + 1);
164
- }
165
-
166
176
  // True when the token's basename — or the basename of the part after its
167
177
  // last `:` (git `<ref>:<path>`, Windows drive) — is a secret name. Folded to
168
178
  // lower case once at the top so `.ENV` / `.Secrets` match on the
@@ -247,7 +257,19 @@ function globAltSelectsSecret(alt) {
247
257
  if (/^[*?]+$/.test(alt)) return false; // pure wildcard: equivalent to no glob
248
258
  const wild = alt.search(/[*?[]/);
249
259
  const lit = wild === -1 ? alt : alt.slice(0, wild);
250
- if (lit.startsWith('.env.')) return true;
260
+ // #4580: when there is no wildcard, `alt` (== `lit`) is a WHOLE literal
261
+ // filename, so classify it exactly the same way Read/Bash do (by its
262
+ // FINAL extension, via isSecretBasename) rather than by a `.env.`-prefix
263
+ // heuristic — that heuristic mis-blocked multi-dot templates like
264
+ // `.env.local.example`. When a wildcard IS present, `lit` is only a
265
+ // PARTIAL literal prefix (`.env.local.exam*` can still select
266
+ // `.env.local`), which cannot be classified exactly, so the original
267
+ // conservative prefix rule stays.
268
+ if (wild === -1) {
269
+ if (isSecretBasename(lit)) return true;
270
+ } else if (lit.startsWith('.env.')) {
271
+ return true;
272
+ }
251
273
  if (lit !== '' && ('.env.'.startsWith(lit) || '.secrets'.startsWith(lit))) return true;
252
274
  let re;
253
275
  try {
@@ -260,10 +282,14 @@ function globAltSelectsSecret(alt) {
260
282
 
261
283
  // Returns null (allowed), 'secret-read', or 'glob-too-complex'.
262
284
  function classifyGrepGlob(glob) {
263
- const segIdx = glob.replace(/\/+$/, '').lastIndexOf('/');
285
+ // Segment via the SAME `lastSegment` helper Read/Bash use (namesSecret),
286
+ // rather than a hand-rolled forward-slash-only split — the two used to
287
+ // diverge on a backslash-bearing glob (`config\.env`), which `lastSegment`
288
+ // reduces to `.env` but a `/`-only split left untouched, letting it escape
289
+ // this arm's predicate while Read/Bash still blocked it.
264
290
  // Case-fold the last segment (GLOB_PROBES are lower case) so `.ENV*` and
265
291
  // `*.ENV` select the secret namespace on case-insensitive filesystems.
266
- const segment = (segIdx === -1 ? glob : glob.slice(segIdx + 1)).toLowerCase();
292
+ const segment = lastSegment(glob).toLowerCase();
267
293
  const alts = expandBraces(segment);
268
294
  if (alts === null) return 'glob-too-complex';
269
295
  return alts.some(globAltSelectsSecret) ? 'secret-read' : null;
@@ -449,13 +449,17 @@ function contextTokenSuffix(currentUsage) {
449
449
  // --- Compact state format (opt-in) ---------------------------------------------
450
450
 
451
451
  /**
452
- * Collapse GSD's free-text status (often a multi-sentence narrative) to a
453
- * single keyword, built on the canonical normalizer (#2162 approval
454
- * condition): normalizeStateStatus() in state-document.cjs owns the status
455
- * vocabulary (discussing / planning / executing / verifying / completed /
456
- * paused) so the two can't drift. "paused" — the canonical stuck state — is
457
- * uppercased to PAUSED, the one state worth shouting about. Statuses the
458
- * normalizer passes through unrecognized fall back to their first word,
452
+ * Collapse GSD's status value to a single keyword, built on the canonical
453
+ * normalizer (#2162 approval condition): normalizeStateStatus() in
454
+ * state-document.cjs owns the status vocabulary (discussing / planning /
455
+ * executing / verifying / completed / paused) so the two can't drift.
456
+ * #4186: the normalizer recognizes the DECLARED vocabulary by anchored
457
+ * whole-field match — vocabulary values (the state writer persists tokens)
458
+ * collapse to their keyword; free-text narratives are no longer
459
+ * keyword-guessed from substrings (a `.planning/` mention in non-English
460
+ * prose used to render `planning`), and pass through unrecognized to the
461
+ * first-word fallback below. "paused" — the canonical stuck state — is
462
+ * uppercased to PAUSED, the one state worth shouting about. The fallback is
459
463
  * capped at 16 chars so a rogue STATE.md can't blow up the line.
460
464
  * Returns null for empty input.
461
465
  */