@opengsd/gsd-core 1.12.0 → 1.13.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 (286) 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 +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -1311,6 +1311,128 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
1311
1311
  }
1312
1312
  return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
1313
1313
  }
1314
+ /**
1315
+ * Stage the `hooks/lib/` helpers a set of staged hook scripts require, walking
1316
+ * the require graph TRANSITIVELY to a fixed point.
1317
+ *
1318
+ * Extracted from writeCursorHooksJson (#3911 review, commit 704859e9c) so the
1319
+ * reduced Codex hook bundle can share one implementation instead of growing a
1320
+ * second, divergent copy (#4087 / #4098). Both reduced bundles hand-pick which
1321
+ * hook SCRIPTS they ship, and neither can hand-pick their helpers correctly for
1322
+ * long: `hooks/lib/hook-exit.js` requires `./cli-exit.js`, which requires
1323
+ * `./exit-code-registry.js` — with NO `./lib/` prefix, because from inside
1324
+ * `lib/` the sibling is already local. A single-pass scan for the `./lib/…`
1325
+ * spelling used FROM a hook script stages hook-exit.js and stops, and the
1326
+ * installed hook then dies on MODULE_NOT_FOUND at load, before its own
1327
+ * try/catch, on every event it is registered for.
1328
+ *
1329
+ * Scans CONTENT rather than paths so a caller can seed from whatever it staged,
1330
+ * transformed or not, without this helper knowing the caller's layout.
1331
+ *
1332
+ * @returns the lib filenames actually staged, in staging order.
1333
+ */
1334
+ function stageTransitiveHookLibs(opts) {
1335
+ const { seedSources, srcLibDir, destLibDir, runtimeLabel, transform } = opts;
1336
+ const requiredLibFiles = new Set();
1337
+ const scannedLibFiles = new Set();
1338
+ const staged = [];
1339
+ // `./X` means DIFFERENT things depending on where the scanned file lives, and
1340
+ // conflating them stages the wrong file. From a hook SCRIPT in hooks/, a bare
1341
+ // `./X` is a sibling hook-level artifact — Codex's gsd-check-update-worker.js
1342
+ // requires `./managed-hooks-registry.cjs`, which lives in hooks/, not
1343
+ // hooks/lib/ — so only the explicit `./lib/X` spelling is a lib requirement.
1344
+ // From inside a LIB file, the sibling is already local, so `./X` IS a lib
1345
+ // requirement (hook-exit.js -> ./cli-exit.js -> ./exit-code-registry.js); that
1346
+ // is the case 704859e9c added and it must keep working. Cursor never exposed
1347
+ // the difference because none of its staged scripts has a bare sibling
1348
+ // require; Codex's does, and the fail-loud guard below caught it immediately
1349
+ // by demanding managed-hooks-registry.cjs out of hooks/dist/lib.
1350
+ // Fresh per call: a module-level /g regex carries lastIndex across calls and
1351
+ // would silently skip matches on the second install in one process.
1352
+ const seedRequireRe = /require\(\s*['"]\.\/lib\/([A-Za-z0-9._-]+)['"]\s*\)/g;
1353
+ const libRequireRe = /require\(\s*['"]\.\/(?:lib\/)?([A-Za-z0-9._-]+)['"]\s*\)/g;
1354
+ // A NESTED helper path is outside the flat layout hooks/lib/ has and the
1355
+ // build emits, and the character classes above cannot express it — so it
1356
+ // would be a SILENT miss, staging nothing and shipping a hook that dies at
1357
+ // load. Detected separately and refused loudly instead: a silent miss is the
1358
+ // failure mode this whole function exists to remove (review of #4087).
1359
+ const nestedRequireRe = /require\(\s*['"]\.\/lib\/[A-Za-z0-9._-]+\/[^'"]*['"]\s*\)/;
1360
+ const scanForLibRequires = (source, fromLib) => {
1361
+ if (nestedRequireRe.test(source)) {
1362
+ throw new Error(`A staged ${runtimeLabel} hook requires a NESTED hooks/lib path. hooks/lib/ is flat and `
1363
+ + 'this stager only resolves flat helper names, so the nested helper would never be '
1364
+ + 'staged and the hook would throw MODULE_NOT_FOUND at load. Flatten the helper or '
1365
+ + 'extend this stager deliberately.');
1366
+ }
1367
+ const re = fromLib ? libRequireRe : seedRequireRe;
1368
+ re.lastIndex = 0;
1369
+ let m;
1370
+ while ((m = re.exec(source)) !== null) {
1371
+ const candidate = m[1];
1372
+ // A capture with no alphanumeric character is not a module name — it is
1373
+ // prose. This scan reads whole file text, comments included, and
1374
+ // hooks/lib/injection-patterns.js's own header documents this mechanism
1375
+ // with the literal string `require('./lib/...')`, which captures `...`
1376
+ // and would send the resolver hunting for `hooks/lib/...` and fail the
1377
+ // install (measured; that helper is not staged for either reduced bundle
1378
+ // today, so it is latent rather than live).
1379
+ //
1380
+ // KNOWN LIMIT, disclosed rather than papered over: this does NOT make the
1381
+ // scan comment-aware. A comment naming a REAL helper — `require(
1382
+ // './lib/git-cmd.js')` in prose — still registers it and would over-stage
1383
+ // that helper. Closing that needs a comment-stripping pass; the
1384
+ // line-based stripper in scripts/lint-hooks-runtime-build-seam.cjs is the
1385
+ // precedent (its header explains why the naive two-regex strip corrupts
1386
+ // these very files), but promoting a lint-script helper into installer
1387
+ // runtime code is a larger change than this fix.
1388
+ if (!/[A-Za-z0-9]/.test(candidate))
1389
+ continue;
1390
+ requiredLibFiles.add(candidate);
1391
+ }
1392
+ };
1393
+ for (const source of seedSources)
1394
+ scanForLibRequires(source, false);
1395
+ if (requiredLibFiles.size === 0)
1396
+ return staged;
1397
+ node_fs_1.default.mkdirSync(destLibDir, { recursive: true });
1398
+ // Iterate to a fixed point: staging a lib file can add MORE required lib
1399
+ // files (its own requires), which must themselves be staged and scanned.
1400
+ let libFile = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
1401
+ while (libFile !== undefined) {
1402
+ scannedLibFiles.add(libFile);
1403
+ // Node's own extension resolution: `require('./lib/x')` is a valid, working
1404
+ // CommonJS spelling today, and matching only the extension-bearing form
1405
+ // resolved `x` literally, found nothing, and failed the install on a
1406
+ // legitimate require (review of #4087). Try the bare name first so an
1407
+ // extension-bearing capture still wins, then .js/.cjs.
1408
+ let resolvedName;
1409
+ for (const candidate of [libFile, `${libFile}.js`, `${libFile}.cjs`]) {
1410
+ if (node_fs_1.default.existsSync(node_path_1.default.join(srcLibDir, candidate))) {
1411
+ resolvedName = candidate;
1412
+ break;
1413
+ }
1414
+ }
1415
+ const libSrc = node_path_1.default.join(srcLibDir, resolvedName ?? libFile);
1416
+ if (resolvedName === undefined) {
1417
+ // FAIL LOUD. Skipping here would ship hook scripts whose top-level
1418
+ // require() throws before their own try/catch, wedging every session —
1419
+ // and the install would still exit 0, so nobody would know until a user
1420
+ // hit it. A missing helper source is a packaging bug; surface it.
1421
+ throw new Error(`hooks/lib/${libFile} is required by a staged ${runtimeLabel} hook but is missing from ${srcLibDir}. `
1422
+ + 'Installing would ship a hook that throws MODULE_NOT_FOUND at load.');
1423
+ }
1424
+ let libContent = node_fs_1.default.readFileSync(libSrc, 'utf8');
1425
+ if (transform)
1426
+ libContent = transform(libContent);
1427
+ // Written under its RESOLVED name so an extensionless require still lands a
1428
+ // file Node can resolve at the destination.
1429
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destLibDir, resolvedName), libContent);
1430
+ staged.push(resolvedName);
1431
+ scanForLibRequires(libContent, true);
1432
+ libFile = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
1433
+ }
1434
+ return staged;
1435
+ }
1314
1436
  function writeCursorHooksJson(targetDir, src, opts) {
1315
1437
  opts = opts || {};
1316
1438
  const hooksDir = node_path_1.default.join(targetDir, 'hooks');
@@ -1348,43 +1470,13 @@ function writeCursorHooksJson(targetDir, src, opts) {
1348
1470
  // "./lib/X" requires, and every lib file staged is itself scanned for further
1349
1471
  // "./lib/X" OR bare "./X" (sibling-within-lib) requires, so the requirement
1350
1472
  // graph is derived to a fixed point instead of one hand-tuned level deep.
1351
- const requiredLibFiles = new Set();
1352
- const scannedLibFiles = new Set();
1353
- const libRequireRe = /require\(\s*['"]\.\/(?:lib\/)?([A-Za-z0-9._-]+)['"]\s*\)/g;
1354
- function scanForLibRequires(source) {
1355
- libRequireRe.lastIndex = 0;
1356
- let m;
1357
- while ((m = libRequireRe.exec(source)) !== null)
1358
- requiredLibFiles.add(m[1]);
1359
- }
1360
- for (const script of installedScripts) {
1361
- scanForLibRequires(node_fs_1.default.readFileSync(node_path_1.default.join(hooksDir, script), 'utf8'));
1362
- }
1363
- if (requiredLibFiles.size > 0) {
1364
- const srcLibDir = node_path_1.default.join(srcHooksDir, 'lib');
1365
- const destLibDir = node_path_1.default.join(hooksDir, 'lib');
1366
- node_fs_1.default.mkdirSync(destLibDir, { recursive: true });
1367
- // Iterate to a fixed point: staging a lib file can add MORE required lib
1368
- // files (its own requires), which must themselves be staged and scanned.
1369
- let libFile = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
1370
- while (libFile !== undefined) {
1371
- scannedLibFiles.add(libFile);
1372
- const libSrc = node_path_1.default.join(srcLibDir, libFile);
1373
- if (!node_fs_1.default.existsSync(libSrc)) {
1374
- // FAIL LOUD. Skipping here would ship hook scripts whose top-level
1375
- // require() throws before their own try/catch, wedging every session —
1376
- // and the install would still exit 0, so nobody would know until a user
1377
- // hit it. A missing helper source is a packaging bug; surface it.
1378
- throw new Error(`hooks/lib/${libFile} is required by a staged Cursor hook but is missing from ${srcLibDir}. `
1379
- + 'Installing would ship a hook that throws MODULE_NOT_FOUND at load.');
1380
- }
1381
- let libContent = node_fs_1.default.readFileSync(libSrc, 'utf8');
1382
- libContent = libContent.replace(/gsd:/gi, 'gsd-');
1383
- node_fs_1.default.writeFileSync(node_path_1.default.join(destLibDir, libFile), libContent);
1384
- scanForLibRequires(libContent);
1385
- libFile = [...requiredLibFiles].find((f) => !scannedLibFiles.has(f));
1386
- }
1387
- }
1473
+ stageTransitiveHookLibs({
1474
+ seedSources: [...installedScripts].map((script) => node_fs_1.default.readFileSync(node_path_1.default.join(hooksDir, script), 'utf8')),
1475
+ srcLibDir: node_path_1.default.join(srcHooksDir, 'lib'),
1476
+ destLibDir: node_path_1.default.join(hooksDir, 'lib'),
1477
+ runtimeLabel: 'Cursor',
1478
+ transform: (content) => content.replace(/gsd:/gi, 'gsd-'),
1479
+ });
1388
1480
  // #2717: write the CommonJS marker into hooks/ alongside the staged .js
1389
1481
  // scripts. Cursor sets skipSharedHooksInstall, so it never reaches
1390
1482
  // installSharedHooksBundle (the only other writer of this marker); without
@@ -1587,6 +1679,26 @@ function writeWindsurfHooksJson(targetDir, src, opts) {
1587
1679
  installedScripts.add(script);
1588
1680
  }
1589
1681
  }
1682
+ // Stage the hooks/lib/ helpers these scripts require (#4087 review). Windsurf
1683
+ // sets hostBehaviors.skipSharedHooksInstall, so like Cursor it never reaches
1684
+ // installSharedHooksBundle — the only other stager of hooks/lib — and it was
1685
+ // staging neither. Both Cascade guards require helpers at module load:
1686
+ // gsd-windsurf-pre-write.js requires ./lib/hook-exit.js and ./lib/git-probe.js,
1687
+ // gsd-windsurf-pre-command.js requires ./lib/hook-exit.js. Measured against a
1688
+ // real `--windsurf --global` install before this call existed: the installer
1689
+ // exited 0, hooks/ held only the two scripts, and running either one exited 1
1690
+ // with "Cannot find module './lib/hook-exit.js'" — the same failure #4087
1691
+ // reports for Codex, on every pre_write_code / pre_run_command event.
1692
+ //
1693
+ // The transform matches the one applied to the scripts above: a helper must be
1694
+ // rewritten the same way as its caller or the two disagree on the spelling.
1695
+ stageTransitiveHookLibs({
1696
+ seedSources: [...installedScripts].map((script) => node_fs_1.default.readFileSync(node_path_1.default.join(hooksDir, script), 'utf8')),
1697
+ srcLibDir: node_path_1.default.join(srcHooksDir, 'lib'),
1698
+ destLibDir: node_path_1.default.join(hooksDir, 'lib'),
1699
+ runtimeLabel: 'Windsurf',
1700
+ transform: (content) => content.replace(/gsd:/gi, 'gsd-'),
1701
+ });
1590
1702
  // #2717: write the CommonJS marker into hooks/ alongside the staged .js
1591
1703
  // scripts. Windsurf sets skipSharedHooksInstall, so it never reaches
1592
1704
  // installSharedHooksBundle (the only other writer of this marker); without
@@ -1737,6 +1849,37 @@ function applySettingsJsonHooks(settings, opts) {
1737
1849
  if (!settings.hooks.SessionStart) {
1738
1850
  settings.hooks.SessionStart = [];
1739
1851
  }
1852
+ // #3981: Claude Code treats a timed-out hook as NON-blocking — the tool
1853
+ // call continues through the normal permission flow. The blocking
1854
+ // PreToolUse guards therefore need a budget a host stall cannot exceed,
1855
+ // not one sized to the hook's own ~0.1 s runtime. Observed stalls reached
1856
+ // 84.3 s; 120 s is the top of the issue's prescribed 60–120 range and
1857
+ // returns every observed verdict. Registration below uses this constant,
1858
+ // and the migration pass right here raises existing managed entries.
1859
+ const BLOCKING_GUARD_TIMEOUT_S = 120;
1860
+ const blockingGuardNames = [
1861
+ 'gsd-prompt-guard',
1862
+ 'gsd-workflow-guard',
1863
+ 'gsd-worktree-path-guard',
1864
+ 'gsd-agent-isolation-guard',
1865
+ 'gsd-write-guard',
1866
+ 'gsd-secret-read-guard',
1867
+ 'gsd-validate-commit',
1868
+ ];
1869
+ for (const entries of Object.values(settings.hooks)) {
1870
+ if (!Array.isArray(entries))
1871
+ continue;
1872
+ for (const entry of entries) {
1873
+ if (!entry || !Array.isArray(entry.hooks))
1874
+ continue;
1875
+ for (const h of entry.hooks) {
1876
+ if (blockingGuardNames.some((name) => referencesHook(h, name)) &&
1877
+ h.timeout === 5) {
1878
+ h.timeout = BLOCKING_GUARD_TIMEOUT_S;
1879
+ }
1880
+ }
1881
+ }
1882
+ }
1740
1883
  const hasGsdUpdateHook = settings.hooks.SessionStart.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-check-update')));
1741
1884
  // Guard: only register if the hook file was actually installed (#1754).
1742
1885
  // When hooks/dist/ is missing from the npm package (as in v1.32.0), the
@@ -1817,7 +1960,7 @@ function applySettingsJsonHooks(settings, opts) {
1817
1960
  {
1818
1961
  type: 'command',
1819
1962
  command: promptGuardCommand,
1820
- timeout: 5
1963
+ timeout: BLOCKING_GUARD_TIMEOUT_S
1821
1964
  }
1822
1965
  ]
1823
1966
  });
@@ -1894,7 +2037,7 @@ function applySettingsJsonHooks(settings, opts) {
1894
2037
  {
1895
2038
  type: 'command',
1896
2039
  command: workflowGuardCommand,
1897
- timeout: 5
2040
+ timeout: BLOCKING_GUARD_TIMEOUT_S
1898
2041
  }
1899
2042
  ]
1900
2043
  });
@@ -1919,7 +2062,7 @@ function applySettingsJsonHooks(settings, opts) {
1919
2062
  {
1920
2063
  type: 'command',
1921
2064
  command: worktreePathGuardCommand,
1922
- timeout: 5
2065
+ timeout: BLOCKING_GUARD_TIMEOUT_S
1923
2066
  }
1924
2067
  ]
1925
2068
  });
@@ -1949,7 +2092,7 @@ function applySettingsJsonHooks(settings, opts) {
1949
2092
  {
1950
2093
  type: 'command',
1951
2094
  command: agentIsolationGuardCommand,
1952
- timeout: 5
2095
+ timeout: BLOCKING_GUARD_TIMEOUT_S
1953
2096
  }
1954
2097
  ]
1955
2098
  });
@@ -1976,7 +2119,7 @@ function applySettingsJsonHooks(settings, opts) {
1976
2119
  {
1977
2120
  type: 'command',
1978
2121
  command: writeGuardCommand,
1979
- timeout: 5
2122
+ timeout: BLOCKING_GUARD_TIMEOUT_S
1980
2123
  }
1981
2124
  ]
1982
2125
  });
@@ -1985,6 +2128,33 @@ function applySettingsJsonHooks(settings, opts) {
1985
2128
  else if (!hasWriteGuardHook && !node_fs_1.default.existsSync(writeGuardFile)) {
1986
2129
  console.warn(` ${yellow}⚠${reset} Skipped write guard hook — gsd-write-guard.js not found at target`);
1987
2130
  }
2131
+ // Configure PreToolUse hook for secret-file read protection (#4221).
2132
+ // Hard-blocks Read/Grep/Bash reads of .env, .env.<suffix> and .secrets.
2133
+ // Replaces the Read(.env*) permission deny rules the installer used to
2134
+ // write (#768): on Claude Code >= 2.1.259 ANY Read() deny rule makes every
2135
+ // `cd DIR && grep …` compound prompt for approval, even in auto mode; a
2136
+ // hook denial is not a permission rule and never arms that check.
2137
+ const secretReadGuardCommand = isGlobal
2138
+ ? buildHookCommand(targetDir, 'gsd-secret-read-guard.js', hookOpts)
2139
+ : localCmd('gsd-secret-read-guard.js');
2140
+ const hasSecretReadGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-secret-read-guard')));
2141
+ const secretReadGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-secret-read-guard.js');
2142
+ if (!hasSecretReadGuardHook && node_fs_1.default.existsSync(secretReadGuardFile) && secretReadGuardCommand) {
2143
+ settings.hooks[preToolEvent].push({
2144
+ matcher: 'Read|Grep|Bash',
2145
+ hooks: [
2146
+ {
2147
+ type: 'command',
2148
+ command: secretReadGuardCommand,
2149
+ timeout: BLOCKING_GUARD_TIMEOUT_S
2150
+ }
2151
+ ]
2152
+ });
2153
+ console.log(` ${green}✓${reset} Configured secret read guard hook (.env / .secrets read protection)`);
2154
+ }
2155
+ else if (!hasSecretReadGuardHook && !node_fs_1.default.existsSync(secretReadGuardFile)) {
2156
+ console.warn(` ${yellow}⚠${reset} Skipped secret read guard hook — gsd-secret-read-guard.js not found at target`);
2157
+ }
1988
2158
  // Configure commit validation hook (Conventional Commits enforcement, opt-in)
1989
2159
  const validateCommitCommand = isGlobal
1990
2160
  ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
@@ -2001,7 +2171,7 @@ function applySettingsJsonHooks(settings, opts) {
2001
2171
  {
2002
2172
  type: 'command',
2003
2173
  command: validateCommitCommand,
2004
- timeout: 5
2174
+ timeout: BLOCKING_GUARD_TIMEOUT_S
2005
2175
  }
2006
2176
  ]
2007
2177
  });
@@ -2330,6 +2500,7 @@ function buildKimiHooksTomlBlock(targetDir, opts) {
2330
2500
  { event: 'PreToolUse', command: cmd('gsd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
2331
2501
  { event: 'PreToolUse', command: cmd('gsd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
2332
2502
  { event: 'PreToolUse', command: cmd('gsd-write-guard.js'), matcher: 'WriteFile', timeout: 5 },
2503
+ { event: 'PreToolUse', command: cmd('gsd-secret-read-guard.js'), matcher: 'ReadFile|Grep|Shell', timeout: 5 },
2333
2504
  { event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 },
2334
2505
  { event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: 5 },
2335
2506
  // PostToolUse
@@ -2532,6 +2703,7 @@ module.exports = {
2532
2703
  KIMI_HOOKS_TOML_MARKER_BEGIN,
2533
2704
  KIMI_HOOKS_TOML_MARKER_END,
2534
2705
  // Shared
2706
+ stageTransitiveHookLibs,
2535
2707
  buildHookCommand,
2536
2708
  applySettingsJsonHooks,
2537
2709
  referencesHook,
@@ -242,6 +242,8 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE = {
242
242
  'gsd-write-guard.js',
243
243
  'gsd-agent-isolation-guard.js',
244
244
  'gsd-worktree-path-guard.js',
245
+ // #4221: secret-file read guard (Read|Grep|Bash).
246
+ 'gsd-secret-read-guard.js',
245
247
  ]),
246
248
  'codex-toml': new Set([
247
249
  'gsd-check-update.js',
@@ -269,6 +271,8 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE = {
269
271
  'gsd-write-guard.js',
270
272
  'gsd-agent-isolation-guard.js',
271
273
  'gsd-worktree-path-guard.js',
274
+ // #4221: secret-file read guard (Read|Grep|Bash).
275
+ 'gsd-secret-read-guard.js',
272
276
  ]),
273
277
  'codex-toml': new Set([
274
278
  'gsd-check-update.js',
@@ -64,7 +64,7 @@ const stateDocument = require("./state-document.cjs");
64
64
  const { stateFieldValue } = stateDocument;
65
65
  // eslint-disable-next-line @typescript-eslint/no-require-imports
66
66
  const phaseId = require("./phase-id.cjs");
67
- const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, stripProjectCodePrefix } = phaseId;
67
+ const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, parsePhaseFromProse, stripProjectCodePrefix } = phaseId;
68
68
  // eslint-disable-next-line @typescript-eslint/no-require-imports
69
69
  const stateMod = require("./state.cjs");
70
70
  const { readStateHeadFreshness } = stateMod;
@@ -233,10 +233,7 @@ function readGitSignals(cwd) {
233
233
  }
234
234
  /** Leading numeric phase token from a STATE.md scalar or body `Phase:` value. */
235
235
  function phaseTokenFromState(raw) {
236
- if (!raw?.trim())
237
- return null;
238
- const match = raw.trim().match(/^(\d+(?:[A-Z])?(?:\.\d+)*)/i);
239
- return match ? match[1] : null;
236
+ return parsePhaseFromProse(raw).phase;
240
237
  }
241
238
  /**
242
239
  * Detect whether the current phase's verify report indicates failure. The
@@ -429,7 +426,7 @@ function detectSignals(cwd, now = Date.now) {
429
426
  const stateHeadRaw = stateFieldValue(fm, body, 'state_head', 'State Head').value;
430
427
  const freshness = readStateHeadFreshness(cwd, stateHeadRaw);
431
428
  return {
432
- current_phase: parseIntOrNull(currentPhaseRaw),
429
+ current_phase: phaseTokenFromState(currentPhaseRaw),
433
430
  total_phases: parseIntOrNull(totalPhasesRaw),
434
431
  status: (statusRaw || '').toLowerCase(),
435
432
  progress: parseIntOrNull(progressRaw),
@@ -472,11 +469,12 @@ function isComplete(s) {
472
469
  return false;
473
470
  }
474
471
  else {
475
- // Legacy path: STATE.md comparison. Still subject to the two-scale bug,
476
- // but only fires when ROADMAP.md is absent or has no Progress table.
472
+ // Legacy path: STATE.md comparison. It only fires when ROADMAP.md is
473
+ // absent or has no Progress table, and uses canonical phase-id ordering
474
+ // so dotted phase ids are never coerced to JavaScript numbers.
477
475
  if (s.total_phases === null || s.current_phase === null)
478
476
  return false;
479
- if (s.current_phase < s.total_phases)
477
+ if (comparePhaseNum(s.current_phase, s.total_phases) < 0)
480
478
  return false;
481
479
  }
482
480
  // Status regex: require milestone-level completion language. The pre-fix
@@ -512,17 +512,42 @@ function stateCurrentPositionSlice(body) {
512
512
  const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isCurrentPosition, { levelBounded: true });
513
513
  return section ? section.body : null;
514
514
  }
515
+ /**
516
+ * Join a matched `**Field:**`/`Field:` label prefix to its new value, inserting a
517
+ * single space when the prefix does not already end in same-line whitespace.
518
+ *
519
+ * On an empty field the same-line `[ \t]*` gap (below) captures nothing, so the
520
+ * bare `prefix + value` would glue the value to the label (`**Status:**value`);
521
+ * this inserts the missing separator. A non-empty field whose label-to-value
522
+ * separator is ordinary space/tab keeps that separator in the prefix, so `[ \t]$`
523
+ * is true and the output stays byte-identical to prior behaviour. The one
524
+ * exception is a non-empty field written with NO separator at all (a hand-edited
525
+ * `**Status:**value`): the narrowed gap captures nothing, `[ \t]$` is false, and a
526
+ * single space is inserted — an intentional normalization, not byte-identical, and
527
+ * with no GSD-template trigger. An empty new value inserts no separator, avoiding a
528
+ * dangling trailing space. See #4010.
529
+ */
530
+ function joinFieldReplacement(prefix, newValue) {
531
+ const value = `${newValue}`;
532
+ const needsSeparator = value.length > 0 && !/[ \t]$/.test(prefix);
533
+ return `${prefix}${needsSeparator ? ' ' : ''}${value}`;
534
+ }
515
535
  function stateReplaceField(content, fieldName, newValue) {
516
536
  const escaped = (0, pattern_cjs_1.escapeRegex)(fieldName);
517
537
  // Bold inline format: **FieldName:** value
518
- const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
538
+ // The label-to-value gap is same-line whitespace only (`[ \t]*`, mirroring the
539
+ // read side at stateExtractField). `\s*` here matched `\n`, so on an empty field
540
+ // `(.*)` captured the following line and the rebuild discarded it — the #4010
541
+ // data-loss. ADR-3180 §7.7 makes stateExtractField the same-line-confined owner;
542
+ // this aligns the writer to it.
543
+ const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*[ \\t]*)(.*)`, 'i');
519
544
  if (boldPattern.test(content)) {
520
- return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
545
+ return content.replace(boldPattern, (_match, prefix) => joinFieldReplacement(prefix, newValue));
521
546
  }
522
- // Plain line-start format: FieldName: value
523
- const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
547
+ // Plain line-start format: FieldName: value (same same-line confinement as above)
548
+ const plainPattern = new RegExp(`(^${escaped}:[ \\t]*)(.*)`, 'im');
524
549
  if (plainPattern.test(content)) {
525
- return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
550
+ return content.replace(plainPattern, (_match, prefix) => joinFieldReplacement(prefix, newValue));
526
551
  }
527
552
  // Pipe-table format: | FieldName | value |
528
553
  // Preserve the surrounding pipe/whitespace structure; only swap the value cell.
@@ -128,19 +128,29 @@ exports.STATE_FIELD_SCHEMA = Object.freeze(Object.assign(Object.create(null), {
128
128
  // (verified: it calls `stateExtractField(bodyContent, 'Current Plan')`
129
129
  // only), so that shape is out of scope for this row regardless.
130
130
  //
131
- // #3784 is the open issue for teaching `Current Plan` to read the
132
- // hybrid shape; **PR #3791** ("fix(#3784): read the hybrid
133
- // `Current Plan: N of M` shape, keep zero-padding, and name the
134
- // accepted shapes on failure") is the in-flight fix. Do NOT widen
135
- // this row speculatively — that would assert a shape the shipped
136
- // parser does not accept, which is the exact defect class §8.8
137
- // exists to make impossible. When #3791 merges, `acceptedShapes`
138
- // MUST widen to `['N', 'N of M']` — until then, the row 23/24/25
139
- // parser-shape tests (`tests/state-transition.test.cjs`) will go RED
140
- // the moment the parser changes underneath it. That failure is the
141
- // forcing function working as designed, not a broken test: it is
142
- // what stops the schema and the parser from drifting apart silently.
143
- acceptedShapes: Object.freeze(['N']),
131
+ // #3784/#3791 WIDENED THIS ROW. The paragraph above describes the
132
+ // pre-#3791 parser and is kept as the record of what the shape was
133
+ // before, because row 25 exists to stop exactly that reading from
134
+ // being re-asserted by accident.
135
+ //
136
+ // `advancePlanCore` now accepts the hybrid shape, so the declared set
137
+ // is `['N', 'N of M']`. Two properties of the widening matter to a
138
+ // future reader:
139
+ //
140
+ // - It is ANCHORED. The parser matches `/^(\d+)\s+of\s+(\d+)\s*$/`
141
+ // against the whole value, so `4 — blocked on review of 2 PRs`
142
+ // is REJECTED rather than yielding a total of 2 out of prose.
143
+ // Declaring `'N of M'` is a claim about that grammar, not about
144
+ // "contains the word of".
145
+ // - `'N/M'` stays UNDECLARED and must keep failing. Row 23 probes
146
+ // the undeclared remainder of `SHAPE_EXAMPLES`, so it needs at
147
+ // least one member outside the declared set to stay non-vacuous.
148
+ //
149
+ // Widening this row without widening the parser (or the reverse) goes
150
+ // RED on rows 23/24/25. That coupling is the forcing function, and it
151
+ // is the reason this row is data rather than a predicate: §8.8's
152
+ // "parsers are checked, not generated".
153
+ acceptedShapes: Object.freeze(['N', 'N of M']),
144
154
  emitted: 'when-present',
145
155
  },
146
156
  // Status / lifecycle (body-derived; #1230 delta heuristic applies)