@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
@@ -35,6 +35,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  const node_path_1 = __importDefault(require("node:path"));
36
36
  const node_fs_1 = __importDefault(require("node:fs"));
37
37
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
+ const security_cjs_1 = require("./security.cjs");
38
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
40
  const io = require("./io.cjs");
40
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -174,8 +175,11 @@ function resolvePhaseDirForArg(cwd, phaseArg) {
174
175
  */
175
176
  function resolveConfinedPath(cwd, relFile) {
176
177
  const root = node_path_1.default.resolve(cwd);
177
- const resolved = node_path_1.default.resolve(root, relFile);
178
- if (resolved !== root && !resolved.startsWith(root + node_path_1.default.sep))
178
+ // ADR-4650 decision 6: lexical family — resolving the symlink here would
179
+ // undo the refuse-don't-resolve posture documented above; `lstatSync` below
180
+ // is the gate that actually refuses a symlink.
181
+ const resolved = (0, security_cjs_1.tryWithinRootLexical)(relFile, root);
182
+ if (resolved === null)
179
183
  return null;
180
184
  try {
181
185
  if (!node_fs_1.default.lstatSync(resolved).isFile())
@@ -321,6 +325,32 @@ function loadWindowsOrDegrade(cwd, windowsOverride) {
321
325
  }
322
326
  return { ok: true, windows, ledger };
323
327
  }
328
+ /**
329
+ * #3780: lazy (module-load cost, see file header) require of the ledger's
330
+ * serialization seam — the same `.planning/.WINDOWS.lock` the windows cmd*
331
+ * writers hold, so this router's own read-compute-write cycles on
332
+ * WINDOWS.md cannot lose updates against them. Required from the REAL
333
+ * compiled module, never through the injectable `windowsOverride` seam:
334
+ * the lock is infrastructure, not a parser stand-in.
335
+ */
336
+ function withLedgerLock(cwd, fn) {
337
+ let lockMod;
338
+ try {
339
+ /* eslint-disable @typescript-eslint/no-require-imports */
340
+ lockMod = require('./broken-windows.cjs');
341
+ /* eslint-enable @typescript-eslint/no-require-imports */
342
+ }
343
+ catch {
344
+ // Ledger module unavailable (the #1953 degrade world — see the row-86
345
+ // notesLedgerUnavailableWithoutBrokenWindows contract): no lock-holding
346
+ // writer can exist either, because every WINDOWS.md writer requires this
347
+ // same module. Run the body unlocked and let loadWindowsOrDegrade
348
+ // produce the canonical degrade note — wrapping that world in lock
349
+ // ceremony would only rewrite the note the degrade contract pins.
350
+ return fn();
351
+ }
352
+ return lockMod.withLedgerLock(cwd, fn);
353
+ }
324
354
  /**
325
355
  * Strict-mode window append (step 9 of `evaluate`). Degrades to
326
356
  * `{ recorded: false, note }` per `loadWindowsOrDegrade` — never an error,
@@ -329,6 +359,23 @@ function loadWindowsOrDegrade(cwd, windowsOverride) {
329
359
  * file/line) and does not append a second one.
330
360
  */
331
361
  function recordStrictWindow(cwd, padded, target, windowsOverride) {
362
+ // #3780: hold the same cross-process ledger lock the windows cmd* writers
363
+ // hold — this site's read-compute-write on WINDOWS.md is otherwise the
364
+ // same lost-update race: a concurrent `gsd_run windows append` (or another
365
+ // evaluator) could silently overwrite this entry, or be overwritten by
366
+ // it. The lock comes from the real ledger module, not the injectable
367
+ // `windowsOverride` seam — it is infrastructure, not a parser stand-in.
368
+ // The wrapper keeps the #1953-defect-2 degrade contract: the lock's typed
369
+ // refusal degrades to `{ recorded: false, note }` like every other
370
+ // failure, it never throws out of this function.
371
+ try {
372
+ return withLedgerLock(cwd, () => recordStrictWindowLocked(cwd, padded, target, windowsOverride));
373
+ }
374
+ catch (e) {
375
+ return { recorded: false, note: `failed to record broken-windows entry: ${e instanceof Error ? e.message : String(e)}` };
376
+ }
377
+ }
378
+ function recordStrictWindowLocked(cwd, padded, target, windowsOverride) {
332
379
  const loaded = loadWindowsOrDegrade(cwd, windowsOverride);
333
380
  if (!loaded.ok)
334
381
  return { recorded: false, note: loaded.note };
@@ -357,6 +404,18 @@ function recordStrictWindow(cwd, padded, target, windowsOverride) {
357
404
  * `accept`/`decline` never tell a user "it failed" without saying why.
358
405
  */
359
406
  function resolveLedgerWindow(cwd, padded, file, line, kind, reasonText, windowsOverride) {
407
+ // #3780: same serialization as recordStrictWindow — a concurrent ledger
408
+ // writer holding a stale snapshot would silently revert this resolve (or
409
+ // lose its own write to this one). Same degrade contract: the lock's
410
+ // typed refusal degrades, never throws.
411
+ try {
412
+ return withLedgerLock(cwd, () => resolveLedgerWindowLocked(cwd, padded, file, line, kind, reasonText, windowsOverride));
413
+ }
414
+ catch (e) {
415
+ return { resolved: false, note: `failed to resolve broken-windows entry: ${e instanceof Error ? e.message : String(e)}` };
416
+ }
417
+ }
418
+ function resolveLedgerWindowLocked(cwd, padded, file, line, kind, reasonText, windowsOverride) {
360
419
  const loaded = loadWindowsOrDegrade(cwd, windowsOverride);
361
420
  if (!loaded.ok)
362
421
  return { resolved: false, note: loaded.note };
@@ -15,6 +15,10 @@ const node_os_1 = __importDefault(require("node:os"));
15
15
  const node_path_1 = __importDefault(require("node:path"));
16
16
  const node_crypto_1 = __importDefault(require("node:crypto"));
17
17
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
18
+ // ADR-4650 decision 6: lexical family — the key is validated before
19
+ // `fs.mkdirSync` creates the store dir, so the candidate legitimately does
20
+ // not exist yet at check time.
21
+ const security_cjs_1 = require("./security.cjs");
18
22
  // ---------------------------------------------------------------------------
19
23
  // Constants
20
24
  // ---------------------------------------------------------------------------
@@ -94,15 +98,12 @@ function putResearch(cwd, key, payload, { clock = Date, homeDir = node_os_1.defa
94
98
  const fetched_at = new Date(clock.now()).toISOString();
95
99
  const entry = { content, source, provider, confidence, fetched_at, ttl, kind };
96
100
  const dir = resolveStorePath(cwd, source, { homeDir });
97
- // Belt-and-suspenders: ensure the resolved file path stays inside the store dir.
98
- const resolvedDir = node_path_1.default.resolve(dir);
99
- const filePath = node_path_1.default.join(dir, `${key}.json`);
100
- const resolvedFile = node_path_1.default.resolve(filePath);
101
- if (!resolvedFile.startsWith(resolvedDir + node_path_1.default.sep)) {
101
+ const containedFile = (0, security_cjs_1.tryWithinRootLexical)(`${key}.json`, dir);
102
+ if (containedFile === null || containedFile === node_path_1.default.resolve(dir)) {
102
103
  throw new Error('invalid research key');
103
104
  }
104
105
  node_fs_1.default.mkdirSync(dir, { recursive: true });
105
- (0, shell_command_projection_cjs_1.platformWriteSync)(filePath, JSON.stringify(entry));
106
+ (0, shell_command_projection_cjs_1.platformWriteSync)(containedFile, JSON.stringify(entry));
106
107
  return entry;
107
108
  }
108
109
  // ---------------------------------------------------------------------------
@@ -120,16 +121,14 @@ function getResearch(cwd, key, { clock = Date, homeDir = node_os_1.default.homed
120
121
  const tierDirs = [userDir, projectDir];
121
122
  const candidates = [];
122
123
  for (const dir of tierDirs) {
123
- const resolvedDir = node_path_1.default.resolve(dir);
124
- const filePath = node_path_1.default.join(dir, `${key}.json`);
125
- // Belt-and-suspenders: ensure path stays inside tier dir
126
- if (!node_path_1.default.resolve(filePath).startsWith(resolvedDir + node_path_1.default.sep))
124
+ const containedFile = (0, security_cjs_1.tryWithinRootLexical)(`${key}.json`, dir);
125
+ if (containedFile === null || containedFile === node_path_1.default.resolve(dir))
127
126
  continue;
128
- if (!node_fs_1.default.existsSync(filePath))
127
+ if (!node_fs_1.default.existsSync(containedFile))
129
128
  continue;
130
129
  let entry;
131
130
  try {
132
- entry = JSON.parse(node_fs_1.default.readFileSync(filePath, 'utf8'));
131
+ entry = JSON.parse(node_fs_1.default.readFileSync(containedFile, 'utf8'));
133
132
  }
134
133
  catch {
135
134
  // Corrupt file in this tier — skip it
@@ -31,6 +31,8 @@ exports.resolveTimeoutMs = resolveTimeoutMs;
31
31
  exports.nativeTimeoutToken = nativeTimeoutToken;
32
32
  exports.isEmptyReview = isEmptyReview;
33
33
  exports.fileRefPrompt = fileRefPrompt;
34
+ exports.artifactPaths = artifactPaths;
35
+ exports.resolveLaneBudget = resolveLaneBudget;
34
36
  exports.resolveLanePlan = resolveLanePlan;
35
37
  const review_lane_descriptor_cjs_1 = require("./review-lane-descriptor.cjs");
36
38
  /* ------------------------------------------------------------------ *
@@ -236,6 +238,27 @@ function artifactPaths(runDir, slug) {
236
238
  errPath: `${base}/gsd-review-${slug}.err`,
237
239
  };
238
240
  }
241
+ /**
242
+ * Per-lane prompt budget (#2797 semantics, preserved exactly).
243
+ *
244
+ * `-1` is the UNSET sentinel and falls back to the central `review.max_prompt_tokens`, because
245
+ * `0` is a legitimate value meaning "do not trim this lane". Treating 0 as unset would silently
246
+ * switch a user who deliberately disabled trimming onto the global budget.
247
+ *
248
+ * Single source of truth: `gsd-core/bin/gsd-tools.cjs`'s `review-lane plan`/`invoke` and
249
+ * `src/reviewer-step-dispatch.cts`'s `dispatchReviewerLanes` both resolve a lane's budget through
250
+ * this function rather than each carrying their own copy (#4209 R3 — two verbatim copies drift).
251
+ */
252
+ function resolveLaneBudget(lane, configGet) {
253
+ if (!lane.promptBudgetKey)
254
+ return null;
255
+ const per = configGet(lane.promptBudgetKey);
256
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
257
+ if (isNum(per) && per !== -1)
258
+ return per;
259
+ const global = configGet('review.max_prompt_tokens');
260
+ return isNum(global) ? global : null;
261
+ }
239
262
  /* ------------------------------------------------------------------ *
240
263
  * Resolution
241
264
  * ------------------------------------------------------------------ */
@@ -0,0 +1,337 @@
1
+ "use strict";
2
+ /**
3
+ * Reviewer Step Dispatch (#4209 Phase 1 Plan 2, ADR-2782 seam).
4
+ *
5
+ * ONE interpreter for "a step declares `supportsReviewerLanes: true`" — see
6
+ * `gsd-core/references/loop-hook-dispatch.md` for the canonical explanation of the trait and how
7
+ * `review-lane dispatch-step` re-derives it. This module trusts `trait` exactly as given: it is
8
+ * the CALLER's job to have derived it correctly. Every direct or lifecycle caller routes through
9
+ * `dispatchReviewerLanes` so selection/plan/invoke logic is owned once, not re-derived per
10
+ * feature. This module owns NONE of those primitives — it wires `resolveReviewerSelection`
11
+ * (selection) and `resolveLanePlan` (planning), the same building blocks
12
+ * `gsd-core/bin/gsd-tools.cjs`'s `review-lane plan` subcommand uses. Invocation (`runLane`) needs
13
+ * OS-aware spawn/probe plumbing this module does not own, so `deps.invoke` is the one required,
14
+ * caller-supplied seam (wired for real in `gsd-core/bin/gsd-tools.cjs`'s `review-lane
15
+ * dispatch-step` route).
16
+ *
17
+ * Fail-closed contract:
18
+ * - Trait not exactly `true`, or nothing selected → inert. Zero plan/invoke calls.
19
+ * - Missing/unsafe request-level input (paths escaping `repoRoot`, absent depth/base SHA) stops
20
+ * the WHOLE dispatch before any lane is planned or invoked.
21
+ * - An explicitly requested lane the selector could not resolve does not silently narrow the
22
+ * result to only what worked: lanes that DID resolve still run and their results are kept,
23
+ * but the aggregate `ok` is `false` so no caller mistakes a partial run for a clean one.
24
+ * - Once a lane is planned, a per-lane plan/budget/invoke failure never displaces or cancels a
25
+ * sibling lane already run.
26
+ * - A lane whose `promptChannel` is `'none'` (it reviews the working tree on its own terms, fed
27
+ * nothing — e.g. `coderabbit`) cannot receive the bounded prompt below and is rejected before
28
+ * plan/invoke, same as an unresolved slug.
29
+ *
30
+ * The bounded source-review prompt built here is METADATA ONLY — repository root, canonical
31
+ * file paths, review depth, base SHA, and four fixed prohibitions. It never embeds file
32
+ * contents. If the assembled prompt exceeds a lane's resolved budget, that lane's dispatch
33
+ * hard-fails before `invoke` runs for it — no silent truncation of the file list.
34
+ */
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.SOURCE_REVIEW_PROHIBITIONS = exports.DISPATCH_REASON = void 0;
40
+ exports.buildSourceReviewPrompt = buildSourceReviewPrompt;
41
+ exports.dispatchReviewerLanes = dispatchReviewerLanes;
42
+ const node_fs_1 = __importDefault(require("node:fs"));
43
+ const node_path_1 = __importDefault(require("node:path"));
44
+ const prompt_budget_cjs_1 = require("./prompt-budget.cjs");
45
+ const security_cjs_1 = require("./security.cjs");
46
+ const review_lane_invocation_cjs_1 = require("./review-lane-invocation.cjs");
47
+ const review_reviewer_selection_cjs_1 = require("./review-reviewer-selection.cjs");
48
+ /** Closed set of request-level (not per-lane) halt reasons. Mirrors `LANE_UNAVAILABLE`'s shape. */
49
+ exports.DISPATCH_REASON = Object.freeze({
50
+ TRAIT_NOT_ENABLED: 'trait_not_enabled',
51
+ NO_LANES_SELECTED: 'no_lanes_selected',
52
+ SELECTION_FAILED: 'selection_failed',
53
+ INVALID_PATHS: 'invalid_paths',
54
+ PATH_ESCAPES_REPO_ROOT: 'path_escapes_repo_root',
55
+ MISSING_PROVENANCE: 'missing_provenance',
56
+ INVALID_PROVENANCE: 'invalid_provenance',
57
+ PROMPT_WRITE_FAILED: 'prompt_write_failed',
58
+ });
59
+ /** Fixed, non-negotiable prompt constraints (SAFE-03..SAFE-06). Order is the display order. */
60
+ exports.SOURCE_REVIEW_PROHIBITIONS = Object.freeze([
61
+ 'Do not modify any source file.',
62
+ 'Do not run tests.',
63
+ 'Do not start background processes.',
64
+ 'Do not poll or wait — return findings from a single read-only pass.',
65
+ ]);
66
+ // #4209 RQ-04: a control character (newline, CR, NUL, ...) in ANY string this module embeds
67
+ // into the external prompt (`buildSourceReviewPrompt`) lets it inject a fabricated section —
68
+ // not just via `paths` (agy-F1's original finding), since `depth`, `baseSha`, and `repoRoot` land
69
+ // in that same markdown. Every embedded string is checked against this ONE shared boundary.
70
+ const CONTROL_CHAR = /[\x00-\x1f\x7f\u2028\u2029]/;
71
+ function defaultWritePromptFile(filePath, content) {
72
+ node_fs_1.default.writeFileSync(filePath, content, 'utf8');
73
+ }
74
+ /**
75
+ * Validate that every path is a non-empty string resolving INSIDE `repoRoot` — blocks `..`
76
+ * traversal and absolute paths pointing elsewhere before any lane sees them.
77
+ */
78
+ function validatePaths(repoRoot, paths) {
79
+ if (!Array.isArray(paths) || paths.length === 0) {
80
+ return { ok: false, reason: exports.DISPATCH_REASON.INVALID_PATHS };
81
+ }
82
+ const root = node_path_1.default.resolve(String(repoRoot ?? ''));
83
+ // repoRoot itself may be a symlink (e.g. a `/tmp`-based worktree on macOS, where `/tmp` is
84
+ // itself a symlink to `/private/tmp`) — realpath it once so the per-path comparison below
85
+ // compares like with like, not a resolved child path against an unresolved root.
86
+ let realRoot;
87
+ try {
88
+ realRoot = node_fs_1.default.realpathSync(root);
89
+ }
90
+ catch {
91
+ realRoot = root;
92
+ }
93
+ // #4209 agy-F1: a control character (newline, CR, NUL, ...) in a path lets a maliciously
94
+ // named repo file inject a fabricated section into the markdown prompt built from `paths`
95
+ // below (buildSourceReviewPrompt) — reject it here, at the shared trust boundary, rather than
96
+ // relying on the incidental quoting `git diff --name-only` happens to apply upstream.
97
+ for (const p of paths) {
98
+ if (typeof p !== 'string' || p.length === 0 || CONTROL_CHAR.test(p)) {
99
+ return { ok: false, reason: exports.DISPATCH_REASON.INVALID_PATHS };
100
+ }
101
+ // ADR-4650 decision 6: lexical family — this is the first of the two
102
+ // deliberate halves (#4209 WR-05); the ENOENT-tolerant realpath half
103
+ // below cannot be folded into a single `tryWithinRoot` call (its
104
+ // ancestor-walk would accept a deleted path via the nearest existing
105
+ // ancestor, not the explicit `continue` this code requires).
106
+ const resolved = node_path_1.default.resolve(root, p);
107
+ if ((0, security_cjs_1.tryWithinRootLexical)(p, root) === null) {
108
+ return { ok: false, reason: exports.DISPATCH_REASON.PATH_ESCAPES_REPO_ROOT };
109
+ }
110
+ // #4209 WR-05: `path.resolve` is lexical only — a symlink whose OWN path sits inside
111
+ // repoRoot can still point outside it, passing the check above while listing an
112
+ // out-of-repo file for the external lane to read. `fs.realpathSync` follows the link;
113
+ // ENOENT is expected and benign here (a `git diff --name-only` path can legitimately name
114
+ // a file already deleted in a stale worktree) and is not itself an escape.
115
+ let real;
116
+ try {
117
+ real = node_fs_1.default.realpathSync(resolved);
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ if ((0, security_cjs_1.tryWithinRootLexical)(real, realRoot) === null) {
123
+ return { ok: false, reason: exports.DISPATCH_REASON.PATH_ESCAPES_REPO_ROOT };
124
+ }
125
+ }
126
+ return { ok: true };
127
+ }
128
+ // `resolveLaneBudget` (review-lane-invocation.cjs) resolves the number; `null` and a resolved
129
+ // `0` both mean unbounded (#2797) — the caller's overflow check must test both `!== null` and
130
+ // `!== 0`. See the call site below.
131
+ /**
132
+ * One-line depth definition for an external reviewer lane, condensed from `<depth_levels>` in
133
+ * `agents/gsd-code-reviewer.md` (#4209 review: a bare `quick`/`standard`/`deep` label means
134
+ * nothing to a third-party CLI that never sees that agent's system prompt — unlike the internal
135
+ * reviewer, whose own persona fully defines these three terms). Every category named here must
136
+ * stay a strict subset of what `<depth_levels>` actually does — `tests/reviewer-step-dispatch
137
+ * .test.cjs`'s "depthMeaning tracks depth_levels" tests assert each case against the real agent
138
+ * file, not just against this function, so the two cannot silently drift again. An unrecognised
139
+ * depth normalizes to `standard`'s text, matching `agents/gsd-code-reviewer.md`'s own "if depth
140
+ * is not one of quick/standard/deep, default to standard" rule — the raw label is not repeated
141
+ * here since `buildSourceReviewPrompt` already states it once, verbatim, earlier in the prompt.
142
+ */
143
+ function depthMeaning(depth) {
144
+ switch (depth) {
145
+ case 'quick':
146
+ return 'pattern-scan without reading full file contents: hardcoded secrets, dangerous functions, debug artifacts, empty catch blocks, commented-out code';
147
+ case 'standard':
148
+ return 'read each changed file in context for bugs, security, and quality problems; cross-reference imports and exports';
149
+ case 'deep':
150
+ return 'standard, plus cross-file analysis: trace call chains, check type consistency at API boundaries, verify error propagation, check state mutation consistency, detect circular dependencies';
151
+ default:
152
+ return depthMeaning('standard');
153
+ }
154
+ }
155
+ /**
156
+ * Build the bounded source-review prompt. Metadata only — repoRoot, paths, depth, base SHA, and
157
+ * the four fixed prohibitions. NEVER embeds file contents.
158
+ */
159
+ function buildSourceReviewPrompt(input) {
160
+ // Base SHA is identical for every file and already stated once above — repeating it per line
161
+ // (as an earlier version of this prompt did) wastes real tokens at O(files), for zero
162
+ // information gain, on every dispatched lane.
163
+ const fileLines = input.paths.map((p) => `- ${p}`).join('\n');
164
+ const ruleLines = exports.SOURCE_REVIEW_PROHIBITIONS.map((r, i) => `${i + 1}. ${r}`).join('\n');
165
+ return [
166
+ '## Source Review Request',
167
+ '',
168
+ `Repository root: ${input.repoRoot}`,
169
+ `Review depth: ${input.depth}`,
170
+ `Base SHA: ${input.baseSha}`,
171
+ '',
172
+ 'Review the changes introduced in each file below relative to the base SHA above, at the',
173
+ `requested depth (${depthMeaning(input.depth)}). Report every bug, security issue, and`,
174
+ 'code-quality problem you find. For every claim you make, cite the exact file path and line',
175
+ 'number(s) it applies to — a claim with no file:line citation cannot be independently',
176
+ 're-verified and will be discarded by the consolidating reviewer. Performance issues',
177
+ '(O(n²), memory leaks) are out of scope unless also correctness issues (e.g. an infinite',
178
+ 'loop) — do not flag them otherwise.',
179
+ '',
180
+ '### Files in scope',
181
+ fileLines,
182
+ '',
183
+ '### Rules',
184
+ ruleLines,
185
+ ].join('\n');
186
+ }
187
+ /**
188
+ * Dispatch every selected reviewer lane for one opted-in step. See module docstring for scope.
189
+ */
190
+ async function dispatchReviewerLanes(input, deps) {
191
+ if (input.trait !== true) {
192
+ return { dispatched: false, ok: true, reason: exports.DISPATCH_REASON.TRAIT_NOT_ENABLED, results: [] };
193
+ }
194
+ const resolveSelection = deps.resolveSelection ?? review_reviewer_selection_cjs_1.resolveReviewerSelection;
195
+ const selection = resolveSelection(input.selection);
196
+ if (selection.selected.length === 0) {
197
+ // Distinguish "explicitly requested but every candidate was unavailable" (a real failure —
198
+ // `errors` is non-empty) from "nothing was ever requested" (a clean, inert no-op).
199
+ const reason = selection.errors.length > 0
200
+ ? exports.DISPATCH_REASON.SELECTION_FAILED
201
+ : exports.DISPATCH_REASON.NO_LANES_SELECTED;
202
+ return { dispatched: false, ok: selection.errors.length === 0, reason, selection, results: [] };
203
+ }
204
+ const pathCheck = validatePaths(input.repoRoot, input.paths);
205
+ if (!pathCheck.ok) {
206
+ return { dispatched: false, ok: false, reason: pathCheck.reason, selection, results: [] };
207
+ }
208
+ // #4209 RQ-04: depth/baseSha/repoRoot/runDir land in the SAME markdown prompt `paths` does
209
+ // (buildSourceReviewPrompt, `dispatchReviewerLanes`'s `runDir`-derived promptPath write) — a
210
+ // control character in any of them is the identical injection vector agy-F1 found in `paths`,
211
+ // so this trust boundary must reject it here too, not just for the file list.
212
+ if (typeof input.depth !== 'string' || input.depth.length === 0
213
+ || typeof input.baseSha !== 'string' || input.baseSha.length === 0
214
+ || typeof input.repoRoot !== 'string' || input.repoRoot.length === 0
215
+ || typeof input.runDir !== 'string' || input.runDir.length === 0) {
216
+ return { dispatched: false, ok: false, reason: exports.DISPATCH_REASON.MISSING_PROVENANCE, selection, results: [] };
217
+ }
218
+ // #4209 WR-04: a present-but-malicious field (control character) is a different failure mode
219
+ // than an absent one — MISSING_PROVENANCE above means "the caller never supplied this"; this
220
+ // branch means "the caller supplied something and it's an injection attempt," which a caller
221
+ // handling the two reasons differently (e.g. surfacing one as a config problem, the other as
222
+ // a security event) must be able to tell apart.
223
+ if (CONTROL_CHAR.test(input.depth) || CONTROL_CHAR.test(input.baseSha)
224
+ || CONTROL_CHAR.test(input.repoRoot) || CONTROL_CHAR.test(input.runDir)) {
225
+ return { dispatched: false, ok: false, reason: exports.DISPATCH_REASON.INVALID_PROVENANCE, selection, results: [] };
226
+ }
227
+ // #4209 WR-03 (considered, declined): gating `depth` to code-review's quick/standard/deep
228
+ // enum here would reject the deliberately capability-neutral case this function supports —
229
+ // see "a second, unrelated synthetic step context dispatches through the same function
230
+ // identically" below, which passes a wholly different depth vocabulary on purpose to prove
231
+ // this dispatcher has no code-review-specific special-casing. `depthMeaning()`'s `standard`
232
+ // fallback for an off-enum value is accepted, not a bug, for that reason.
233
+ const { configGet, getLane, plan } = deps;
234
+ const writePromptFile = deps.writePromptFile ?? defaultWritePromptFile;
235
+ const prompt = buildSourceReviewPrompt(input);
236
+ const estimatedTokens = (0, prompt_budget_cjs_1.estimateTokens)(prompt);
237
+ // Written once, before any lane's plan() runs: `promptPath` is derived from `runDir` alone
238
+ // (see `artifactPaths`), constant across every lane in this dispatch by construction — there
239
+ // is no per-lane variance to defend against, so writing it per-lane (as an earlier version of
240
+ // this function did) was pure redundancy, not a real safeguard.
241
+ // A hoisted, whole-dispatch write (see the doc comment above) that throws must not escape as
242
+ // an uncaught exception — no lane can succeed anyway if the shared prompt file was never
243
+ // written, so this is a dispatch-level halt like `validatePaths`/`MISSING_PROVENANCE` above,
244
+ // not a per-lane failure.
245
+ try {
246
+ writePromptFile((0, review_lane_invocation_cjs_1.artifactPaths)(input.runDir, '').promptPath, prompt);
247
+ }
248
+ catch {
249
+ return { dispatched: false, ok: false, reason: exports.DISPATCH_REASON.PROMPT_WRITE_FAILED, selection, results: [] };
250
+ }
251
+ const results = [];
252
+ // Never narrow the requested set: an explicit reviewer the selector could not resolve is
253
+ // already surfaced in `selection.errors` — reflect that in the aggregate `ok` even though
254
+ // lanes that DID resolve still run below and keep their own results.
255
+ let anyFailed = selection.errors.length > 0;
256
+ // Tracks whether any lane actually reached plan() — `dispatched` must stay false when every
257
+ // selected slug turned out to be unresolvable, even though a `results` entry was still pushed.
258
+ let planned = false;
259
+ for (const slug of selection.selected) {
260
+ const lane = getLane(slug);
261
+ if (!lane) {
262
+ results.push({ slug, ok: false, reason: 'malformed_lane', detail: 'no such declared lane' });
263
+ anyFailed = true;
264
+ continue;
265
+ }
266
+ // #4209 review: a `promptChannel: 'none'` lane (coderabbit) is fed nothing and reviews
267
+ // whatever it independently sees fit (its own working-tree diff, review.md:367) rather than
268
+ // the bounded `paths`/`depth`/`baseSha` scope `buildSourceReviewPrompt` promises — silently
269
+ // dispatching it here would violate this interpreter's own scoped, metadata-only review
270
+ // contract. Reject before plan()/invoke() rather than let the mismatch surface as an
271
+ // unexplained out-of-scope review.
272
+ if (lane.transport === 'spawn' && lane.invoke.promptChannel === 'none') {
273
+ results.push({
274
+ slug,
275
+ ok: false,
276
+ reason: 'prompt_channel_unsupported',
277
+ detail: `lane '${slug}' declares promptChannel 'none' and cannot receive a scoped source-review prompt`,
278
+ });
279
+ anyFailed = true;
280
+ continue;
281
+ }
282
+ // A single throwing plan()/invoke() must not take down every sibling lane already collected
283
+ // in `results` — same rationale as gsd-tools.cjs's resolveLanePlan guard
284
+ // (#2494/#2605/#1698/#1936/#2073/#2176/#2589/#2794): belt and braces on purpose.
285
+ let planOutcome;
286
+ try {
287
+ planOutcome = plan(lane, { configGet, runDir: input.runDir, repoRoot: input.repoRoot });
288
+ }
289
+ catch (e) {
290
+ results.push({ slug, ok: false, reason: 'malformed_lane', detail: e instanceof Error ? e.message : String(e) });
291
+ anyFailed = true;
292
+ continue;
293
+ }
294
+ if (!planOutcome.ok) {
295
+ results.push({ slug, ok: false, reason: planOutcome.reason, detail: planOutcome.detail });
296
+ anyFailed = true;
297
+ continue;
298
+ }
299
+ const budget = (0, review_lane_invocation_cjs_1.resolveLaneBudget)(lane, configGet);
300
+ if (budget !== null && budget !== 0 && estimatedTokens > budget) {
301
+ results.push({
302
+ slug,
303
+ ok: false,
304
+ reason: 'budget_exceeded',
305
+ detail: `estimated ${estimatedTokens} tokens exceeds resolved budget ${budget} for lane '${slug}'`,
306
+ });
307
+ anyFailed = true;
308
+ continue;
309
+ }
310
+ planned = true;
311
+ let invokeOutcome;
312
+ try {
313
+ invokeOutcome = await deps.invoke(lane, planOutcome.plan);
314
+ }
315
+ catch (e) {
316
+ results.push({ slug, ok: false, reason: 'invoke_failed', detail: e instanceof Error ? e.message : String(e) });
317
+ anyFailed = true;
318
+ continue;
319
+ }
320
+ if (!invokeOutcome.ok)
321
+ anyFailed = true;
322
+ results.push({
323
+ slug,
324
+ ok: invokeOutcome.ok,
325
+ reason: invokeOutcome.reason,
326
+ detail: invokeOutcome.detail,
327
+ reviewPath: invokeOutcome.reviewPath,
328
+ errPath: invokeOutcome.errPath,
329
+ });
330
+ }
331
+ return {
332
+ dispatched: planned,
333
+ ok: !anyFailed,
334
+ selection,
335
+ results,
336
+ };
337
+ }
@@ -626,16 +626,17 @@ function hasPhaseEntries(markdown, phaseIdConvention) {
626
626
  return collectTablePhaseRows(markdown).length > 0;
627
627
  }
628
628
  // ─── #3577: markdown-table phase listings ─────────────────────────────────────
629
- // #3577: a GFM table declares phases when its header's FIRST cell is the literal
630
- // `Phase` (optionally `Phase #` / `Phase No.` / `Phase number`) and the header does
631
- // NOT match a known non-listing schema — the canonical RoadmapProgress table
632
- // (`| Phase | Plans Complete | Status | Completed |`) leads with `Phase` too, and
633
- // its rows are progress markers, not declarations. Data rows carry the phase id in
634
- // their first cell (digit-bearing canonical shape — `Phase`-word header cells and
635
- // `---` delimiter rows are digit-free and excluded by construction). Fence-aware
636
- // via stripFencedCode, matching the #3184 lesson: a fenced EXAMPLE of the table
637
- // form is not a declared phase.
629
+ // #3577/#4480: a GFM table declares phases only when its header's FIRST cell is
630
+ // the literal `Phase` (optionally `Phase #` / `Phase No.` / `Phase number`) AND
631
+ // it positively identifies a `Name` or `Phase Name` column. This fails closed:
632
+ // ordinary progress/summary tables such as `| Phase | Status |` cannot mint a
633
+ // phase whose name is whichever value happens to occupy column two. Data rows
634
+ // carry the phase id in their first cell (digit-bearing canonical shape —
635
+ // `Phase`-word header cells and `---` delimiter rows are digit-free and excluded
636
+ // by construction). Fence-aware via stripFencedCode, matching the #3184 lesson:
637
+ // a fenced EXAMPLE of the table form is not a declared phase.
638
638
  const PHASE_LISTING_HEADER_RE = /^\|?\s*phase(?:\s*(?:#|no\.?|number))?\s*\|/i;
639
+ const PHASE_NAME_HEADER_RE = /^(?:phase\s+)?name$/i;
639
640
  const TABLE_PHASE_ID_RE = /^[A-Za-z]?\d[\w.-]*$/;
640
641
  function collectTablePhaseRows(window) {
641
642
  const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
@@ -645,8 +646,9 @@ function collectTablePhaseRows(window) {
645
646
  if (!PHASE_LISTING_HEADER_RE.test(lines[i]))
646
647
  continue;
647
648
  const headerCells = (0, markdown_table_cjs_1.splitTableRow)(lines[i]);
648
- if ((0, markdown_table_cjs_1.matchTableSchema)(headerCells) !== null)
649
- continue; // canonical non-listing schema
649
+ const nameColumn = headerCells.findIndex((cell) => PHASE_NAME_HEADER_RE.test(cell));
650
+ if (nameColumn === -1)
651
+ continue;
650
652
  if (!(0, markdown_table_cjs_1.isDelimiterRow)((0, markdown_table_cjs_1.splitTableRow)(lines[i + 1])))
651
653
  continue;
652
654
  for (let j = i + 2; j < lines.length; j++) {
@@ -662,7 +664,8 @@ function collectTablePhaseRows(window) {
662
664
  if (!TABLE_PHASE_ID_RE.test(first))
663
665
  continue;
664
666
  if (!/^999\b/.test(first)) {
665
- rows.push({ id: first, name: cells[1] && cells[1] !== '' ? cells[1] : null, row: lines[j] });
667
+ const name = cells[nameColumn];
668
+ rows.push({ id: first, name: name && name !== '' ? name : null, row: lines[j] });
666
669
  }
667
670
  }
668
671
  }
@@ -1481,6 +1484,23 @@ function findRoadmapProgressTable(roadmapContent) {
1481
1484
  function stripLeadingDelimiter(s) {
1482
1485
  return s.replace(/^[\s—–:-]+/, '').trim();
1483
1486
  }
1487
+ /**
1488
+ * #4134/#4433 (§7.2 rule 6 floor, applied symmetrically): a captured "name"
1489
+ * with no letter or digit anywhere is heading/bullet STRUCTURE, not a curated
1490
+ * name — e.g. the trailing `)` a name-then-version heading leaves after its
1491
+ * version token, or a malformed 🚧-bullet whose only content past the version
1492
+ * is punctuation (`---`, `***`, a lone `:`). #4134 fixed this for
1493
+ * `extractMilestoneHeadingName`'s heading path only; #4433 found the sibling
1494
+ * 🚧-bullet capture (`getMilestoneInfo`'s `listMatch`) and the no-STATE.md
1495
+ * fallback (`inProgressMatch`) both skipped straight to a bare truthiness
1496
+ * check, so a punctuation-only bullet name passed through as a real one. This
1497
+ * is now the SOLE name-validity predicate — every capture site in this file
1498
+ * calls it instead of re-deriving the character class. A name that merely
1499
+ * CONTAINS punctuation is unaffected; digits alone qualify.
1500
+ */
1501
+ function hasNameableContent(s) {
1502
+ return /[\p{L}\p{N}]/u.test(s);
1503
+ }
1484
1504
  /**
1485
1505
  * #3216 (ADR-3180 §7.2's "Name extraction — pinned rule"): the sole "milestone
1486
1506
  * heading text → version + curated name" rule. Strips everything through the
@@ -1493,6 +1513,13 @@ function stripLeadingDelimiter(s) {
1493
1513
  * one implementation. Returns `null` when `headingText` carries no version
1494
1514
  * token at all (e.g. a non-milestone heading reached this by mistake).
1495
1515
  *
1516
+ * #4134 (§7.2 rule 6 floor): the rule's direction assumes version-then-name.
1517
+ * A name-then-version heading (`# Roadmap: Project — Name (v1.13)`) leaves a
1518
+ * punctuation fragment (`)`) after the token; a remainder with no letter or
1519
+ * digit anywhere is heading structure, not a curated name, and is refused as
1520
+ * `name: null` so callers report the honest rule-6 answer instead of
1521
+ * fabricating garbage. Names that merely CONTAIN punctuation are unaffected.
1522
+ *
1496
1523
  * @param expectedVersion - When the caller already knows the exact version it
1497
1524
  * is looking for (the STATE-anchored `getMilestoneInfo` path, which located
1498
1525
  * this heading via `selectMilestoneHeading(roadmap, stateVersion)`), pass it
@@ -1529,7 +1556,18 @@ function extractMilestoneHeadingName(headingText, expectedVersion) {
1529
1556
  // whitespace — the marker is already carried structurally by `closed`, so
1530
1557
  // duplicating it inside `name` (e.g. "Old ✅") is redundant and wrong. Only
1531
1558
  // these three markers, only at the end; a marker inside a name is untouched.
1532
- const name = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
1559
+ const candidate = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
1560
+ // #4134 (§7.2 rule 6 floor): a "name" with no letter or digit anywhere is
1561
+ // heading structure, not a curated name. The pinned rule takes everything
1562
+ // AFTER the version token, so a name-then-version heading (`# Roadmap:
1563
+ // Project — Name (v1.13)` — the shape a first-ever ROADMAP.md drifts into)
1564
+ // leaves exactly `)` there, which used to be returned as a COMPLETE-scope
1565
+ // name and propagated into init.* output and STATE.md. Refuse it: callers
1566
+ // already report the honest rule-6 answer (version kept, `name: null`,
1567
+ // scope TRUNCATED) for an unresolvable name. A name that merely CONTAINS
1568
+ // punctuation is untouched — `(` is an ordinary name character (#3171) —
1569
+ // and digits alone qualify (`## v4.0 — 42` is the name `42`).
1570
+ const name = candidate !== null && hasNameableContent(candidate) ? candidate : null;
1533
1571
  return { version, name };
1534
1572
  }
1535
1573
  /**
@@ -1614,7 +1652,7 @@ function getMilestoneInfo(cwd) {
1614
1652
  const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\*?\\*?\\s+([^*\\n]+)`, 'i'));
1615
1653
  if (listMatch) {
1616
1654
  const name = stripLeadingDelimiter(listMatch[1]);
1617
- if (name)
1655
+ if (name && hasNameableContent(name))
1618
1656
  return scoped({ version: stateVersion, name }, SCOPE.COMPLETE);
1619
1657
  }
1620
1658
  // #3216: heading selection routes through the shared owner
@@ -1648,7 +1686,10 @@ function getMilestoneInfo(cwd) {
1648
1686
  // (unchanged from the pre-#3216 fallback).
1649
1687
  const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
1650
1688
  if (inProgressMatch) {
1651
- return scoped({ version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() }, SCOPE.COMPLETE);
1689
+ const inProgressName = inProgressMatch[2].trim();
1690
+ if (hasNameableContent(inProgressName)) {
1691
+ return scoped({ version: 'v' + inProgressMatch[1], name: inProgressName }, SCOPE.COMPLETE);
1692
+ }
1652
1693
  }
1653
1694
  // #3216: enumerate every OPEN (non-shipped) milestone heading via the
1654
1695
  // shared owner and take the first in document order — deletes the