@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
@@ -135,6 +135,7 @@ const CONFIG_DEFAULTS = {
135
135
  firecrawl: _getConfigDefault('firecrawl'),
136
136
  exa_search: _getConfigDefault('exa_search'),
137
137
  text_mode: _getNestedConfigDefault('workflow', 'text_mode'),
138
+ compact_content: _getNestedConfigDefault('workflow', 'compact_content'),
138
139
  sub_repos: _getNestedConfigDefault('planning', 'sub_repos'),
139
140
  pr_strict: _getNestedConfigDefault('planning', 'pr_strict'),
140
141
  resolve_model_ids: _getConfigDefault('resolve_model_ids'),
@@ -81,6 +81,11 @@ const SCHEMA_DEFAULTS = {
81
81
  // #1689: per-plan agent_hint executor routing — default-on. A no-op for plans
82
82
  // without an agent_hint field, so existing dispatch is byte-identical.
83
83
  'workflow.agent_hint_routing': true,
84
+ // #4401: Compact Content mode gate — derived from the defaults manifest via
85
+ // CONFIG_DEFAULTS (added in config-loader.cts) so the manifest stays the
86
+ // single source of truth, matching workflow.smart_zone_tokens /
87
+ // planning.pr_strict / workflow.inline_plan_threshold below.
88
+ 'workflow.compact_content': CONFIG_DEFAULTS.compact_content,
84
89
  // Derived from the defaults manifest rather than restated, so the manifest
85
90
  // stays the single source of truth for the smart-zone budget (#2630).
86
91
  'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens,
@@ -93,6 +98,27 @@ const SCHEMA_DEFAULTS = {
93
98
  // effective default existed only as the workflow's shell fallback and the
94
99
  // docs disagreed (settings-advanced said 3). Manifest stays the one owner.
95
100
  'workflow.inline_plan_threshold': CONFIG_DEFAULTS.inline_plan_threshold,
101
+ // #4285 review: an absent threshold resolved to "Key not found" while the
102
+ // hook silently used 35/25 — the query surface disagreeing with the reader.
103
+ //
104
+ // Restated here rather than derived: `CONFIG_DEFAULTS` is re-exported with a
105
+ // FLATTENED shape that drops the manifest's nested blocks, so
106
+ // `CONFIG_DEFAULTS.hooks` is undefined at runtime and the manifest cannot
107
+ // feed these two rows the way `workflow.smart_zone_tokens` above is fed.
108
+ //
109
+ // Not added to `buildNewProjectConfig` either, and that one is deliberate
110
+ // rather than incidental: it writes a `hooks` object into every NEW project's
111
+ // config.json, which would freeze today's fire-points as an explicit
112
+ // per-project override everywhere — the opposite of this PR's premise that an
113
+ // absent key tracks the shipped default. (The manifest alone would NOT have
114
+ // that effect; `buildNewProjectConfig` builds its own literal. Correcting an
115
+ // earlier version of this comment that ran the two together.)
116
+ //
117
+ // That leaves ONE copy of 35/25 outside the hook — these two rows — and
118
+ // `tests/config.test.cjs` pins them against the hook's exported
119
+ // WARNING_THRESHOLD/CRITICAL_THRESHOLD so the copies cannot drift.
120
+ 'hooks.context_warning_threshold': 35,
121
+ 'hooks.context_critical_threshold': 25,
96
122
  };
97
123
  /**
98
124
  * Resolve a schema-level default for an absent key (#2256). Checks the legacy
@@ -308,6 +334,7 @@ function buildNewProjectConfig(userChoices) {
308
334
  human_verify_mode: 'end-of-phase',
309
335
  context_guard_mode: 'warn',
310
336
  text_mode: false,
337
+ compact_content: false,
311
338
  research_before_questions: false,
312
339
  discuss_mode: 'discuss',
313
340
  skip_discuss: false,
@@ -564,19 +591,35 @@ function _unsetNestedValue(config, keyPath) {
564
591
  * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
565
592
  * the happy path. But note that `error()` will still `exit(1)` out of the process.
566
593
  */
594
+ /**
595
+ * Loads `.planning/config.json` as a plain object, or `{}` if the file does
596
+ * not exist. A parse failure calls `error()` (process-exiting) rather than
597
+ * throwing, matching every caller's existing behavior.
598
+ *
599
+ * Single source for this load+parse step — `setConfigValue`,
600
+ * `unsetConfigValue`, `setConfigValues`, `previewConfigValue`, and
601
+ * `previewUnsetConfigValue` all delegate here instead of each repeating the
602
+ * same try/catch (CLAUDE.md's "Generative Fix Divergence" known-defect
603
+ * pattern: independently-guessed copies of the same logic can silently
604
+ * drift apart).
605
+ */
606
+ function loadConfigJson(cwd) {
607
+ const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
608
+ let config = {};
609
+ try {
610
+ if (node_fs_1.default.existsSync(configPath)) {
611
+ config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
612
+ }
613
+ }
614
+ catch (err) {
615
+ error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
616
+ }
617
+ return config;
618
+ }
567
619
  function unsetConfigValue(cwd, keyPath) {
568
620
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
569
621
  return withPlanningLock(cwd, () => {
570
- // Load existing config or start with empty object
571
- let config = {};
572
- try {
573
- if (node_fs_1.default.existsSync(configPath)) {
574
- config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
575
- }
576
- }
577
- catch (err) {
578
- error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
579
- }
622
+ const config = loadConfigJson(cwd);
580
623
  const { previousValue, existed } = _unsetNestedValue(config, keyPath);
581
624
  // Write back
582
625
  try {
@@ -598,16 +641,7 @@ function unsetConfigValue(cwd, keyPath) {
598
641
  function setConfigValue(cwd, keyPath, parsedValue) {
599
642
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
600
643
  return withPlanningLock(cwd, () => {
601
- // Load existing config or start with empty object
602
- let config = {};
603
- try {
604
- if (node_fs_1.default.existsSync(configPath)) {
605
- config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
606
- }
607
- }
608
- catch (err) {
609
- error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
610
- }
644
+ const config = loadConfigJson(cwd);
611
645
  const previousValue = _setNestedValue(config, keyPath, parsedValue);
612
646
  // Write back
613
647
  try {
@@ -619,6 +653,29 @@ function setConfigValue(cwd, keyPath, parsedValue) {
619
653
  }
620
654
  });
621
655
  }
656
+ /**
657
+ * #4444: read-only preview counterpart to `setConfigValue` — loads config
658
+ * exactly like the real setter and reuses `_setNestedValue` (the SAME
659
+ * traversal/creation logic, including its prototype-pollution guards) on a
660
+ * throwaway in-memory copy that is NEVER written back to disk. This is what
661
+ * makes the dry-run preview provably identical to what the real write would
662
+ * compute, rather than a second, hand-maintained traversal that could drift
663
+ * from the real one.
664
+ */
665
+ function previewConfigValue(cwd, keyPath, parsedValue) {
666
+ const config = loadConfigJson(cwd);
667
+ const previousValue = _setNestedValue(config, keyPath, parsedValue);
668
+ return { key: keyPath, value: parsedValue, previousValue };
669
+ }
670
+ /**
671
+ * #4444: read-only preview counterpart to `unsetConfigValue` — same pattern
672
+ * as `previewConfigValue`, reusing `_unsetNestedValue` on a throwaway copy.
673
+ */
674
+ function previewUnsetConfigValue(cwd, keyPath) {
675
+ const config = loadConfigJson(cwd);
676
+ const { previousValue, existed } = _unsetNestedValue(config, keyPath);
677
+ return { key: keyPath, value: null, previousValue, existed };
678
+ }
622
679
  /**
623
680
  * Batched sibling of setConfigValue: apply multiple key-path writes in a
624
681
  * single load → set-all → write cycle inside ONE withPlanningLock call.
@@ -635,16 +692,7 @@ function setConfigValues(cwd, entries) {
635
692
  }
636
693
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
637
694
  return withPlanningLock(cwd, () => {
638
- // Load existing config or start with empty object
639
- let config = {};
640
- try {
641
- if (node_fs_1.default.existsSync(configPath)) {
642
- config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
643
- }
644
- }
645
- catch (err) {
646
- error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
647
- }
695
+ const config = loadConfigJson(cwd);
648
696
  const results = [];
649
697
  for (const entry of entries) {
650
698
  const previousValue = _setNestedValue(config, entry.keyPath, entry.value);
@@ -676,14 +724,8 @@ function assertEnumValue(parsedValue, rawVal, allowed, label) {
676
724
  error(`Invalid ${label} '${rawVal}'. Valid values: ${allowed.join(', ')}`);
677
725
  }
678
726
  }
679
- /**
680
- * Command to set a value in the config file, allowing nested values via dot notation (e.g.,
681
- * "workflow.research").
682
- *
683
- * Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
684
- * directly if you need to avoid this.
685
- */
686
- function cmdConfigSet(cwd, keyPath, value, raw) {
727
+ function cmdConfigSet(cwd, keyPath, value, raw, options = {}) {
728
+ const dryRun = options.dryRun === true;
687
729
  if (!keyPath) {
688
730
  error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
689
731
  }
@@ -733,6 +775,18 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
733
775
  // present, truthy-adjacent value that consumers must special-case — worst for
734
776
  // secret keys where a leftover value can be passed as a real credential.
735
777
  if (parsedValue === null) {
778
+ if (dryRun) {
779
+ const preview = previewUnsetConfigValue(cwd, kp);
780
+ if ((0, secrets_cjs_1.isSecretKey)(kp)) {
781
+ const maskedPrev = preview.previousValue === undefined
782
+ ? undefined
783
+ : (0, secrets_cjs_1.maskSecret)(preview.previousValue);
784
+ output({ dry_run: true, would_unset: true, key: kp, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset (dry run)`);
785
+ return;
786
+ }
787
+ output({ dry_run: true, would_unset: true, key: kp, value: null, previousValue: preview.previousValue }, raw, `${kp} unset (dry run)`);
788
+ return;
789
+ }
736
790
  const unsetResult = unsetConfigValue(cwd, kp);
737
791
  if ((0, secrets_cjs_1.isSecretKey)(kp)) {
738
792
  const maskedPrev = unsetResult.previousValue === undefined
@@ -787,6 +841,12 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
787
841
  error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
788
842
  }
789
843
  }
844
+ // Compact Content mode gate (#4139)
845
+ if (kp === 'workflow.compact_content') {
846
+ if (typeof parsedValue !== 'boolean') {
847
+ error(`Invalid workflow.compact_content '${val}'. Must be a boolean (true or false).`);
848
+ }
849
+ }
790
850
  // Per-plan executor routing via agent_hint frontmatter (#1689)
791
851
  if (kp === 'workflow.agent_hint_routing') {
792
852
  if (typeof parsedValue !== 'boolean') {
@@ -835,6 +895,48 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
835
895
  error(`Invalid statusline.show_git '${val}'. Must be a boolean (true or false).`);
836
896
  }
837
897
  }
898
+ // Context-monitor fire-points (#4285) — a percentage of the context window
899
+ // REMAINING, so the domain is 0-100 and the hook compares them against
900
+ // `remaining_percentage`. Rejecting an out-of-domain value here keeps accept
901
+ // and honour in agreement ON THE DOMAIN: the hook falls back to its default
902
+ // for a value outside it, so reporting success would be a lie. That agreement
903
+ // is per-key and no wider — a value accepted here can still be superseded at
904
+ // read time by the hook's pair check, and a scoped write (GSD_PROJECT /
905
+ // GSD_WORKSTREAM) lands in a config the hook does not read at all. The PAIR
906
+ // (critical < warning) is deliberately NOT enforced here: config-set writes
907
+ // one key per call, so a two-step retune can be transiently inconsistent on
908
+ // disk and a check here would reject that intermediate write.
909
+ if (kp === 'hooks.context_warning_threshold' || kp === 'hooks.context_critical_threshold') {
910
+ if (typeof parsedValue !== 'number' || !Number.isFinite(parsedValue) || parsedValue < 0 || parsedValue > 100) {
911
+ error(`Invalid ${kp} '${val}'. Must be a number between 0 and 100 (percent of context window remaining).`);
912
+ }
913
+ // The two ENDPOINTS that are in range but can never form a valid pair are
914
+ // refused here rather than stored (#4285 review). `critical < warning` must
915
+ // hold at read time and BOTH sides are clamped to 0-100, so `warning: 0`
916
+ // has no legal partner (nothing is below 0) and `critical: 100` has none
917
+ // either (nothing above 100). Either one is silently discarded by the hook
918
+ // for EVERY value of the other key — verified: both resolve to the 35/25
919
+ // defaults against a present, absent, or extreme partner, while 0.001 and
920
+ // 99.999 are honoured.
921
+ //
922
+ // Storing a value the reader can never honour is exactly the
923
+ // accept-then-discard shape this codebase refuses elsewhere, so this fails
924
+ // at write time where the operator can see it. The pair itself is still NOT
925
+ // checked here — config-set writes one key per call, so a two-step retune
926
+ // is legitimately inconsistent on disk in between.
927
+ if (kp === 'hooks.context_warning_threshold' && parsedValue === 0) {
928
+ error(`Invalid ${kp} '${val}'. 0 is in range but unusable: the monitor requires `
929
+ + `hooks.context_critical_threshold < hooks.context_warning_threshold, and no valid `
930
+ + `critical value is below 0, so a warning of 0 would always fall back to the 35/25 `
931
+ + `defaults. Use a value above 0.`);
932
+ }
933
+ if (kp === 'hooks.context_critical_threshold' && parsedValue === 100) {
934
+ error(`Invalid ${kp} '${val}'. 100 is in range but unusable: the monitor requires `
935
+ + `hooks.context_critical_threshold < hooks.context_warning_threshold, and no valid `
936
+ + `warning value is above 100, so a critical of 100 would always fall back to the `
937
+ + `35/25 defaults. Use a value below 100.`);
938
+ }
939
+ }
838
940
  // Fallow scope + profile enum validation (#3424)
839
941
  const VALID_FALLOW_SCOPES = ['phase', 'repo'];
840
942
  if (kp === 'code_quality.fallow.scope')
@@ -921,6 +1023,19 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
921
1023
  }
922
1024
  }
923
1025
  }
1026
+ if (dryRun) {
1027
+ const preview = previewConfigValue(cwd, kp, parsedValue);
1028
+ if ((0, secrets_cjs_1.isSecretKey)(kp)) {
1029
+ const masked = (0, secrets_cjs_1.maskSecret)(parsedValue);
1030
+ const maskedPrev = preview.previousValue === undefined
1031
+ ? undefined
1032
+ : (0, secrets_cjs_1.maskSecret)(preview.previousValue);
1033
+ output({ dry_run: true, would_update: true, key: kp, value: masked, previousValue: maskedPrev, masked: true }, raw, `${kp}=${masked} (dry run)`);
1034
+ return;
1035
+ }
1036
+ output({ dry_run: true, would_update: true, key: kp, value: parsedValue, previousValue: preview.previousValue }, raw, `${kp}=${String(parsedValue)} (dry run)`);
1037
+ return;
1038
+ }
924
1039
  const setConfigValueResult = setConfigValue(cwd, kp, parsedValue);
925
1040
  // Mask secrets in both JSON and text output. The plaintext is written
926
1041
  // to config.json (that's where secrets live on disk); the CLI output
@@ -435,7 +435,7 @@ function cmdClassify(cwd, options = {}, raw) {
435
435
  }
436
436
  let resolvedPath;
437
437
  try {
438
- resolvedPath = (0, security_cjs_1.requireSafePath)(filePath, cwd, 'SUMMARY file', { allowAbsolute: true });
438
+ resolvedPath = (0, security_cjs_1.requireSafePath)(filePath, cwd, 'SUMMARY file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
439
439
  }
440
440
  catch (e) {
441
441
  // Emit a structured command error instead of leaking a raw stack trace.
@@ -5,7 +5,9 @@
5
5
  * truth). Behaviour is preserved byte-for-behaviour from the prior hand-written
6
6
  * .cjs; only types are added.
7
7
  *
8
- * Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
8
+ * Accepts numeric (D-42), alphanumeric (D-INFRA-01), and phase-prefixed
9
+ * (D4-01 — an optional digit-run between the leading letter and the hyphen,
10
+ * #4130) IDs.
9
11
  * Returns {id, text, category, tags, trackable} per decision.
10
12
  * CJS callers that only use {id, text} safely ignore the extra fields.
11
13
  *
@@ -16,6 +18,11 @@
16
18
  * - Outer bullet loop → seam's `iterateBullets` (for the header-fallback path)
17
19
  *
18
20
  * Resolves #1364 (markdown-header + em-dash recall) and #1365 (fail-loud gate).
21
+ *
22
+ * #4130 follow-up (hardening): the three bullet grammars below consume the
23
+ * decision ID atomically and narrow the em-dash first separator, eliminating
24
+ * the quadratic-backtracking cliff on pathological single bullets. Output is
25
+ * byte-identical on all legal inputs — see the notes at DECISION_ID_SOURCE.
19
26
  */
20
27
  Object.defineProperty(exports, "__esModule", { value: true });
21
28
  exports.extractDecisions = extractDecisions;
@@ -30,21 +37,84 @@ const DISCRETION_HEADINGS = new Set([
30
37
  const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
31
38
  // ─── Bullet parsers (decisions-specific grammar) ─────────────────────────────
32
39
  /**
33
- * Colon form: `- **D-NN[ [tags]]:** text`
40
+ * #4130: the ID grammar every extractor regex below shares, as ONE source.
41
+ * `D`, an OPTIONAL digit-run phase prefix, a hyphen, then the pre-existing
42
+ * alphanumeric tail — so `D-01` (bare), `D4-01`/`D12-01` (phase-prefixed,
43
+ * the reporter's multi-phase convention where bare D-01 collides across
44
+ * phases), and `D-INFRA-01` (alnum tail) are all the same grammar now.
45
+ * #2347 had already taught the shape DETECTOR to call `D4-01` decision-shaped
46
+ * while the EXTRACTOR still anchored on the literal `**D-` — the disagreement
47
+ * that made a whole phase-prefixed CONTEXT.md report could-not-parse. Deriving
48
+ * the three grammars (and the token evidence below) from this one constant is
49
+ * the parity pin: the extractor's ID universe cannot drift from the declared
50
+ * grammar again without editing this line, which the #4130 property tests
51
+ * watch from the other side.
52
+ */
53
+ const DECISION_ID_SOURCE = 'D[0-9]*-[A-Za-z0-9][A-Za-z0-9_-]*';
54
+ /**
55
+ * #4130 follow-up (hardening): how the three grammars below CONSUME the ID —
56
+ * atomically, via the `(?=(X))\1` lookahead emulation (lookarounds are atomic
57
+ * in ECMAScript; the backreference must replay exactly what the lookahead
58
+ * captured, so the engine can never give the ID tail back one character at a
59
+ * time). That give-back was quadratic driver #1: the tail class
60
+ * `[A-Za-z0-9_-]*` overlaps the pre-separator class `[^:*]*` (every id char
61
+ * is also `[^:*]`), so on a FAILING bullet the base regex re-split the tail
62
+ * O(n) times with an O(n) scan after each — measured ~1.1s @ 40k chars on
63
+ * `- **D-` + `a-`×20k (the #4357 review's deferred cliff).
64
+ *
65
+ * Byte-identical on all legal inputs: a successful match always consumes the
66
+ * MAXIMAL id run (the lookahead's own match is exactly that maximal run), and
67
+ * the continuation's success depends only on the position of the first
68
+ * `:`/`*` (or `*` for the em-dash form) after the id boundary — id chars
69
+ * contain neither, so moving the boundary inside the run cannot change
70
+ * success or any capture. Group 1 stays the full id (the lookahead's capture
71
+ * IS group 1), so handlers keep reading match[1]/[2]/[3] untouched. Pinned by
72
+ * the differential property test against a frozen copy of the pre-hardening
73
+ * grammars and by the regex-lattice test in tests/decisions.test.cjs.
74
+ */
75
+ /**
76
+ * #4130: the bold lead-in that ATTEMPTS the ID grammar above — used by the
77
+ * parse-miss guard and the #3939 join regexes, where recognising MORE shapes
78
+ * is the conservative direction (an over-broad match can only make a
79
+ * malformed bullet fail loud). The prefix run is either empty (bare `D-`) or
80
+ * DIGIT-INITIAL (`4`, `4x` — a phase prefix with a typo still counts as an
81
+ * attempted ID, so `D4x-01` reaches the guard and fails loud instead of
82
+ * vanishing), but never letter-initial: `D` + letters + `-` (`Deferred-until`)
83
+ * is a prose word, and prose must stay `none-present` (#2347's law).
84
+ */
85
+ const ID_ATTEMPT_SOURCE = 'D(?:[0-9][A-Za-z0-9]*)?-';
86
+ /**
87
+ * Colon form: `- **D[phase]-NN[ [tags]]:** text`
34
88
  * (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`)
89
+ * Group 1 captures the FULL id including any phase prefix (#4130).
90
+ * The ID is consumed atomically `(?=(…))\1` — see the hardening note above
91
+ * the constants (#4130 follow-up); with the tail unable to give back, the
92
+ * remaining `[^:*]*:` scan has a single viable split and the whole match is
93
+ * linear in line length.
35
94
  */
36
- const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
95
+ const bulletColonRe = new RegExp(`^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^:*]*:\\*\\*\\s*(.*)$`);
37
96
  /**
38
- * Em-dash form: `- **D-NN[ [tags]] — title** body`
97
+ * Em-dash form: `- **D[phase]-NN[ [tags]] — title** body`
39
98
  * The em-dash (U+2014) or its lookalike separates the ID+tags group from a title
40
99
  * that lives inside the bold markers; the body (which may be empty) follows
41
100
  * outside the closing `**`. This form was not handled pre-T1 (bug #1364).
42
101
  *
43
102
  * Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
103
+ *
104
+ * #4130 follow-up (hardening), quadratic driver #2: the first separator was
105
+ * `[^*]*[—–]`, whose leading class ALSO accepts the dash — on a failing
106
+ * dash-laden title the engine retried the separator at every dash position
107
+ * with an O(n) scan after each (~1.7s @ 40k). Narrowed to `[^*—–]*[—–]`:
108
+ * the leading class now excludes the dash, so the separator is the FIRST
109
+ * dash — one viable split, single pass. Behavior-preserving because every
110
+ * candidate dash lies before the first `*` (the leading class cannot cross
111
+ * a star), so the trailing `[^*]*` reaches that same first star from any
112
+ * candidate and `**` succeeds or fails identically; no capture involves the
113
+ * dash position. The ID is atomic like the other forms (driver #1).
44
114
  */
45
- const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
115
+ const bulletEmDashRe = new RegExp(`^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^*—–]*[—–][^*]*\\*\\*\\s*(.*)$`);
46
116
  /**
47
- * Titled-colon form: `- **D-NN[ [tags]]: Title.** body`
117
+ * Titled-colon form: `- **D[phase]-NN[ [tags]]: Title.** body`
48
118
  * A title sits between the colon and the closing `**` (so the `:**` anchor of
49
119
  * bulletColonRe fails, and there is no em-dash for bulletEmDashRe). This is a strict
50
120
  * superset of the colon-immediate form, so it MUST be checked AFTER bulletColonRe and
@@ -53,16 +123,34 @@ const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+
53
123
  * (e.g. `D-07 ratio 3:1:**`) still fails the anchor and falls through to the parse-miss
54
124
  * guard — matching bulletColonRe's `[^:*]*` discipline that the separator colon is the
55
125
  * only colon permitted before `**`. (#1639)
126
+ *
127
+ * The ID is consumed atomically `(?=(…))\1` like the other forms — the
128
+ * hardening note above the constants explains why (#4130 follow-up).
129
+ */
130
+ const bulletTitledColonRe = new RegExp(`^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^:*]*:[^:*]*\\*\\*\\s*(.*)$`);
131
+ /**
132
+ * #4130: the parse-miss guard's probe — a line whose bold lead-in ATTEMPTS the
133
+ * ID grammar (see `ID_ATTEMPT_SOURCE`) but failed all three bullet patterns
134
+ * above. Bare `D-` attempts behave exactly as before #4130; a digit-initial
135
+ * prefix run (`D4-`… including a typo'd `D4x-`) is new evidence of an attempt,
136
+ * so the malformed-prefixed bullet fails loud instead of silently vanishing.
56
137
  */
57
- const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:[^:*]*\*\*\s*(.*)$/;
138
+ const parseMissGuardRe = new RegExp(`^\\s*-\\s+\\*\\*${ID_ATTEMPT_SOURCE}`);
139
+ /**
140
+ * #4130: bare-token evidence of decision-shaped content — a `D…-<alnum>` token
141
+ * in running text. `D-01` matched before; the digit-run phase prefix (`D4-01`)
142
+ * is added so token evidence agrees with the extractor's ID grammar
143
+ * (DECISION_ID_SOURCE) instead of silently ignoring prefixed mentions.
144
+ */
145
+ const decisionTokenRe = new RegExp(`\\bD[0-9]*-[A-Za-z0-9]`, 'm');
58
146
  /**
59
147
  * #2347: format-agnostic evidence that a block/section holds real decision
60
148
  * ENTRIES the parser could not read — a bullet whose bold lead-in is an
61
149
  * ID-SHAPED token (uppercase prefix, optional digits, hyphen, alnum), whatever
62
- * the exact ID grammar. The three parser grammars above all require a `D-`
63
- * prefix; #1365's fail-loud guard reused that same `\bD-` test as its "is this
64
- * decision-shaped?" evidence, so any other prefix (e.g. `D5-01`) was invisible
65
- * to BOTH parser and guard, collapsing `could-not-parse` into a clean
150
+ * the exact ID grammar. #1365's fail-loud guard originally reused the parser's
151
+ * own `\bD-` test as its "is this decision-shaped?" evidence, so any prefix the
152
+ * parser could not read (e.g. `D5-01` then, `DEC-01` now) was invisible to
153
+ * BOTH parser and guard, collapsing `could-not-parse` into a clean
66
154
  * `none-present` pass.
67
155
  *
68
156
  * The ID-shape requirement (not "any bold bullet") is deliberate: a decisions
@@ -70,25 +158,35 @@ const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([
70
158
  * bullets with bold labels (`- **Scope:** …`, `- **Why:** …`, `- **Note:** …`).
71
159
  * Those are NOT decision entries and must stay `none-present` — a false
72
160
  * `could-not-parse` hard-blocks the plan gate. `[A-Z]+[0-9]*-[A-Za-z0-9]` matches
73
- * `D-01` / `D5-01` / `DEC-01` but not `Scope:` / `Why:` / `Follow-up:` (mixed
161
+ * `D-01` / `D4-01` / `DEC-01` but not `Scope:` / `Why:` / `Follow-up:` (mixed
74
162
  * case) / `TODO:` (no `-<alnum>` id) — mirroring the parser's own `D-<alnum>`
75
163
  * shape without hardcoding the `D`.
164
+ *
165
+ * #4130 parity note: for the D-prefixed universe this detector's grammar
166
+ * (`D` + digit-run + `-` + alnum) is exactly `DECISION_ID_SOURCE` above, so a
167
+ * well-formed bullet the detector calls decision-shaped is now always one the
168
+ * extractor can read. The detector stays WIDER on purpose (`DEC-01` is still
169
+ * evidence): an ID grammar outside the parser's universe must keep failing
170
+ * loud, never silently passing. The #4130 property tests pin both directions.
76
171
  */
77
172
  const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
78
173
  /**
79
- * #3939: a decision bullet's DECLARATION line — the `- **D-NN … **` bold lead-in
80
- * the three grammars above anchor on — may wrap across a line break. Physical
81
- * line breaks inside a bullet are markdown-insignificant, and GSD's own
174
+ * #3939: a decision bullet's DECLARATION line — the `- **D[phase]-NN … **` bold
175
+ * lead-in the three grammars above anchor on — may wrap across a line break.
176
+ * Physical line breaks inside a bullet are markdown-insignificant, and GSD's own
82
177
  * discuss-phase writer emits the wrapped shape whenever a decision title runs
83
178
  * past the wrap column. All three grammars require the closing `**` in the same
84
- * string as the `- **D-` anchor, so a wrapped declaration matched none of them
179
+ * string as the `- **D…-` anchor, so a wrapped declaration matched none of them
85
180
  * and fell to the #1365 parse-miss guard, forcing `could-not-parse` (which
86
181
  * hard-blocks `check.decision-coverage-plan`) on a well-formed CONTEXT.md.
87
182
  *
88
183
  * The repair is confined to how the LOGICAL bullet is assembled — the grammars
89
184
  * themselves are untouched, so every single-line form parses exactly as before.
185
+ * #4130: the anchor uses `ID_ATTEMPT_SOURCE` (digit-run phase prefixes join
186
+ * like bare ones; recognising more start shapes only reassembles the logical
187
+ * bullet, which then parses or fails loud as itself).
90
188
  */
91
- const decisionBulletStartRe = /^\s*-\s+\*\*D-/;
189
+ const decisionBulletStartRe = new RegExp(`^\\s*-\\s+\\*\\*${ID_ATTEMPT_SOURCE}`);
92
190
  /**
93
191
  * A line that opens a new BLOCK-LEVEL construct, and therefore terminates the
94
192
  * bullet above it: a list marker of any family (`-`, `*`, `+`, `1.`, `1)`), an
@@ -136,7 +234,8 @@ const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/;
136
234
  * to watch for a splice.
137
235
  *
138
236
  * The id character class is deliberately looser than the grammars' (it admits
139
- * an empty id, so a bare `- **D-` still counts as unsettled). This regex only
237
+ * an empty id, so a bare `- **D-` still counts as unsettled, and — #4130 — a
238
+ * digit-run phase prefix between the `D` and the first hyphen). This regex only
140
239
  * answers "may an id-adjacent bracket still open here?", where recognising MORE
141
240
  * shapes is the conservative direction: an over-broad match can only make a
142
241
  * malformed bullet fail loud, while a missed one silently re-classifies.
@@ -145,7 +244,7 @@ const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/;
145
244
  * into `tags` (and therefore into `trackable`). A `[` further along the title is
146
245
  * ordinary text and does not restrict the join.
147
246
  */
148
- const tagRegionRe = /^\s*-\s+\*\*D-[A-Za-z0-9_-]*\s*(?:\[([^\]]*))?$/;
247
+ const tagRegionRe = new RegExp(`^\\s*-\\s+\\*\\*D[0-9]*-[A-Za-z0-9_-]*\\s*(?:\\[([^\\]]*))?$`);
149
248
  /**
150
249
  * #3939 (review): would folding `next` onto a lead-in whose `[tags]` bracket is
151
250
  * still open splice the inserted space INTO a tag token?
@@ -347,11 +446,11 @@ function parseDecisionLines(block) {
347
446
  current.text += ' ' + trimmed;
348
447
  continue;
349
448
  }
350
- // Colon form: `- **D-NN[ [tags]]:** text`
449
+ // Colon form: `- **D[phase]-NN[ [tags]]:** text`
351
450
  const colonMatch = line.match(bulletColonRe);
352
451
  if (colonMatch) {
353
452
  flush();
354
- const id = `D-${colonMatch[1]}`;
453
+ const id = colonMatch[1];
355
454
  const tags = colonMatch[2]
356
455
  ? colonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
357
456
  : [];
@@ -360,11 +459,11 @@ function parseDecisionLines(block) {
360
459
  openIndent = (0, token_scanner_cjs_1.indentWidth)(line);
361
460
  continue;
362
461
  }
363
- // Em-dash form: `- **D-NN[ [tags]] — title** body`
462
+ // Em-dash form: `- **D[phase]-NN[ [tags]] — title** body`
364
463
  const emDashMatch = line.match(bulletEmDashRe);
365
464
  if (emDashMatch) {
366
465
  flush();
367
- const id = `D-${emDashMatch[1]}`;
466
+ const id = emDashMatch[1];
368
467
  const tags = emDashMatch[2]
369
468
  ? emDashMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
370
469
  : [];
@@ -376,14 +475,14 @@ function parseDecisionLines(block) {
376
475
  openIndent = (0, token_scanner_cjs_1.indentWidth)(line);
377
476
  continue;
378
477
  }
379
- // Titled-colon form: `- **D-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is
478
+ // Titled-colon form: `- **D[phase]-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is
380
479
  // a strict superset of bulletColonRe, so it only catches bullets the colon-immediate
381
480
  // and em-dash forms missed (minimal blast radius). id + [tags] trackability honored;
382
481
  // the body after the closing bold run is reported as text.
383
482
  const titledColonMatch = line.match(bulletTitledColonRe);
384
483
  if (titledColonMatch) {
385
484
  flush();
386
- const id = `D-${titledColonMatch[1]}`;
485
+ const id = titledColonMatch[1];
387
486
  const tags = titledColonMatch[2]
388
487
  ? titledColonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
389
488
  : [];
@@ -392,10 +491,14 @@ function parseDecisionLines(block) {
392
491
  openIndent = (0, token_scanner_cjs_1.indentWidth)(line);
393
492
  continue;
394
493
  }
395
- // Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
396
- // bullet but failed both patterns — flush, warn, and record the miss.
494
+ // Parse-miss guard (FIX B + #1343, grammar widened #4130): a line whose bold
495
+ // lead-in ATTEMPTS the ID grammar but failed all three patterns — flush,
496
+ // warn, and record the miss. `ID_ATTEMPT_SOURCE` accepts the bare `D-` form
497
+ // (as before) plus a digit-initial prefix run, so a typo'd phase prefix
498
+ // (`D4x-01`) fails loud instead of silently vanishing, while a letter-initial
499
+ // run (`Deferred-until`) stays prose and stays invisible.
397
500
  // parseMisses > 0 forces could-not-parse even when other decisions parsed.
398
- if (/^\s*-\s+\*\*D-/.test(line)) {
501
+ if (parseMissGuardRe.test(line)) {
399
502
  flush();
400
503
  parseMisses += 1;
401
504
  console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
@@ -449,10 +552,10 @@ function extractDecisions(content) {
449
552
  // FIX A: Block present but 0 extracted and no parse-misses.
450
553
  // Only report could-not-parse when there is genuine evidence of real decisions
451
554
  // that failed to parse: a bold-lead-in bullet (`- **…**`, any ID grammar — #2347),
452
- // a \bD- token in the block text, or an unterminated fence. An empty scaffold
555
+ // a bare `D[phase]-<alnum>` token (#4130) in the block text, or an unterminated fence. An empty scaffold
453
556
  // (<decisions></decisions>) or an all-prose block has no such evidence — treat
454
557
  // as none-present so the gate passes cleanly.
455
- const hasDecisionTokenInBlock = /\bD-[A-Za-z0-9]/m.test(combined);
558
+ const hasDecisionTokenInBlock = decisionTokenRe.test(combined);
456
559
  const hasBoldLeadInBullet = boldLeadInBulletRe.test(combined);
457
560
  if (hasDecisionTokenInBlock || hasBoldLeadInBullet || unterminatedFence) {
458
561
  return { decisions: [], outcome: 'could-not-parse' };
@@ -475,10 +578,10 @@ function extractDecisions(content) {
475
578
  }
476
579
  // FIX A: Heading found but 0 extracted and no parse-misses.
477
580
  // Report could-not-parse when the section body holds a decision-entry-shaped
478
- // bold-lead-in bullet (`- **…**`, any ID grammar — #2347) or a D- token. A
581
+ // bold-lead-in bullet (`- **…**`, any ID grammar — #2347) or a `D[phase]-<alnum>` token (#4130). A
479
582
  // heading with only prose, sub-headings, or all-discretion content (no such
480
583
  // evidence) is a legitimate empty/discretion section → none-present.
481
- const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
584
+ const hasDecisionTokenInSection = decisionTokenRe.test(section.body);
482
585
  const hasBoldLeadInBulletInSection = boldLeadInBulletRe.test(section.body);
483
586
  if (hasDecisionTokenInSection || hasBoldLeadInBulletInSection) {
484
587
  return { decisions: [], outcome: 'could-not-parse' };
@@ -487,8 +590,8 @@ function extractDecisions(content) {
487
590
  }
488
591
  // ── Path 3: no blocks, no heading ────────────────────────────────────────────
489
592
  // Apply shape heuristics to distinguish none-present from could-not-parse.
490
- // We re-use the already-computed unterminatedFence and check for D- tokens.
491
- const hasDecisionToken = /\bD-[A-Za-z0-9]/m.test(stripped);
593
+ // We re-use the already-computed unterminatedFence and check for decision tokens.
594
+ const hasDecisionToken = decisionTokenRe.test(stripped);
492
595
  if (unterminatedFence || hasDecisionToken) {
493
596
  return { decisions: [], outcome: 'could-not-parse' };
494
597
  }