@opengsd/gsd-core 1.7.0-rc.6 → 1.8.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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -27,6 +27,12 @@
27
27
  * list-todos [area] Count and enumerate pending todos
28
28
  * list-seeds [status] List captured seeds (optional status filter)
29
29
  * verify-path-exists <path> Check file/directory existence
30
+ * quick-tasks-append --task <text> Append a row to STATE.md's "Quick Tasks
31
+ * Completed" table (schema-backed via
32
+ * markdown-table.cjs; #2133/ADR-2143).
33
+ * Fails loud (non-zero exit) on a missing
34
+ * or unrecognized table instead of the old
35
+ * awk NF-2 silent-skip guess.
30
36
  * config-ensure-section Initialize .planning/config.json
31
37
  * history-digest Aggregate all SUMMARY.md data
32
38
  * summary-extract <path> [--fields] Extract structured data from SUMMARY.md
@@ -53,6 +59,10 @@
53
59
  * Requirements Operations:
54
60
  * requirements mark-complete <ids> Mark requirement IDs as complete in REQUIREMENTS.md
55
61
  * Accepts: REQ-01,REQ-02 or REQ-01 REQ-02 or [REQ-01, REQ-02]
62
+ * requirements ready-ids <plan-path> <ids> Read-only: which of <ids> are safe to mark-complete now
63
+ * (no sibling *-PLAN.md in the same phase dir still missing its SUMMARY for that ID)
64
+ * requirements revert-phase <ids> Revert requirement IDs out of Complete (checkbox + traceability row);
65
+ * gaps_found-only, never call on the pass path
56
66
  *
57
67
  * Milestone Operations:
58
68
  * milestone complete <version> Archive milestone, create MILESTONES.md
@@ -279,13 +289,13 @@ const { routeInitCommand } = require('./lib/init-command-router.cjs');
279
289
  // here, invoked from case 'init' below.
280
290
  const { warnIfStaleBake } = require('./lib/stale-bake-guard.cjs');
281
291
  const loopResolver = require('./lib/loop-resolver.cjs');
282
- const capabilityState = require('./lib/capability-state.cjs');
283
- const capabilityWriter = require('./lib/capability-writer.cjs');
292
+ const brokenWindows = require('./lib/broken-windows.cjs');
284
293
  const { routePhaseCommand } = require('./lib/phase-command-router.cjs');
285
294
  const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
286
295
  const { routeValidateCommand } = require('./lib/validate-command-router.cjs');
287
296
  const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs');
288
- const { routeAgentCommand } = require('./lib/agent-command-router.cjs');
297
+ const { routeCapabilityCommand } = require('./lib/capability-command-router.cjs');
298
+ const { routeAgentCommand, AGENT_FAILURE_CLASSES } = require('./lib/agent-command-router.cjs');
289
299
  const smartEntryMod = require('./lib/smart-entry.cjs');
290
300
  const { routeCheckCommand } = require('./lib/check-command-router.cjs');
291
301
  const { routeTaskCommand } = require('./lib/task-command-router.cjs');
@@ -556,2606 +566,2021 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
556
566
  return true;
557
567
  }
558
568
 
559
- // ─── Arg parsing helpers ──────────────────────────────────────────────────────
560
-
561
- // ─── CLI Router ───────────────────────────────────────────────────────────────
562
-
563
- async function main() {
564
- let args = process.argv.slice(2);
565
-
566
- // --json-errors / GSD_JSON_ERRORS=1: when active, error() emits structured
567
- // JSON ({ ok: false, reason: <ERROR_REASON code>, message }) to stderr
568
- // instead of "Error: <text>". Lets test suites assert on typed reason codes
569
- // per CONTRIBUTING.md "Prohibited: Raw Text Matching" (#2974).
570
- //
571
- // Detect early — before any flag parsing that can fire error() — so even
572
- // --cwd and workstream-resolution failures emit structured stderr (#3310).
573
- // The argv splice must happen here too, otherwise the dispatcher below sees
574
- // "--json-errors" as an unknown command. Default off — human operators keep
575
- // their plain-text diagnostic.
576
- const jsonErrorsIdx = args.indexOf('--json-errors');
577
- if (jsonErrorsIdx !== -1) {
578
- setJsonErrorMode(true);
579
- args.splice(jsonErrorsIdx, 1);
580
- } else if (process.env.GSD_JSON_ERRORS === '1') {
581
- setJsonErrorMode(true);
569
+ // ─── ADR-2346 (epic #2345): host dispatch table ───────────────────────────────
570
+ // Layer-2 of the two-layer dispatch. Core, non-capability host commands live
571
+ // here — NOT in the capability registry (ADR-959's commandFamilies is reserved
572
+ // for toggleable feature capabilities: graphify/audit/intel). A host command
573
+ // like `state` is core, non-toggleable, carries no tier/activationKey, so it
574
+ // cannot be a capability. Each entry maps a top-level command to its standard
575
+ // `route*Command` router (the same routers the hardcoded `case` arms called).
576
+ // Consulted in runCommand's `default` case, after capability + overlay
577
+ // dispatch, before the unknown-command error. A migrated command's `case` arm
578
+ // is removed at cutover so it reaches here; an unmigrated command still hits
579
+ // its `case` (collision structurally impossible, same property as ADR-959).
580
+
581
+ // ─── ADR-2346 P3: resolve/git/config/research host routers ────────────────
582
+ // Each body was relocated VERBATIM from its `case` arm (cutover: the arm is
583
+ // removed so dispatch reaches HOST_COMMAND_ROUTERS). Closures over module-
584
+ // scope libs (commands/config/output/error/_dispatchNonFamily) are preserved;
585
+ // only per-dispatch values (args/cwd/raw/defaultValue/workstreamContext)
586
+ // arrive via the destructured context.
587
+
588
+ function routeResolveModel({ args, cwd, raw }) {
589
+ commands.cmdResolveModel(cwd, args[1], raw);
582
590
  }
583
591
 
584
- // Optional cwd override for sandboxed subagents running outside project root.
585
- let cwd = process.cwd();
586
- const cwdEqArg = args.find(arg => arg.startsWith('--cwd='));
587
- const cwdIdx = args.indexOf('--cwd');
588
- if (cwdEqArg) {
589
- const value = cwdEqArg.slice('--cwd='.length).trim();
590
- if (!value) error('Missing value for --cwd', ERROR_REASON.USAGE);
591
- args.splice(args.indexOf(cwdEqArg), 1);
592
- cwd = path.resolve(value);
593
- } else if (cwdIdx !== -1) {
594
- const value = args[cwdIdx + 1];
595
- if (!value || value.startsWith('--')) error('Missing value for --cwd', ERROR_REASON.USAGE);
596
- args.splice(cwdIdx, 2);
597
- cwd = path.resolve(value);
592
+ function routeResolveGranularity({ args, cwd, raw }) {
593
+ const granArgs = args.slice(1);
594
+ let granOverride;
595
+ const granPositionals = [];
596
+ for (let i = 0; i < granArgs.length; i++) {
597
+ const a = granArgs[i];
598
+ if (a === '--granularity' && granArgs[i + 1] !== undefined && !granArgs[i + 1].startsWith('--')) {
599
+ if (granOverride === undefined) { granOverride = granArgs[++i]; } else { ++i; }
600
+ } else {
601
+ granPositionals.push(a);
602
+ }
603
+ }
604
+ commands.cmdResolveGranularity(cwd, granPositionals[0], raw, granOverride);
598
605
  }
599
606
 
600
- if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
601
- error(`Invalid --cwd: ${cwd}`, ERROR_REASON.USAGE);
607
+ function routeResolveExecution({ args, cwd, raw }) {
608
+ const execArgs = args.slice(1);
609
+ let effortOverride;
610
+ let fastModeOverride;
611
+ let attempt;
612
+ let failureClass;
613
+ const positionals = [];
614
+ // #2296: the valid classes come from the classifier's own frozen enum, so
615
+ // this validator can never drift from what `agent classify-failure` emits.
616
+ const validFailureClasses = Object.values(AGENT_FAILURE_CLASSES);
617
+ const setFailureClass = (v) => {
618
+ if (!validFailureClasses.includes(v)) {
619
+ error(
620
+ `--failure-class must be one of: ${validFailureClasses.join(', ')}`,
621
+ ERROR_REASON.USAGE,
622
+ );
623
+ }
624
+ failureClass = v;
625
+ };
626
+ for (let i = 0; i < execArgs.length; i++) {
627
+ const a = execArgs[i];
628
+ if (a.startsWith('--effort=')) {
629
+ effortOverride = a.slice('--effort='.length);
630
+ continue;
631
+ }
632
+ if (a.startsWith('--fast-mode=')) {
633
+ const v = a.slice('--fast-mode='.length);
634
+ fastModeOverride = v === 'true' ? true : v === 'false' ? false : undefined;
635
+ continue;
636
+ }
637
+ if (a.startsWith('--attempt=')) {
638
+ const v = a.slice('--attempt='.length);
639
+ const n = parseInt(v, 10);
640
+ if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
641
+ attempt = n;
642
+ continue;
643
+ }
644
+ if (a.startsWith('--failure-class=')) {
645
+ setFailureClass(a.slice('--failure-class='.length));
646
+ continue;
647
+ }
648
+ if (a === '--effort') {
649
+ const val = execArgs[i + 1];
650
+ if (val === undefined || val.startsWith('--')) error('Missing value for --effort', ERROR_REASON.USAGE);
651
+ effortOverride = val;
652
+ i++;
653
+ continue;
654
+ }
655
+ if (a === '--fast-mode') {
656
+ const val = execArgs[i + 1];
657
+ if (val === undefined || val.startsWith('--')) error('Missing value for --fast-mode', ERROR_REASON.USAGE);
658
+ fastModeOverride = val === 'true' ? true : val === 'false' ? false : undefined;
659
+ i++;
660
+ continue;
661
+ }
662
+ if (a === '--attempt') {
663
+ const val = execArgs[i + 1];
664
+ if (val === undefined || val.startsWith('--')) error('Missing value for --attempt', ERROR_REASON.USAGE);
665
+ const n = parseInt(val, 10);
666
+ if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
667
+ attempt = n;
668
+ i++;
669
+ continue;
670
+ }
671
+ if (a === '--failure-class') {
672
+ const val = execArgs[i + 1];
673
+ if (val === undefined || val.startsWith('--')) error('Missing value for --failure-class', ERROR_REASON.USAGE);
674
+ setFailureClass(val);
675
+ i++;
676
+ continue;
677
+ }
678
+ if (a === '--raw') continue;
679
+ if (a.startsWith('-')) error(`Unknown flag for resolve-execution: ${a}`, ERROR_REASON.USAGE);
680
+ positionals.push(a);
681
+ }
682
+ if (positionals.length === 0) error('agent-type required', ERROR_REASON.USAGE);
683
+ if (positionals.length > 1) error(`resolve-execution requires exactly one agent-type argument; got: ${positionals.join(', ')}`, ERROR_REASON.USAGE);
684
+ const agentTypeArg = positionals[0];
685
+ commands.cmdResolveExecution(cwd, agentTypeArg, raw, {
686
+ effortOverride,
687
+ fastModeOverride,
688
+ attempt,
689
+ failureClass,
690
+ });
602
691
  }
603
692
 
604
- // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
605
- // However, in monorepo worktrees where the subdirectory itself owns .planning/,
606
- // skip worktree resolution — the CWD is already the correct project root.
607
- const { resolveWorktreeRoot } = require('./lib/worktree-safety.cjs');
608
- if (!fs.existsSync(path.join(cwd, '.planning'))) {
609
- const worktreeRoot = resolveWorktreeRoot(cwd);
610
- if (worktreeRoot !== cwd) {
611
- cwd = worktreeRoot;
693
+ function routeGit({ args, cwd }) {
694
+ const subcommand = args[1];
695
+ if (subcommand !== 'base-branch') {
696
+ error(
697
+ `Unknown git subcommand: ${subcommand || '(none)'}. Available: base-branch`,
698
+ ERROR_REASON.SDK_UNKNOWN_COMMAND,
699
+ );
700
+ return;
612
701
  }
702
+ cmdGitBaseBranch(cwd, args.slice(2));
613
703
  }
614
704
 
615
- // Optional workstream override for parallel milestone work.
616
- // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
617
- let workstreamContext = null;
618
- try {
619
- workstreamContext = resolveActiveWorkstream(cwd, args, process.env, {
620
- getStored: getActiveWorkstream,
705
+ function routeConfigEnsureSection({ args, cwd, raw }) {
706
+ const handled = _dispatchNonFamily({
707
+ registryCommand: 'config-ensure-section',
708
+ registryArgs: args.slice(1),
709
+ legacyCommand: 'config-ensure-section',
710
+ legacyArgs: args.slice(1),
711
+ cwd,
712
+ raw,
713
+ error,
714
+ output: output,
621
715
  });
622
- args = workstreamContext.args;
623
- // Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths.
624
- applyResolvedWorkstreamEnv(workstreamContext, process.env);
625
- } catch (err) {
626
- error(err.message || String(err));
716
+ if (!handled) config.cmdConfigEnsureSection(cwd, raw);
627
717
  }
628
718
 
629
- const rawIndex = args.indexOf('--raw');
630
- const raw = rawIndex !== -1;
631
- if (rawIndex !== -1) args.splice(rawIndex, 1);
632
-
633
- // --pick <name>: extract a single field from JSON output (replaces jq dependency).
634
- // Supports dot-notation (e.g., --pick workflow.research) and bracket notation
635
- // for arrays (e.g., --pick directories[-1]).
636
- const pickIdx = args.indexOf('--pick');
637
- let pickField = null;
638
- if (pickIdx !== -1) {
639
- pickField = args[pickIdx + 1];
640
- if (!pickField || pickField.startsWith('--')) error('Missing value for --pick', ERROR_REASON.USAGE);
641
- args.splice(pickIdx, 2);
719
+ function routeConfigSet({ args, cwd, raw }) {
720
+ const handled = _dispatchNonFamily({
721
+ registryCommand: 'config-set',
722
+ registryArgs: args.slice(1),
723
+ legacyCommand: 'config-set',
724
+ legacyArgs: args.slice(1),
725
+ cwd,
726
+ raw,
727
+ error,
728
+ output: output,
729
+ });
730
+ if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw);
642
731
  }
643
732
 
644
- // --default <value>: for config-get, return this value instead of erroring
645
- // when the key is absent. Allows workflows to express optional config reads
646
- // without defensive `2>/dev/null || true` boilerplate (#1893).
647
- const defaultIdx = args.indexOf('--default');
648
- let defaultValue = undefined;
649
- if (defaultIdx !== -1) {
650
- defaultValue = args[defaultIdx + 1];
651
- if (defaultValue === undefined) defaultValue = '';
652
- args.splice(defaultIdx, 2);
733
+ function routeConfigSetModelProfile({ args, cwd, raw }) {
734
+ const handled = _dispatchNonFamily({
735
+ registryCommand: 'config-set-model-profile',
736
+ registryArgs: args.slice(1),
737
+ legacyCommand: 'config-set-model-profile',
738
+ legacyArgs: args.slice(1),
739
+ cwd,
740
+ raw,
741
+ error,
742
+ output: output,
743
+ });
744
+ if (!handled) config.cmdConfigSetModelProfile(cwd, args[1], raw);
653
745
  }
654
746
 
655
- let command = args[0];
656
-
657
- // Accept `query` as a meta-prefix for canonical dotted/spaced commands.
658
- // Workflows may call `node gsd-tools.cjs query <command>` directly.
659
- if (command === 'query') {
660
- args.shift();
661
- command = args[0];
747
+ function routeConfigGet({ args, cwd, raw, defaultValue }) {
748
+ const configGetSdkArgs = defaultValue !== undefined
749
+ ? [args[1], '--default', defaultValue]
750
+ : args.slice(1);
751
+ const handled = _dispatchNonFamily({
752
+ registryCommand: 'config-get',
753
+ registryArgs: configGetSdkArgs,
754
+ legacyCommand: 'config-get',
755
+ legacyArgs: args.slice(1),
756
+ cwd,
757
+ raw,
758
+ error,
759
+ output: output,
760
+ });
761
+ if (!handled) config.cmdConfigGet(cwd, args[1], raw, defaultValue);
662
762
  }
663
763
 
664
- // #3243: accept dotted canonical form (e.g. `state.update`) as well as the
665
- // spaced form (`state update`). Some workflow callers pass the dotted
666
- // canonical form directly; this normalization keeps both forms valid.
667
- //
668
- // Split on the FIRST dot only — `check.decision-coverage-plan` becomes
669
- // command='check', args=['check','decision-coverage-plan',...rest].
670
- // Guard: head and rest must both be non-empty (rejects leading-dot args like
671
- // ".hidden" and bare-dot ".").
672
- const originalCommand = command; // preserved for "Unknown command" suggestion
673
- if (typeof command === 'string' && command.includes('.')) {
674
- const dotIdx = command.indexOf('.');
675
- const head = command.slice(0, dotIdx);
676
- const rest = command.slice(dotIdx + 1);
677
- if (head && rest) {
678
- command = head;
679
- args = [head, rest, ...args.slice(1)];
680
- }
764
+ function routeConfigNewProject({ args, cwd, raw }) {
765
+ const handled = _dispatchNonFamily({
766
+ registryCommand: 'config-new-project',
767
+ registryArgs: args.slice(1),
768
+ legacyCommand: 'config-new-project',
769
+ legacyArgs: args.slice(1),
770
+ cwd,
771
+ raw,
772
+ error,
773
+ output: output,
774
+ });
775
+ if (!handled) config.cmdConfigNewProject(cwd, args[1], raw);
681
776
  }
682
777
 
683
- // Top-level usage string — emitted by `gsd-tools` (no args) and by
684
- // `gsd-tools --help` / any `--help` request below.
685
- // CR feedback: the command list must enumerate every top-level command
686
- // supported by the dispatcher so `--help` is actually useful for
687
- // discovery; previously it was a partial subset that didn't include
688
- // phase / roadmap / milestone / progress / etc.
689
- const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
690
- 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
691
- 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
692
- 'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
693
- 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
694
- 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
695
- 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
696
- 'profile-sample, progress, project-instruction-file, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, smart-entry, state, ' +
697
- 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
698
- 'Global flags:\n' +
699
- ' --raw Emit raw output without post-processing\n' +
700
- ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
701
- ' --cwd <path> Override working directory for project-root resolution\n' +
702
- ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
703
- ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
704
- 'For command-specific argument requirements, invoke the command without args ' +
705
- '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
706
-
707
- if (!command) {
708
- error(TOP_LEVEL_USAGE);
778
+ function routeConfigPath({ cwd, raw, workstreamContext }) {
779
+ config.cmdConfigPath(cwd, raw, workstreamContext);
709
780
  }
710
781
 
711
- // #3019: a `--help` / `-h` flag in argv must render the top-level usage
712
- // and exit 0 — not error out with "Unknown flag". The previous shape
713
- // erred on agent-hallucinated flags, but it also blocked humans from
714
- // discovering the command surface via subcommand help requests routed
715
- // through this dispatcher. Rendering top-level usage on --help is strictly
716
- // better UX than the old short-circuit that printed unrelated usage text.
717
- const HELP_FLAGS = new Set(['-h', '--help', '-?', '--h', '--usage']);
718
- if (args.some((a) => HELP_FLAGS.has(a))) {
719
- process.stdout.write(TOP_LEVEL_USAGE + '\n');
720
- return;
782
+ async function routeMigrateConfig({ cwd, raw }) {
783
+ await config.cmdMigrateConfig(cwd, raw);
721
784
  }
722
785
 
723
- // Reject version flags. AI agents sometimes hallucinate --version on tool
724
- // invocations; silently ignoring it can cause destructive operations to
725
- // proceed unchecked. (Help flags are handled above.)
726
- const NEVER_VALID_FLAGS = new Set(['--version', '-v']);
727
- for (const arg of args) {
728
- if (NEVER_VALID_FLAGS.has(arg)) {
729
- error(`Unknown flag: ${arg}\ngsd-tools does not accept version flags. Run "gsd-tools" with no arguments for usage.`, ERROR_REASON.USAGE);
786
+ function routeResearchStore({ args, cwd, raw }) {
787
+ const researchStore = require('./lib/research-store.cjs');
788
+ const subcommand = args[1];
789
+ const homeDir = process.env.HOME || require('os').homedir();
790
+ if (subcommand === 'get') {
791
+ const key = args[2];
792
+ if (!key || key.startsWith('--')) {
793
+ error('Usage: gsd-tools research-store get <key> [--kind <k>]', ERROR_REASON.USAGE);
794
+ }
795
+ if (!researchStore.isValidResearchKey(key)) {
796
+ error('research-store: <key> must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE);
797
+ }
798
+ const result = researchStore.getResearch(cwd, key, { homeDir });
799
+ output(result, raw);
800
+ } else if (subcommand === 'put') {
801
+ const key = args[2];
802
+ if (!key || key.startsWith('--')) {
803
+ error('Usage: gsd-tools research-store put <key> --content <str> --source <s> --provider <p> --confidence <c> --kind <k>', ERROR_REASON.USAGE);
804
+ }
805
+ if (!researchStore.isValidResearchKey(key)) {
806
+ error('research-store: <key> must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE);
807
+ }
808
+ const contentIdx = args.indexOf('--content');
809
+ const sourceIdx = args.indexOf('--source');
810
+ const providerIdx = args.indexOf('--provider');
811
+ const confidenceIdx = args.indexOf('--confidence');
812
+ const kindIdx = args.indexOf('--kind');
813
+ function getFlagValue(idx, flagName) {
814
+ if (idx === -1) return null;
815
+ const val = args[idx + 1];
816
+ if (val === undefined || val.startsWith('--')) {
817
+ error(`research-store put: missing value for ${flagName}`, ERROR_REASON.USAGE);
818
+ }
819
+ return val;
820
+ }
821
+ const content = getFlagValue(contentIdx, '--content');
822
+ const source = getFlagValue(sourceIdx, '--source');
823
+ const provider = getFlagValue(providerIdx, '--provider');
824
+ const confidence = getFlagValue(confidenceIdx, '--confidence');
825
+ const kind = getFlagValue(kindIdx, '--kind');
826
+ if (!content || !source || !provider || !confidence || !kind) {
827
+ error('Usage: gsd-tools research-store put <key> --content <str> --source <s> --provider <p> --confidence <c> --kind <k>', ERROR_REASON.USAGE);
828
+ }
829
+ const entry = researchStore.putResearch(cwd, key, { content, source, provider, confidence, kind }, { homeDir });
830
+ output(entry, raw);
831
+ } else {
832
+ error('Unknown research-store subcommand. Available: get, put', ERROR_REASON.SDK_UNKNOWN_COMMAND);
730
833
  }
731
834
  }
732
835
 
733
- // Multi-repo guard: resolve project root for commands that read/write .planning/.
734
- // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
735
- // filesystem traversal on every invocation.
736
- // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
737
- // Both are registry/config queries that resolve activation via
738
- // .planning/config.json; they need the project root (cwd) for correct
739
- // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
740
- // move the other at the same time (keep them consistent).
741
- const SKIP_ROOT_RESOLUTION = new Set([
742
- 'generate-slug', 'current-timestamp', 'verify-path-exists',
743
- 'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
744
- 'worktree', 'prompt-budget',
745
- 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
746
- 'user-story', // pure string validation — no .planning/ access needed
747
- // #1529: pure runtime→filename projection via getProjectInstructionFile; no
748
- // .planning/ access needed, and resolving project root would break workflow
749
- // invocations that run before .planning/ exists (new-project Step 1).
750
- 'project-instruction-file',
751
- // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
752
- // needs no .planning/ access, so skip the findProjectRoot traversal.
753
- 'eval',
754
- ]);
755
- if (!SKIP_ROOT_RESOLUTION.has(command)) {
756
- cwd = findProjectRoot(cwd);
757
- }
758
-
759
- // When --pick is active, capture stdout and extract the requested field.
760
- if (pickField) {
761
- const captured = await captureStdoutSyncWrites(async () => {
762
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
763
- });
764
- const resolved = resolveAtFileOutput(captured);
836
+ function routeResearchPlan({ args, cwd, raw }) {
837
+ const researchProvider = require('./lib/research-provider.cjs');
838
+ const inputIdx = args.indexOf('--input');
839
+ const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
840
+ if (!inputPath || inputPath.startsWith('--')) {
841
+ error('Usage: gsd-tools research-plan --input <path>', ERROR_REASON.USAGE);
842
+ }
843
+ let planInput;
765
844
  try {
766
- const obj = JSON.parse(resolved);
767
- const value = extractField(obj, pickField);
768
- const result = value === null || value === undefined ? '' : String(value);
769
- fs.writeSync(1, result);
770
- } catch {
771
- fs.writeSync(1, captured);
845
+ const raw_ = fs.readFileSync(path.resolve(inputPath), 'utf8');
846
+ planInput = JSON.parse(raw_);
847
+ } catch (readErr) {
848
+ error(`research-plan: cannot read/parse --input file: ${inputPath}`, ERROR_REASON.USAGE);
772
849
  }
773
- return;
850
+ if (planInput === null || typeof planInput !== 'object' || Array.isArray(planInput)) {
851
+ error('research-plan: --input must be an object with a questions array', ERROR_REASON.USAGE);
852
+ }
853
+ if (!Array.isArray(planInput.questions)) {
854
+ error('research-plan: --input must be an object with a questions array', ERROR_REASON.USAGE);
855
+ }
856
+ const { ecosystem = '', config: planConfig = {}, questions } = planInput;
857
+ const homeDir = process.env.HOME || require('os').homedir();
858
+ const plan = researchProvider.planResearch({ questions, ecosystem, config: planConfig, cwd, homeDir });
859
+ output(plan, raw);
774
860
  }
775
861
 
776
- // Intercept stdout to transparently resolve @file: references (#1891).
777
- // io.cjs output() writes @file:<path> when JSON > 50KB. The --pick path
778
- // already resolves this, but the normal path wrote @file: to stdout, forcing
779
- // every workflow to have a bash-specific `if [[ "$INIT" == @file:* ]]` check
780
- // that breaks on PowerShell and other non-bash shells.
781
- const captured = await captureStdoutSyncWrites(async () => {
782
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
783
- });
784
- fs.writeSync(1, resolveAtFileOutput(captured));
785
- }
786
-
787
- function captureStdoutSyncWrites(run) {
788
- const originalWriteSync = fs.writeSync;
789
- let captured = '';
862
+ // ─── ADR-2346 P4: leaf host routers (all remaining commands) ───────────
863
+ // Each body relocated verbatim from its `case` arm; inner break; → return;.
790
864
 
791
- fs.writeSync = function patchedWriteSync(fd, data, ...rest) {
792
- if (fd === 1) {
793
- if (Buffer.isBuffer(data)) {
794
- captured += data.toString('utf-8');
795
- return data.length;
796
- }
797
- const text = String(data);
798
- captured += text;
799
- let encoding = 'utf-8';
800
- if (typeof rest[1] === 'string') encoding = rest[1];
801
- return Buffer.byteLength(text, encoding);
802
- }
803
- return originalWriteSync.call(fs, fd, data, ...rest);
804
- };
865
+ function routeAgent({ args, cwd, raw, error }) {
866
+ routeAgentCommand({ args, raw });
867
+ }
805
868
 
806
- const restore = () => {
807
- fs.writeSync = originalWriteSync;
808
- };
869
+ function routeSmartEntry({ args, cwd, raw, error }) {
870
+ smartEntryMod.runSmartEntry(cwd, args, raw);
871
+ }
809
872
 
810
- return Promise.resolve()
811
- .then(() => run())
812
- .then(() => {
813
- restore();
814
- return captured;
815
- }, (err) => {
816
- restore();
817
- // The wrapped command may have written to stdout BEFORE it threw — e.g. a --raw
818
- // command that emits a JSON result/error envelope and THEN throws ExitError to set a
819
- // non-zero exit code (capability set/disable on an unknown id). Without this flush that
820
- // captured output is silently discarded (the success-path flush at the call site never
821
- // runs on a throw). Emit it now; the error still propagates so the exit code is preserved.
822
- if (captured) {
823
- try { originalWriteSync.call(fs, 1, resolveAtFileOutput(captured)); } catch { /* best-effort flush */ }
824
- }
825
- throw err;
826
- });
827
- }
873
+ function routeCheck({ args, cwd, raw, error }) {
874
+ routeCheckCommand({ args, cwd, raw });
875
+ }
828
876
 
829
- function resolveAtFileOutput(captured) {
830
- if (!captured.startsWith('@file:')) return captured;
831
- return fs.readFileSync(captured.slice(6), 'utf-8');
832
- }
877
+ function routeFindPhase({ args, cwd, raw, error }) {
878
+ // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
879
+ // SDK handler: findPhase in sdk/src/query/phase.ts.
880
+ const handled = _dispatchNonFamily({
881
+ registryCommand: 'find-phase',
882
+ registryArgs: args.slice(1),
883
+ legacyCommand: 'find-phase',
884
+ legacyArgs: args.slice(1),
885
+ cwd,
886
+ raw,
887
+ error,
888
+ output: output,
889
+ });
890
+ if (!handled) phase.cmdFindPhase(cwd, args[1], raw);
891
+ }
833
892
 
834
- /**
835
- * Extract a field from an object using dot-notation and bracket syntax.
836
- * Supports: 'field', 'parent.child', 'arr[-1]', 'arr[0]'
837
- */
838
- function extractField(obj, fieldPath) {
839
- const parts = fieldPath.split('.');
840
- let current = obj;
841
- for (const part of parts) {
842
- if (current === null || current === undefined) return undefined;
843
- const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/);
844
- if (bracketMatch) {
845
- const key = bracketMatch[1];
846
- const index = parseInt(bracketMatch[2], 10);
847
- current = current[key];
848
- if (!Array.isArray(current)) return undefined;
849
- current = index < 0 ? current[current.length + index] : current[index];
850
- } else {
851
- current = current[part];
852
- }
893
+ function routeCommit({ args, cwd, raw, error }) {
894
+ const amend = args.includes('--amend');
895
+ const noVerify = args.includes('--no-verify');
896
+ const filesIndex = args.indexOf('--files');
897
+ // Collect all positional args between command name and first flag,
898
+ // then join them — handles both quoted ("multi word msg") and
899
+ // unquoted (multi word msg) invocations from different shells
900
+ const endIndex = filesIndex !== -1 ? filesIndex : args.length;
901
+ const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--'));
902
+ const message = messageArgs.join(' ') || undefined;
903
+ const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
904
+ commands.cmdCommit(cwd, message, files, raw, amend, noVerify);
853
905
  }
854
- return current;
855
- }
856
906
 
857
- async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
858
- switch (command) {
859
- case 'agent': {
860
- routeAgentCommand({ args, raw });
861
- break;
862
- }
907
+ function routeCheckCommit({ args, cwd, raw, error }) {
908
+ commands.cmdCheckCommit(cwd, raw);
909
+ }
863
910
 
864
- case 'smart-entry': {
865
- smartEntryMod.runSmartEntry(cwd, args, raw);
866
- break;
867
- }
911
+ function routeCommitToSubrepo({ args, cwd, raw, error }) {
912
+ const message = args[1];
913
+ const filesIndex = args.indexOf('--files');
914
+ const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
915
+ commands.cmdCommitToSubrepo(cwd, message, files, raw);
916
+ }
868
917
 
869
- case 'check': {
870
- routeCheckCommand({ args, cwd, raw });
871
- break;
872
- }
918
+ function routePrSubrepo({ args, cwd, raw, error }) {
919
+ const message = args[1];
920
+ const { repo, branch } = parseNamedArgs(args, ['repo', 'branch']);
921
+ commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
922
+ }
873
923
 
874
- case 'state': {
875
- routeStateCommand({
876
- state,
877
- args,
878
- cwd,
879
- raw,
880
- error,
881
- });
882
- break;
883
- }
924
+ function routeVerifySummary({ args, cwd, raw, error }) {
925
+ const summaryPath = args[1];
926
+ const countIndex = args.indexOf('--check-count');
927
+ const checkCount = countIndex !== -1 ? parseInt(args[countIndex + 1], 10) : 2;
928
+ verify.cmdVerifySummary(cwd, summaryPath, checkCount, raw);
929
+ }
884
930
 
885
- case 'resolve-model': {
886
- commands.cmdResolveModel(cwd, args[1], raw);
887
- break;
888
- }
931
+ function routeTemplate({ args, cwd, raw, error }) {
932
+ const subcommand = args[1];
933
+ if (subcommand === 'select') {
934
+ template.cmdTemplateSelect(cwd, args[2], raw);
935
+ } else if (subcommand === 'fill') {
936
+ const templateType = args[2];
937
+ const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']);
938
+ let fields = {};
939
+ if (fieldsRaw) {
940
+ const { safeJsonParse } = require('./lib/security.cjs');
941
+ const result = safeJsonParse(fieldsRaw, { label: '--fields' });
942
+ if (!result.ok) error(result.error);
943
+ fields = result.value;
944
+ }
945
+ template.cmdTemplateFill(cwd, templateType, {
946
+ phase, plan, name, fields,
947
+ type: type || 'execute',
948
+ wave: wave || '1',
949
+ }, raw);
950
+ } else {
951
+ error('Unknown template subcommand. Available: select, fill', ERROR_REASON.SDK_UNKNOWN_COMMAND);
952
+ }
953
+ }
889
954
 
890
- case 'resolve-granularity': {
891
- // Parse optional --granularity <val> flag (space form only); positional is phase-type.
892
- // The =form (--granularity=<val>) is intentionally not supported: parseNamedArgs and
893
- // the /gsd:plan-phase + init plan-phase paths accept only the space form, so supporting
894
- // = here alone would create an inconsistency (#703).
895
- const granArgs = args.slice(1);
896
- let granOverride;
897
- const granPositionals = [];
898
- for (let i = 0; i < granArgs.length; i++) {
899
- const a = granArgs[i];
900
- if (a === '--granularity' && granArgs[i + 1] !== undefined && !granArgs[i + 1].startsWith('--')) {
901
- if (granOverride === undefined) { granOverride = granArgs[++i]; } else { ++i; }
902
- } else {
903
- granPositionals.push(a);
904
- }
905
- }
906
- commands.cmdResolveGranularity(cwd, granPositionals[0], raw, granOverride);
907
- break;
908
- }
955
+ function routeTask({ args, cwd, raw, error }) {
956
+ routeTaskCommand({ args, cwd, raw });
957
+ }
909
958
 
910
- case 'resolve-execution': {
911
- // Deterministic flag parsing: consume --flag <value> pairs first,
912
- // then the AGENT is the single remaining positional.
913
- // Supports both orderings: <agent> --flag val AND --flag val <agent>.
914
- // Also supports --flag=value form (same convention as --cwd= above).
915
- const execArgs = args.slice(1);
916
- let effortOverride;
917
- let fastModeOverride;
918
- let attempt;
919
- const positionals = [];
920
- for (let i = 0; i < execArgs.length; i++) {
921
- const a = execArgs[i];
922
- // --effort=<val> form
923
- if (a.startsWith('--effort=')) {
924
- effortOverride = a.slice('--effort='.length);
925
- continue;
926
- }
927
- // --fast-mode=<val> form
928
- if (a.startsWith('--fast-mode=')) {
929
- const v = a.slice('--fast-mode='.length);
930
- fastModeOverride = v === 'true' ? true : v === 'false' ? false : undefined;
931
- continue;
932
- }
933
- // --attempt=<val> form
934
- if (a.startsWith('--attempt=')) {
935
- const v = a.slice('--attempt='.length);
936
- const n = parseInt(v, 10);
937
- if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
938
- attempt = n;
939
- continue;
940
- }
941
- // --effort <val>
942
- if (a === '--effort') {
943
- const val = execArgs[i + 1];
944
- if (val === undefined || val.startsWith('--')) error('Missing value for --effort', ERROR_REASON.USAGE);
945
- effortOverride = val;
946
- i++;
947
- continue;
948
- }
949
- // --fast-mode <val>
950
- if (a === '--fast-mode') {
951
- const val = execArgs[i + 1];
952
- if (val === undefined || val.startsWith('--')) error('Missing value for --fast-mode', ERROR_REASON.USAGE);
953
- fastModeOverride = val === 'true' ? true : val === 'false' ? false : undefined;
954
- i++;
955
- continue;
956
- }
957
- // --attempt <val>
958
- if (a === '--attempt') {
959
- const val = execArgs[i + 1];
960
- if (val === undefined || val.startsWith('--')) error('Missing value for --attempt', ERROR_REASON.USAGE);
961
- const n = parseInt(val, 10);
962
- if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
963
- attempt = n;
964
- i++;
965
- continue;
966
- }
967
- // --raw is handled by top-level arg processing; skip it here
968
- if (a === '--raw') continue;
969
- // Unknown flag
970
- if (a.startsWith('-')) error(`Unknown flag for resolve-execution: ${a}`, ERROR_REASON.USAGE);
971
- // Positional
972
- positionals.push(a);
973
- }
974
- if (positionals.length === 0) error('agent-type required', ERROR_REASON.USAGE);
975
- if (positionals.length > 1) error(`resolve-execution requires exactly one agent-type argument; got: ${positionals.join(', ')}`, ERROR_REASON.USAGE);
976
- const agentTypeArg = positionals[0];
977
- commands.cmdResolveExecution(cwd, agentTypeArg, raw, {
978
- effortOverride,
979
- fastModeOverride,
980
- attempt,
981
- });
982
- break;
983
- }
959
+ function routeFrontmatter({ args, cwd, raw, error }) {
960
+ // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
961
+ // SDK handler: sdk/src/query/frontmatter.ts + frontmatter-mutation.ts.
962
+ // CJS fallback: frontmatter.cjs (cooperating sibling).
963
+ const subcommand = args[1];
964
+ const file = args[2];
965
+ const FRONTMATTER_SDK_MAP = {
966
+ get: 'frontmatter.get',
967
+ set: 'frontmatter.set',
968
+ merge: 'frontmatter.merge',
969
+ validate: 'frontmatter.validate',
970
+ };
971
+ if (subcommand in FRONTMATTER_SDK_MAP) {
972
+ const handled = _dispatchNonFamily({
973
+ registryCommand: FRONTMATTER_SDK_MAP[subcommand],
974
+ registryArgs: args.slice(2),
975
+ legacyCommand: 'frontmatter',
976
+ legacyArgs: args.slice(1),
977
+ cwd,
978
+ raw,
979
+ error,
980
+ output: output,
981
+ });
982
+ if (handled) return;
983
+ }
984
+ // CJS fallback (SDK unavailable or unknown subcommand)
985
+ if (subcommand === 'get') {
986
+ frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
987
+ } else if (subcommand === 'set') {
988
+ const { field, value } = parseNamedArgs(args, ['field', 'value']);
989
+ frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
990
+ } else if (subcommand === 'merge') {
991
+ frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw);
992
+ } else if (subcommand === 'validate') {
993
+ frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw);
994
+ } else {
995
+ error('Unknown frontmatter subcommand. Available: get, set, merge, validate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
996
+ }
997
+ }
984
998
 
985
- case 'find-phase': {
986
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
987
- // SDK handler: findPhase in sdk/src/query/phase.ts.
988
- const handled = _dispatchNonFamily({
989
- registryCommand: 'find-phase',
990
- registryArgs: args.slice(1),
991
- legacyCommand: 'find-phase',
992
- legacyArgs: args.slice(1),
993
- cwd,
994
- raw,
995
- error,
996
- output: output,
997
- });
998
- if (!handled) phase.cmdFindPhase(cwd, args[1], raw);
999
- break;
1000
- }
999
+ function routeEval({ args, cwd, raw, error }) {
1000
+ routeEvalCommand({ evalMod, args, cwd, raw, error });
1001
+ }
1001
1002
 
1002
- case 'commit': {
1003
- const amend = args.includes('--amend');
1004
- const noVerify = args.includes('--no-verify');
1005
- const filesIndex = args.indexOf('--files');
1006
- // Collect all positional args between command name and first flag,
1007
- // then join them — handles both quoted ("multi word msg") and
1008
- // unquoted (multi word msg) invocations from different shells
1009
- const endIndex = filesIndex !== -1 ? filesIndex : args.length;
1010
- const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--'));
1011
- const message = messageArgs.join(' ') || undefined;
1012
- const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
1013
- commands.cmdCommit(cwd, message, files, raw, amend, noVerify);
1014
- break;
1015
- }
1003
+ function routeVerification({ args, cwd, raw, error }) {
1004
+ routeVerificationCommand({
1005
+ verification,
1006
+ args,
1007
+ cwd,
1008
+ raw,
1009
+ error,
1010
+ });
1011
+ }
1016
1012
 
1017
- case 'check-commit': {
1018
- commands.cmdCheckCommit(cwd, raw);
1019
- break;
1020
- }
1013
+ function routeGenerateSlug({ args, cwd, raw, error }) {
1014
+ // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1015
+ // SDK handler: generateSlug in sdk/src/query/utils.ts.
1016
+ const handled = _dispatchNonFamily({
1017
+ registryCommand: 'generate-slug',
1018
+ registryArgs: args.slice(1),
1019
+ legacyCommand: 'generate-slug',
1020
+ legacyArgs: args.slice(1),
1021
+ cwd,
1022
+ raw,
1023
+ error,
1024
+ output: output,
1025
+ });
1026
+ if (!handled) commands.cmdGenerateSlug(args[1], raw);
1027
+ }
1021
1028
 
1022
- case 'commit-to-subrepo': {
1023
- const message = args[1];
1024
- const filesIndex = args.indexOf('--files');
1025
- const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
1026
- commands.cmdCommitToSubrepo(cwd, message, files, raw);
1027
- break;
1028
- }
1029
+ function routeCurrentTimestamp({ args, cwd, raw, error }) {
1030
+ // Keep this command on the CJS fast path.
1031
+ // Rationale: it is a pure local formatter and avoids SDK bridge startup
1032
+ // in tight subprocess loops where Windows CI has shown intermittent
1033
+ // native crashes (0xC0000005 / 3221225477).
1034
+ commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1035
+ }
1029
1036
 
1030
- case 'pr-subrepo': {
1031
- const message = args[1];
1032
- const { repo, branch } = parseNamedArgs(args, ['repo', 'branch']);
1033
- commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
1034
- break;
1035
- }
1037
+ function routeProjectInstructionFile({ args, cwd, raw, error }) {
1038
+ // #1529: pure runtime→filename projection. Backs the
1039
+ // `gsd_run query project-instruction-file --runtime <r>` call in
1040
+ // new-project.md so the bash workflow and profile-output.cjs share one
1041
+ // source of truth (getProjectInstructionFile in runtime-name-policy.cjs).
1042
+ // No SDK bridge — pure local lookup, runs before .planning/ exists.
1043
+ const { getProjectInstructionFile } = require('./lib/runtime-name-policy.cjs');
1044
+ // Parse --runtime <value> (space or = form); default to empty so the
1045
+ // safe AGENTS.md cross-agent default applies.
1046
+ const pifArgs = args.slice(1);
1047
+ let pifRuntime = '';
1048
+ for (let i = 0; i < pifArgs.length; i++) {
1049
+ const a = pifArgs[i];
1050
+ if (a === '--runtime' && pifArgs[i + 1] !== undefined) { pifRuntime = pifArgs[++i]; continue; }
1051
+ if (a.startsWith('--runtime=')) { pifRuntime = a.slice('--runtime='.length); continue; }
1052
+ // First positional that isn't a flag also works (lenient); otherwise ignore unknown flags.
1053
+ if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; }
1054
+ }
1055
+ const filename = getProjectInstructionFile(pifRuntime);
1056
+ process.stdout.write(filename + '\n');
1057
+ }
1036
1058
 
1037
- case 'verify-summary': {
1038
- const summaryPath = args[1];
1039
- const countIndex = args.indexOf('--check-count');
1040
- const checkCount = countIndex !== -1 ? parseInt(args[countIndex + 1], 10) : 2;
1041
- verify.cmdVerifySummary(cwd, summaryPath, checkCount, raw);
1042
- break;
1043
- }
1059
+ function routeListTodos({ args, cwd, raw, error }) {
1060
+ commands.cmdListTodos(cwd, args[1], raw);
1061
+ }
1044
1062
 
1045
- case 'template': {
1046
- const subcommand = args[1];
1047
- if (subcommand === 'select') {
1048
- template.cmdTemplateSelect(cwd, args[2], raw);
1049
- } else if (subcommand === 'fill') {
1050
- const templateType = args[2];
1051
- const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']);
1052
- let fields = {};
1053
- if (fieldsRaw) {
1054
- const { safeJsonParse } = require('./lib/security.cjs');
1055
- const result = safeJsonParse(fieldsRaw, { label: '--fields' });
1056
- if (!result.ok) error(result.error);
1057
- fields = result.value;
1058
- }
1059
- template.cmdTemplateFill(cwd, templateType, {
1060
- phase, plan, name, fields,
1061
- type: type || 'execute',
1062
- wave: wave || '1',
1063
- }, raw);
1064
- } else {
1065
- error('Unknown template subcommand. Available: select, fill', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1066
- }
1067
- break;
1068
- }
1063
+ function routeListSeeds({ args, cwd, raw, error }) {
1064
+ commands.cmdListSeeds(cwd, args[1], raw);
1065
+ }
1069
1066
 
1070
- case 'task': {
1071
- routeTaskCommand({ args, cwd, raw });
1072
- break;
1073
- }
1067
+ function routeVerifyPathExists({ args, cwd, raw, error }) {
1068
+ commands.cmdVerifyPathExists(cwd, args[1], raw);
1069
+ }
1074
1070
 
1075
- case 'frontmatter': {
1076
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1077
- // SDK handler: sdk/src/query/frontmatter.ts + frontmatter-mutation.ts.
1078
- // CJS fallback: frontmatter.cjs (cooperating sibling).
1079
- const subcommand = args[1];
1080
- const file = args[2];
1081
- const FRONTMATTER_SDK_MAP = {
1082
- get: 'frontmatter.get',
1083
- set: 'frontmatter.set',
1084
- merge: 'frontmatter.merge',
1085
- validate: 'frontmatter.validate',
1086
- };
1087
- if (subcommand in FRONTMATTER_SDK_MAP) {
1088
- const handled = _dispatchNonFamily({
1089
- registryCommand: FRONTMATTER_SDK_MAP[subcommand],
1090
- registryArgs: args.slice(2),
1091
- legacyCommand: 'frontmatter',
1092
- legacyArgs: args.slice(1),
1093
- cwd,
1094
- raw,
1095
- error,
1096
- output: output,
1097
- });
1098
- if (handled) break;
1099
- }
1100
- // CJS fallback (SDK unavailable or unknown subcommand)
1101
- if (subcommand === 'get') {
1102
- frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw);
1103
- } else if (subcommand === 'set') {
1104
- const { field, value } = parseNamedArgs(args, ['field', 'value']);
1105
- frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
1106
- } else if (subcommand === 'merge') {
1107
- frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw);
1108
- } else if (subcommand === 'validate') {
1109
- frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw);
1110
- } else {
1111
- error('Unknown frontmatter subcommand. Available: get, set, merge, validate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1112
- }
1113
- break;
1114
- }
1071
+ function routeQuickTasksAppend({ args, cwd, raw, error }) {
1072
+ // #2133 / ADR-2143 §3,§7: schema-backed replacement for fast.md's inline
1073
+ // `awk NF-2` Quick Tasks column arithmetic. Row construction is delegated
1074
+ // to the pure appendQuickTaskRow (markdown-table.cjs); this case only
1075
+ // handles the I/O (read STATE.md, resolve date/commit, write STATE.md).
1076
+ const qtaArgs = args.slice(1);
1077
+ const qtaTask = parseNamedArgs(qtaArgs, ['task']).task || args[1];
1078
+ if (!qtaTask) {
1079
+ error('quick-tasks-append requires --task <description> (or a positional description)', ERROR_REASON.USAGE);
1080
+ }
1115
1081
 
1116
- case 'verify': {
1117
- routeVerifyCommand({
1118
- verify,
1119
- args,
1120
- cwd,
1121
- raw,
1122
- error,
1123
- });
1124
- break;
1125
- }
1082
+ const statePath = path.join(cwd, '.planning', 'STATE.md');
1083
+ if (!fs.existsSync(statePath)) {
1084
+ error(`quick-tasks-append: STATE.md not found at ${statePath}`, ERROR_REASON.USAGE);
1085
+ }
1126
1086
 
1127
- case 'eval': {
1128
- routeEvalCommand({ evalMod, args, cwd, raw, error });
1129
- break;
1130
- }
1087
+ const date = new Date().toISOString().slice(0, 10);
1088
+ const { execGit } = require('./lib/shell-command-projection.cjs');
1089
+ const hashResult = execGit(['rev-parse', '--short', 'HEAD'], { cwd });
1090
+ const commit = hashResult.exitCode === 0 && hashResult.stdout ? hashResult.stdout : '—';
1091
+
1092
+ const { appendQuickTaskRow } = require('./lib/markdown-table.cjs');
1093
+
1094
+ // #2242 review fix: route the read -> mutate -> write cycle through
1095
+ // state.readModifyWriteStateMd (lib/state.cjs) instead of a raw
1096
+ // fs.readFileSync + fs.writeFileSync pair, so the whole read-modify-write
1097
+ // is atomic under STATE.md's lockfile — closing the lost-update race a
1098
+ // raw read/write pair left open (cf. #500/#905/#1230). This mirrors the
1099
+ // pattern every other STATE.md-mutating case in state.cts uses (e.g.
1100
+ // cmdStateAddBlocker, cmdStateAddDecision): a mutable outer variable
1101
+ // captures the pure helper's side output, and a fail-loud reason throws
1102
+ // ExitError from INSIDE the transform (readModifyWriteStateMd's finally
1103
+ // still releases the lock before the throw propagates; the transform
1104
+ // throws before returning new content, so nothing is ever written).
1105
+ let mutation;
1106
+ state.readModifyWriteStateMd(statePath, (content) => {
1107
+ const result = appendQuickTaskRow(content, { description: qtaTask, date, commit });
1108
+ if (!result.ok) {
1109
+ // Mirrors fast.md's old "skip with a brief log" behaviour (#2133): this
1110
+ // is an expected, recoverable condition (no table / unrecognized
1111
+ // schema), not a hard crash. ExitError sets a non-zero exit code (so
1112
+ // fast.md's `|| echo ...` fallback fires) without calling
1113
+ // process.exit() directly — stdout stays flushed and untouched.
1114
+ throw new ExitError(1, `⚠ quick-tasks-append: ${result.reason}`);
1115
+ }
1116
+ mutation = result.value;
1117
+ return result.value.content;
1118
+ }, cwd);
1131
1119
 
1132
- // ─── Verification Status ───────────────────────────────────────────────
1133
- //
1134
- // verification status <phaseDir>
1135
- // Read the first *-VERIFICATION.md in phaseDir and return
1136
- // { status, next_action, next_command } routing result.
1137
- //
1138
- // Note: `verification` (reads verifier-emitted status) is distinct from
1139
- // `verify` (runs verification checks like plan-structure/artifacts).
1140
-
1141
- case 'verification': {
1142
- routeVerificationCommand({
1143
- verification,
1144
- args,
1145
- cwd,
1146
- raw,
1147
- error,
1148
- });
1149
- break;
1150
- }
1120
+ output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row);
1121
+ }
1151
1122
 
1152
- case 'generate-slug': {
1153
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1154
- // SDK handler: generateSlug in sdk/src/query/utils.ts.
1155
- const handled = _dispatchNonFamily({
1156
- registryCommand: 'generate-slug',
1157
- registryArgs: args.slice(1),
1158
- legacyCommand: 'generate-slug',
1159
- legacyArgs: args.slice(1),
1160
- cwd,
1161
- raw,
1162
- error,
1163
- output: output,
1164
- });
1165
- if (!handled) commands.cmdGenerateSlug(args[1], raw);
1166
- break;
1167
- }
1123
+ function routeNormalizeTestCommand({ args, cwd, raw, error }) {
1124
+ // #1857: rewrite a resolved test command to a one-shot form so a
1125
+ // watch-mode runner (vitest/jest) cannot hang a verification gate. Shared
1126
+ // by the regression gate and the post-merge gate. args[1] is the raw
1127
+ // resolved command; --cwd (already parsed into `cwd`) locates package.json.
1128
+ const testCommandNormalizer = require('./lib/normalize-test-command.cjs');
1129
+ testCommandNormalizer.cmdNormalizeTestCommand(cwd, args[1]);
1130
+ }
1168
1131
 
1169
- case 'current-timestamp': {
1170
- // Keep this command on the CJS fast path.
1171
- // Rationale: it is a pure local formatter and avoids SDK bridge startup
1172
- // in tight subprocess loops where Windows CI has shown intermittent
1173
- // native crashes (0xC0000005 / 3221225477).
1174
- commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1175
- break;
1176
- }
1132
+ function routeDispatchShouldFlatten({ args, cwd, raw, error }) {
1133
+ // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
1134
+ //
1135
+ // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
1136
+ // looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and
1137
+ // calls shouldFlattenDispatch(dispatch) from host-integration.cjs.
1138
+ //
1139
+ // Fail-closed: any unknown runtime, missing dispatch, or thrown error
1140
+ // yields `true` (inline — the always-safe default).
1141
+ //
1142
+ // Output:
1143
+ // --raw → prints exactly `true` or `false`
1144
+ // --json → prints { runtime, shouldFlatten, dispatch }
1145
+ // default → same as --raw
1146
+ try {
1147
+ // Resolve runtime using the same precedence as `config-get runtime`.
1148
+ const { resolveRuntime } = require('./lib/runtime-slash.cjs');
1149
+ const runtimeId = resolveRuntime(cwd);
1150
+
1151
+ // Look up dispatch from the capability registry.
1152
+ const registry = require('./lib/capability-registry.cjs');
1153
+ const runtimeEntry = registry.runtimes != null
1154
+ ? registry.runtimes[runtimeId]
1155
+ : null;
1156
+ const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null;
1157
+
1158
+ // Call shouldFlattenDispatch from host-integration.cjs.
1159
+ const hostIntegration = require('./lib/host-integration.cjs');
1160
+ const shouldFlat = dispatch !== null
1161
+ ? hostIntegration.shouldFlattenDispatch(dispatch)
1162
+ : true; // fail-closed: unknown runtime → inline
1163
+
1164
+ const jsonIdx = args.indexOf('--json');
1165
+ if (jsonIdx !== -1) {
1166
+ output({
1167
+ runtime: runtimeId,
1168
+ shouldFlatten: shouldFlat,
1169
+ dispatch: dispatch,
1170
+ }, raw);
1171
+ } else {
1172
+ // --raw or default: print exactly true or false
1173
+ process.stdout.write(shouldFlat ? 'true' : 'false');
1174
+ }
1175
+ } catch {
1176
+ // Fail-closed on any error: inline is always safe.
1177
+ process.stdout.write('true');
1178
+ }
1179
+ }
1177
1180
 
1178
- case 'project-instruction-file': {
1179
- // #1529: pure runtime→filename projection. Backs the
1180
- // `gsd_run query project-instruction-file --runtime <r>` call in
1181
- // new-project.md so the bash workflow and profile-output.cjs share one
1182
- // source of truth (getProjectInstructionFile in runtime-name-policy.cjs).
1183
- // No SDK bridge — pure local lookup, runs before .planning/ exists.
1184
- const { getProjectInstructionFile } = require('./lib/runtime-name-policy.cjs');
1185
- // Parse --runtime <value> (space or = form); default to empty so the
1186
- // safe AGENTS.md cross-agent default applies.
1187
- const pifArgs = args.slice(1);
1188
- let pifRuntime = '';
1189
- for (let i = 0; i < pifArgs.length; i++) {
1190
- const a = pifArgs[i];
1191
- if (a === '--runtime' && pifArgs[i + 1] !== undefined) { pifRuntime = pifArgs[++i]; continue; }
1192
- if (a.startsWith('--runtime=')) { pifRuntime = a.slice('--runtime='.length); continue; }
1193
- // First positional that isn't a flag also works (lenient); otherwise ignore unknown flags.
1194
- if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; }
1195
- }
1196
- const filename = getProjectInstructionFile(pifRuntime);
1197
- process.stdout.write(filename + '\n');
1198
- break;
1199
- }
1181
+ function routeAgentSkills({ args, cwd, raw, error }) {
1182
+ // --json emits typed IR { agent_type, block, skills_count } for test assertions
1183
+ // (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
1184
+ const jsonIdx = args.indexOf('--json');
1185
+ const agentSkillsJsonMode = jsonIdx !== -1;
1186
+ if (agentSkillsJsonMode) args.splice(jsonIdx, 1);
1187
+ init.cmdAgentSkills(cwd, args[1], raw, agentSkillsJsonMode);
1188
+ }
1200
1189
 
1201
- case 'list-todos': {
1202
- commands.cmdListTodos(cwd, args[1], raw);
1203
- break;
1204
- }
1190
+ function routeSkillManifest({ args, cwd, raw, error }) {
1191
+ init.cmdSkillManifest(cwd, args, raw);
1192
+ }
1205
1193
 
1206
- case 'list-seeds': {
1207
- commands.cmdListSeeds(cwd, args[1], raw);
1208
- break;
1209
- }
1194
+ function routeHistoryDigest({ args, cwd, raw, error }) {
1195
+ commands.cmdHistoryDigest(cwd, raw);
1196
+ }
1210
1197
 
1211
- case 'verify-path-exists': {
1212
- commands.cmdVerifyPathExists(cwd, args[1], raw);
1213
- break;
1214
- }
1198
+ function routePhases({ args, cwd, raw, error }) {
1199
+ routePhasesCommand({
1200
+ phase,
1201
+ milestone,
1202
+ args,
1203
+ cwd,
1204
+ raw,
1205
+ error,
1206
+ });
1207
+ }
1215
1208
 
1216
- case 'config-ensure-section': {
1217
- // Phase 6 (#3575): dispatch via SDK executeForCjs. The catalog rebinds
1218
- // 'config-ensure-section' to configNewProject in
1219
- // sdk/src/query/command-static-catalog-foundation.ts, restoring the
1220
- // legacy "no-arg full default init" contract on the SDK path
1221
- // (configEnsureSection itself stays available as an unbound single-
1222
- // section helper for future SDK callers).
1223
- const handled = _dispatchNonFamily({
1224
- registryCommand: 'config-ensure-section',
1225
- registryArgs: args.slice(1),
1226
- legacyCommand: 'config-ensure-section',
1227
- legacyArgs: args.slice(1),
1228
- cwd,
1229
- raw,
1230
- error,
1231
- output: output,
1232
- });
1233
- if (!handled) config.cmdConfigEnsureSection(cwd, raw);
1234
- break;
1235
- }
1209
+ function routeAssumptionDelta({ args, cwd, raw, error }) {
1210
+ // #1561 — advisory architecture checkpoint. `scan <phase>` reads the
1211
+ // phase section via the same resolver as roadmap.get-phase and runs the
1212
+ // deterministic detectAssumptionDelta, emitting the typed IR as JSON.
1213
+ const sub = args[1];
1214
+ if (sub === 'scan') {
1215
+ const phaseNum = args[2];
1216
+ // Reject missing or flag-shaped phase values (QA matrix: values that
1217
+ // look like flags). `scan --json` must not treat "--json" as a phase.
1218
+ if (!phaseNum || phaseNum.startsWith('-')) {
1219
+ error('Usage: assumption-delta scan <phase> [--terms <csv>]', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1220
+ return;
1221
+ }
1222
+ // Optional --terms <csv> override (replaces the pluralization cues;
1223
+ // optional/chosen keep defaults). An EMPTY value ("") or a flag-shaped
1224
+ // value restores the curated defaults (does NOT disable pluralization).
1225
+ // Terms are normalized (deduped, alphanumeric-only, capped) by
1226
+ // detectAssumptionDelta's resolveTerms.
1227
+ let termsOverride;
1228
+ const termsIdx = args.indexOf('--terms');
1229
+ const termsVal = termsIdx !== -1 ? args[termsIdx + 1] : undefined;
1230
+ if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
1231
+ const list = termsVal
1232
+ .split(',')
1233
+ .map((t) => t.trim().toLowerCase())
1234
+ .filter((t) => t.length > 0);
1235
+ termsOverride = list.length > 0 ? { pluralization: list } : undefined;
1236
+ }
1237
+ const section = roadmap.getRoadmapPhaseWithFallback(cwd, phaseNum);
1238
+ const result = detectAssumptionDelta(section ?? '', termsOverride);
1239
+ output(result, raw);
1240
+ return;
1241
+ }
1242
+ error(`Unknown assumption-delta subcommand: ${sub}. Available: scan`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1243
+ }
1236
1244
 
1237
- case 'config-set': {
1238
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1239
- const handled = _dispatchNonFamily({
1240
- registryCommand: 'config-set',
1241
- registryArgs: args.slice(1),
1242
- legacyCommand: 'config-set',
1243
- legacyArgs: args.slice(1),
1244
- cwd,
1245
- raw,
1246
- error,
1247
- output: output,
1248
- });
1249
- if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw);
1250
- break;
1251
- }
1245
+ function routeRequirements({ args, cwd, raw, error }) {
1246
+ const subcommand = args[1];
1247
+ if (subcommand === 'mark-complete') {
1248
+ milestone.cmdRequirementsMarkComplete(cwd, args.slice(2), raw);
1249
+ } else if (subcommand === 'ready-ids') {
1250
+ // #2388: read-only shared-ID gate — computes which of the given
1251
+ // requirement IDs are safe to hand to mark-complete right now
1252
+ // (no sibling *-PLAN.md in the same phase dir still missing its
1253
+ // *-SUMMARY.md for that ID).
1254
+ milestone.cmdRequirementsReadyIds(cwd, args.slice(2), raw);
1255
+ } else if (subcommand === 'revert-phase') {
1256
+ // #2388: gaps_found-only revert — flips this phase's own
1257
+ // requirement IDs back out of Complete (checkbox + traceability
1258
+ // row) before the gap report renders.
1259
+ milestone.cmdRequirementsRevertPhase(cwd, args.slice(2), raw);
1260
+ } else {
1261
+ error('Unknown requirements subcommand. Available: mark-complete, ready-ids, revert-phase', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1262
+ }
1263
+ }
1252
1264
 
1253
- case "config-set-model-profile": {
1254
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1255
- const handled = _dispatchNonFamily({
1256
- registryCommand: 'config-set-model-profile',
1257
- registryArgs: args.slice(1),
1258
- legacyCommand: 'config-set-model-profile',
1259
- legacyArgs: args.slice(1),
1260
- cwd,
1261
- raw,
1262
- error,
1263
- output: output,
1264
- });
1265
- if (!handled) config.cmdConfigSetModelProfile(cwd, args[1], raw);
1266
- break;
1267
- }
1265
+ function routeGapAnalysis({ args, cwd, raw, error }) {
1266
+ // Post-planning gap checker (#2493) — unified REQUIREMENTS.md +
1267
+ // CONTEXT.md <decisions> coverage report against PLAN.md files.
1268
+ gapChecker.cmdGapAnalysis(cwd, args.slice(1), raw);
1269
+ }
1268
1270
 
1269
- case 'config-get': {
1270
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1271
- // The SDK handler supports --default via the registry args (args.slice(1)
1272
- // contains the key; defaultValue is handled by the SDK via the --default
1273
- // flag which was already stripped from args and held in defaultValue).
1274
- // Pass the full original args.slice(1) so the SDK sees the key; the
1275
- // defaultValue from the flag is in the global defaultValue variable above.
1276
- // Since the SDK handler reads --default from registryArgs, re-inject it.
1277
- const configGetSdkArgs = defaultValue !== undefined
1278
- ? [args[1], '--default', defaultValue]
1279
- : args.slice(1);
1280
- const handled = _dispatchNonFamily({
1281
- registryCommand: 'config-get',
1282
- registryArgs: configGetSdkArgs,
1283
- legacyCommand: 'config-get',
1284
- legacyArgs: args.slice(1),
1285
- cwd,
1286
- raw,
1287
- error,
1288
- output: output,
1289
- });
1290
- if (!handled) config.cmdConfigGet(cwd, args[1], raw, defaultValue);
1291
- break;
1292
- }
1271
+ function routeMilestone({ args, cwd, raw, error }) {
1272
+ const subcommand = args[1];
1273
+ if (subcommand === 'complete') {
1274
+ const milestoneName = parseMultiwordArg(args, 'name');
1275
+ // #1871: archive phase dirs by default on milestone complete so the next
1276
+ // new-milestone never inherits un-archived dirs. --no-archive-phases opts out.
1277
+ const archivePhases = !args.includes('--no-archive-phases');
1278
+ const force = args.includes('--force');
1279
+ // #2118: --dry-run prints a preview plan without mutating.
1280
+ const dryRun = args.includes('--dry-run');
1281
+ milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun }, raw);
1282
+ } else {
1283
+ error('Unknown milestone subcommand. Available: complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1284
+ }
1285
+ }
1293
1286
 
1294
- case 'normalize-test-command': {
1295
- // #1857: rewrite a resolved test command to a one-shot form so a
1296
- // watch-mode runner (vitest/jest) cannot hang a verification gate. Shared
1297
- // by the regression gate and the post-merge gate. args[1] is the raw
1298
- // resolved command; --cwd (already parsed into `cwd`) locates package.json.
1299
- const testCommandNormalizer = require('./lib/normalize-test-command.cjs');
1300
- testCommandNormalizer.cmdNormalizeTestCommand(cwd, args[1]);
1301
- break;
1302
- }
1287
+ function routeProgress({ args, cwd, raw, error }) {
1288
+ const subcommand = args[1] || 'json';
1289
+ commands.cmdProgressRender(cwd, subcommand, raw);
1290
+ }
1303
1291
 
1304
- case 'dispatch-should-flatten': {
1305
- // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
1306
- //
1307
- // Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
1308
- // looks up registry.runtimes[id].runtime.hostIntegration.dispatch, and
1309
- // calls shouldFlattenDispatch(dispatch) from host-integration.cjs.
1310
- //
1311
- // Fail-closed: any unknown runtime, missing dispatch, or thrown error
1312
- // yields `true` (inline — the always-safe default).
1313
- //
1314
- // Output:
1315
- // --raw → prints exactly `true` or `false`
1316
- // --json → prints { runtime, shouldFlatten, dispatch }
1317
- // default → same as --raw
1318
- try {
1319
- // Resolve runtime using the same precedence as `config-get runtime`.
1320
- const { resolveRuntime } = require('./lib/runtime-slash.cjs');
1321
- const runtimeId = resolveRuntime(cwd);
1322
-
1323
- // Look up dispatch from the capability registry.
1324
- const registry = require('./lib/capability-registry.cjs');
1325
- const runtimeEntry = registry.runtimes != null
1326
- ? registry.runtimes[runtimeId]
1327
- : null;
1328
- const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null;
1329
-
1330
- // Call shouldFlattenDispatch from host-integration.cjs.
1331
- const hostIntegration = require('./lib/host-integration.cjs');
1332
- const shouldFlat = dispatch !== null
1333
- ? hostIntegration.shouldFlattenDispatch(dispatch)
1334
- : true; // fail-closed: unknown runtime → inline
1335
-
1336
- const jsonIdx = args.indexOf('--json');
1337
- if (jsonIdx !== -1) {
1338
- output({
1339
- runtime: runtimeId,
1340
- shouldFlatten: shouldFlat,
1341
- dispatch: dispatch,
1342
- }, raw);
1343
- } else {
1344
- // --raw or default: print exactly true or false
1345
- process.stdout.write(shouldFlat ? 'true' : 'false');
1346
- }
1347
- } catch {
1348
- // Fail-closed on any error: inline is always safe.
1349
- process.stdout.write('true');
1350
- }
1351
- break;
1352
- }
1292
+ function routeUat({ args, cwd, raw, error }) {
1293
+ const subcommand = args[1];
1294
+ if (subcommand === 'render-checkpoint') {
1295
+ const uat = require('./lib/uat.cjs');
1296
+ const options = parseNamedArgs(args, ['file']);
1297
+ uat.cmdRenderCheckpoint(cwd, options, raw);
1298
+ } else if (subcommand === 'classify-coverage') {
1299
+ const coverage = require('./lib/coverage.cjs');
1300
+ const options = parseNamedArgs(args, ['summary', 'file']);
1301
+ coverage.cmdClassify(cwd, options, raw);
1302
+ } else {
1303
+ error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1304
+ }
1305
+ }
1353
1306
 
1354
- case 'config-new-project': {
1355
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1356
- const handled = _dispatchNonFamily({
1357
- registryCommand: 'config-new-project',
1358
- registryArgs: args.slice(1),
1359
- legacyCommand: 'config-new-project',
1360
- legacyArgs: args.slice(1),
1361
- cwd,
1362
- raw,
1363
- error,
1364
- output: output,
1365
- });
1366
- if (!handled) config.cmdConfigNewProject(cwd, args[1], raw);
1367
- break;
1368
- }
1307
+ function routeStats({ args, cwd, raw, error }) {
1308
+ const subcommand = args[1] || 'json';
1309
+ commands.cmdStats(cwd, subcommand, raw);
1310
+ }
1369
1311
 
1370
- case 'config-path': {
1371
- // CJS-native: config-path returns the filesystem path to config.json.
1372
- // The SDK handler (configPath) also exists but requires a projectDir that
1373
- // is already resolved. Both produce identical output; keeping CJS here is
1374
- // simpler and avoids sync-bridge overhead for a trivial path lookup.
1375
- config.cmdConfigPath(cwd, raw, workstreamContext);
1376
- break;
1377
- }
1312
+ function routeTodo({ args, cwd, raw, error }) {
1313
+ const subcommand = args[1];
1314
+ if (subcommand === 'complete') {
1315
+ commands.cmdTodoComplete(cwd, args[2], raw);
1316
+ } else if (subcommand === 'match-phase') {
1317
+ commands.cmdTodoMatchPhase(cwd, args[2], raw);
1318
+ } else {
1319
+ error('Unknown todo subcommand. Available: complete, match-phase', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1320
+ }
1321
+ }
1378
1322
 
1379
- case 'migrate-config': {
1380
- // CJS-native: migrate-config wraps the Configuration Module migrateOnDisk()
1381
- // which is async and mutates the filesystem. No SDK counterpart exists in
1382
- // the command registry (it's a one-shot migration utility). Must await.
1383
- await config.cmdMigrateConfig(cwd, raw);
1384
- break;
1385
- }
1323
+ function routeScaffold({ args, cwd, raw, error }) {
1324
+ const scaffoldType = args[1];
1325
+ const scaffoldOptions = {
1326
+ phase: parseNamedArgs(args, ['phase']).phase,
1327
+ name: parseMultiwordArg(args, 'name'),
1328
+ };
1329
+ commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
1330
+ }
1386
1331
 
1387
- case 'agent-skills': {
1388
- // --json emits typed IR { agent_type, block, skills_count } for test assertions
1389
- // (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
1390
- const jsonIdx = args.indexOf('--json');
1391
- const agentSkillsJsonMode = jsonIdx !== -1;
1392
- if (agentSkillsJsonMode) args.splice(jsonIdx, 1);
1393
- init.cmdAgentSkills(cwd, args[1], raw, agentSkillsJsonMode);
1394
- break;
1395
- }
1332
+ function routeLoop({ args, cwd, raw, error }) {
1333
+ // loop render-hooks <point>
1334
+ const loopSubcommand = args[1];
1335
+ if (loopSubcommand === 'render-hooks') {
1336
+ let loopConfigDir = null;
1337
+ const configDirEqArg = args.find(arg => arg.startsWith('--config-dir='));
1338
+ const configDirIdx = args.indexOf('--config-dir');
1339
+ if (configDirEqArg) {
1340
+ const value = configDirEqArg.slice('--config-dir='.length).trim();
1341
+ if (!value) error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1342
+ loopConfigDir = value;
1343
+ } else if (configDirIdx !== -1) {
1344
+ const value = args[configDirIdx + 1];
1345
+ if (!value || value.startsWith('--')) {
1346
+ error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1347
+ }
1348
+ loopConfigDir = value;
1349
+ }
1350
+ // --active-cap <capId>: parse and validate before delegating
1351
+ let loopActiveCap = undefined;
1352
+ const activeCapEqArg = args.find(arg => arg.startsWith('--active-cap='));
1353
+ const activeCapIdx = args.indexOf('--active-cap');
1354
+ if (activeCapEqArg) {
1355
+ const value = activeCapEqArg.slice('--active-cap='.length).trim();
1356
+ if (!value) error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1357
+ loopActiveCap = value;
1358
+ } else if (activeCapIdx !== -1) {
1359
+ const value = args[activeCapIdx + 1];
1360
+ if (!value || value.startsWith('--')) {
1361
+ error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1362
+ }
1363
+ loopActiveCap = value;
1364
+ }
1365
+ // --runtime <r> (#2003): explicit runtime override so the config-dir
1366
+ // resolution bypasses the persisted-runtime fallback (GSD_RUNTIME →
1367
+ // config.runtime). Mirrors the --config-dir dual-form (--runtime X /
1368
+ // --runtime=X) and the capability-set --runtime precedent.
1369
+ let loopRuntime = undefined;
1370
+ const runtimeEqArg = args.find(arg => arg.startsWith('--runtime='));
1371
+ const runtimeIdx = args.indexOf('--runtime');
1372
+ if (runtimeEqArg) {
1373
+ const value = runtimeEqArg.slice('--runtime='.length).trim();
1374
+ if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1375
+ loopRuntime = value;
1376
+ } else if (runtimeIdx !== -1) {
1377
+ const value = args[runtimeIdx + 1];
1378
+ if (!value || value.startsWith('--')) {
1379
+ error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1380
+ }
1381
+ loopRuntime = value;
1382
+ }
1383
+ loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {
1384
+ configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined,
1385
+ activeCap: loopActiveCap,
1386
+ runtime: loopRuntime,
1387
+ });
1388
+ } else {
1389
+ error(
1390
+ `Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks`,
1391
+ ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
1392
+ );
1393
+ }
1394
+ }
1396
1395
 
1397
- case 'skill-manifest': {
1398
- init.cmdSkillManifest(cwd, args, raw);
1399
- break;
1400
- }
1396
+ function routePhasePlanIndex({ args, cwd, raw, error }) {
1397
+ phase.cmdPhasePlanIndex(cwd, args[1], raw);
1398
+ }
1401
1399
 
1402
- case 'history-digest': {
1403
- commands.cmdHistoryDigest(cwd, raw);
1404
- break;
1405
- }
1400
+ function routeStateSnapshot({ args, cwd, raw, error }) {
1401
+ state.cmdStateSnapshot(cwd, raw);
1402
+ }
1406
1403
 
1407
- case 'phases': {
1408
- routePhasesCommand({
1409
- phase,
1410
- milestone,
1411
- args,
1412
- cwd,
1413
- raw,
1414
- error,
1415
- });
1416
- break;
1417
- }
1404
+ function routeSummaryExtract({ args, cwd, raw, error }) {
1405
+ const summaryPath = args[1];
1406
+ const fieldsIndex = args.indexOf('--fields');
1407
+ const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
1408
+ commands.cmdSummaryExtract(cwd, summaryPath, fields, raw);
1409
+ }
1418
1410
 
1419
- case 'roadmap': {
1420
- routeRoadmapCommand({
1421
- roadmap,
1422
- args,
1423
- cwd,
1424
- raw,
1425
- error,
1426
- });
1427
- break;
1428
- }
1411
+ async function routeWebsearch({ args, cwd, raw, error }) {
1412
+ const query = args[1];
1413
+ const limitIdx = args.indexOf('--limit');
1414
+ const freshnessIdx = args.indexOf('--freshness');
1415
+ await commands.cmdWebsearch(query, {
1416
+ limit: limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10,
1417
+ freshness: freshnessIdx !== -1 ? args[freshnessIdx + 1] : null,
1418
+ }, raw);
1419
+ }
1429
1420
 
1430
- case 'assumption-delta': {
1431
- // #1561 — advisory architecture checkpoint. `scan <phase>` reads the
1432
- // phase section via the same resolver as roadmap.get-phase and runs the
1433
- // deterministic detectAssumptionDelta, emitting the typed IR as JSON.
1434
- const sub = args[1];
1435
- if (sub === 'scan') {
1436
- const phaseNum = args[2];
1437
- // Reject missing or flag-shaped phase values (QA matrix: values that
1438
- // look like flags). `scan --json` must not treat "--json" as a phase.
1439
- if (!phaseNum || phaseNum.startsWith('-')) {
1440
- error('Usage: assumption-delta scan <phase> [--terms <csv>]', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1441
- break;
1442
- }
1443
- // Optional --terms <csv> override (replaces the pluralization cues;
1444
- // optional/chosen keep defaults). An EMPTY value ("") or a flag-shaped
1445
- // value restores the curated defaults (does NOT disable pluralization).
1446
- // Terms are normalized (deduped, alphanumeric-only, capped) by
1447
- // detectAssumptionDelta's resolveTerms.
1448
- let termsOverride;
1449
- const termsIdx = args.indexOf('--terms');
1450
- const termsVal = termsIdx !== -1 ? args[termsIdx + 1] : undefined;
1451
- if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
1452
- const list = termsVal
1453
- .split(',')
1454
- .map((t) => t.trim().toLowerCase())
1455
- .filter((t) => t.length > 0);
1456
- termsOverride = list.length > 0 ? { pluralization: list } : undefined;
1457
- }
1458
- const section = roadmap.getRoadmapPhaseWithFallback(cwd, phaseNum);
1459
- const result = detectAssumptionDelta(section ?? '', termsOverride);
1460
- output(result, raw);
1461
- break;
1462
- }
1463
- error(`Unknown assumption-delta subcommand: ${sub}. Available: scan`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1464
- break;
1465
- }
1421
+ function routeWorkstream({ args, cwd, raw, error }) {
1422
+ const subcommand = args[1];
1423
+ if (subcommand === 'create') {
1424
+ const migrateNameIdx = args.indexOf('--migrate-name');
1425
+ const noMigrate = args.includes('--no-migrate');
1426
+ workstream.cmdWorkstreamCreate(cwd, args[2], {
1427
+ migrate: !noMigrate,
1428
+ migrateName: migrateNameIdx !== -1 ? args[migrateNameIdx + 1] : null,
1429
+ }, raw);
1430
+ } else if (subcommand === 'list') {
1431
+ workstream.cmdWorkstreamList(cwd, raw);
1432
+ } else if (subcommand === 'status') {
1433
+ workstream.cmdWorkstreamStatus(cwd, args[2], raw);
1434
+ } else if (subcommand === 'complete') {
1435
+ workstream.cmdWorkstreamComplete(cwd, args[2], {}, raw);
1436
+ } else if (subcommand === 'set') {
1437
+ workstream.cmdWorkstreamSet(cwd, args[2], raw);
1438
+ } else if (subcommand === 'get') {
1439
+ workstream.cmdWorkstreamGet(cwd, raw);
1440
+ } else if (subcommand === 'progress') {
1441
+ workstream.cmdWorkstreamProgress(cwd, raw);
1442
+ } else {
1443
+ error('Unknown workstream subcommand. Available: create, list, status, complete, set, get, progress', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1444
+ }
1445
+ }
1466
1446
 
1467
- case 'requirements': {
1468
- const subcommand = args[1];
1469
- if (subcommand === 'mark-complete') {
1470
- milestone.cmdRequirementsMarkComplete(cwd, args.slice(2), raw);
1471
- } else {
1472
- error('Unknown requirements subcommand. Available: mark-complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1473
- }
1474
- break;
1475
- }
1447
+ function routeWorktree({ args, cwd, raw, error }) {
1448
+ const subcommand = args[1];
1449
+ const worktreeSafety = require('./lib/worktree-safety.cjs');
1450
+ if (subcommand === 'cleanup-wave') {
1451
+ worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2));
1452
+ } else if (subcommand === 'record-agent') {
1453
+ worktreeSafety.cmdWorktreeRecordAgent(cwd, args.slice(2));
1454
+ } else if (subcommand === 'reap-orphans') {
1455
+ worktreeSafety.cmdWorktreeReapOrphans(cwd);
1456
+ } else if (subcommand === 'base-check') {
1457
+ require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
1458
+ } else if (subcommand === 'set-baseref') {
1459
+ require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
1460
+ } else {
1461
+ error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1462
+ }
1463
+ }
1476
1464
 
1477
- case 'gap-analysis': {
1478
- // Post-planning gap checker (#2493) — unified REQUIREMENTS.md +
1479
- // CONTEXT.md <decisions> coverage report against PLAN.md files.
1480
- gapChecker.cmdGapAnalysis(cwd, args.slice(1), raw);
1481
- break;
1482
- }
1465
+ function routeDocsInit({ args, cwd, raw, error }) {
1466
+ // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
1467
+ // SDK handler: docsInit in sdk/src/query/docs-init.ts.
1468
+ const handled = _dispatchNonFamily({
1469
+ registryCommand: 'docs-init',
1470
+ registryArgs: args.slice(1),
1471
+ legacyCommand: 'docs-init',
1472
+ legacyArgs: args.slice(1),
1473
+ cwd,
1474
+ raw,
1475
+ error,
1476
+ output: output,
1477
+ });
1478
+ if (!handled) docs.cmdDocsInit(cwd, raw);
1479
+ }
1483
1480
 
1484
- case 'phase': {
1485
- routePhaseCommand({
1486
- phase,
1487
- args,
1488
- cwd,
1489
- raw,
1490
- error,
1491
- });
1492
- break;
1493
- }
1481
+ function routeLearnings({ args, cwd, raw, error }) {
1482
+ const subcommand = args[1];
1483
+ if (subcommand === 'list') {
1484
+ learnings.cmdLearningsList(raw);
1485
+ } else if (subcommand === 'query') {
1486
+ const tagIdx = args.indexOf('--tag');
1487
+ const tag = tagIdx !== -1 ? args[tagIdx + 1] : null;
1488
+ if (!tag) error('Usage: gsd-tools learnings query --tag <tag>', ERROR_REASON.USAGE);
1489
+ learnings.cmdLearningsQuery(tag, raw);
1490
+ } else if (subcommand === 'copy') {
1491
+ learnings.cmdLearningsCopy(cwd, raw);
1492
+ } else if (subcommand === 'prune') {
1493
+ const olderIdx = args.indexOf('--older-than');
1494
+ const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
1495
+ if (!olderThan) error('Usage: gsd-tools learnings prune --older-than <duration>', ERROR_REASON.USAGE);
1496
+ learnings.cmdLearningsPrune(olderThan, raw);
1497
+ } else if (subcommand === 'delete') {
1498
+ const id = args[2];
1499
+ if (!id) error('Usage: gsd-tools learnings delete <id>', ERROR_REASON.USAGE);
1500
+ learnings.cmdLearningsDelete(id, raw);
1501
+ } else {
1502
+ error('Unknown learnings subcommand. Available: list, query, copy, prune, delete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1503
+ }
1504
+ }
1494
1505
 
1495
- case 'milestone': {
1496
- const subcommand = args[1];
1497
- if (subcommand === 'complete') {
1498
- const milestoneName = parseMultiwordArg(args, 'name');
1499
- // #1871: archive phase dirs by default on milestone complete so the next
1500
- // new-milestone never inherits un-archived dirs. --no-archive-phases opts out.
1501
- const archivePhases = !args.includes('--no-archive-phases');
1502
- const force = args.includes('--force');
1503
- milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force }, raw);
1506
+ function routeWindows({ args, cwd, raw, error }) {
1507
+ // windows status | append | waive | fixed (issue #1950)
1508
+ // All subcommands emit JSON; `--raw` is accepted for forward-compat with
1509
+ // capture-stdout hooks but is a no-op (output shape is JSON in both modes).
1510
+ const subcommand = args[1];
1511
+ const rest = args.slice(2);
1512
+ try {
1513
+ if (subcommand === 'status') {
1514
+ brokenWindows.cmdWindowsStatus(cwd, { raw });
1515
+ } else if (subcommand === 'append') {
1516
+ brokenWindows.cmdWindowsAppend(cwd, rest, { raw });
1517
+ } else if (subcommand === 'waive') {
1518
+ brokenWindows.cmdWindowsWaive(cwd, rest, { raw });
1519
+ } else if (subcommand === 'fixed') {
1520
+ brokenWindows.cmdWindowsMarkFixed(cwd, rest, { raw });
1504
1521
  } else {
1505
- error('Unknown milestone subcommand. Available: complete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1522
+ error(
1523
+ `Unknown windows subcommand: ${subcommand || '(none)'}. Available: status, append, waive, fixed`,
1524
+ ERROR_REASON.SDK_UNKNOWN_COMMAND,
1525
+ );
1506
1526
  }
1507
- break;
1508
- }
1509
-
1510
- case 'validate': {
1511
- routeValidateCommand({
1512
- verify,
1513
- args,
1514
- cwd,
1515
- raw,
1516
- output: output,
1517
- error,
1518
- });
1519
- break;
1520
- }
1521
-
1522
- case 'progress': {
1523
- const subcommand = args[1] || 'json';
1524
- commands.cmdProgressRender(cwd, subcommand, raw);
1525
- break;
1526
- }
1527
-
1528
- case 'uat': {
1529
- const subcommand = args[1];
1530
- if (subcommand === 'render-checkpoint') {
1531
- const uat = require('./lib/uat.cjs');
1532
- const options = parseNamedArgs(args, ['file']);
1533
- uat.cmdRenderCheckpoint(cwd, options, raw);
1534
- } else if (subcommand === 'classify-coverage') {
1535
- const coverage = require('./lib/coverage.cjs');
1536
- const options = parseNamedArgs(args, ['summary', 'file']);
1537
- coverage.cmdClassify(cwd, options, raw);
1538
- } else {
1539
- error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1527
+ } catch (e) {
1528
+ // WindowsError carries a REASON code; surface it through the structured
1529
+ // error path so tests can assert on the typed reason. `error()` calls
1530
+ // process.exit(1) internally so we never reach the fall-through.
1531
+ if (e && e.name === 'WindowsError' && typeof e.reason === 'string') {
1532
+ error(e.message || 'broken-windows error', e.reason);
1540
1533
  }
1541
- break;
1534
+ // Non-WindowsError: surface the message verbatim and exit non-zero.
1535
+ error(`broken-windows: ${(e && e.message) ? e.message : String(e)}`, ERROR_REASON.UNKNOWN);
1542
1536
  }
1537
+ }
1543
1538
 
1544
- case 'stats': {
1545
- const subcommand = args[1] || 'json';
1546
- commands.cmdStats(cwd, subcommand, raw);
1547
- break;
1548
- }
1539
+ function routeTeamsStatus({ args, cwd, raw, error }) {
1540
+ const teamsStatus = require('./lib/teams-status.cjs');
1541
+ teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
1542
+ }
1549
1543
 
1550
- case 'todo': {
1551
- const subcommand = args[1];
1552
- if (subcommand === 'complete') {
1553
- commands.cmdTodoComplete(cwd, args[2], raw);
1554
- } else if (subcommand === 'match-phase') {
1555
- commands.cmdTodoMatchPhase(cwd, args[2], raw);
1556
- } else {
1557
- error('Unknown todo subcommand. Available: complete, match-phase', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1558
- }
1559
- break;
1560
- }
1544
+ async function routeDetectCustomFiles({ args, cwd, raw, error }) {
1545
+ const configDirIdx = args.indexOf('--config-dir');
1546
+ const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
1547
+ if (!configDir) {
1548
+ error('Usage: gsd-tools detect-custom-files --config-dir <path>', ERROR_REASON.USAGE);
1549
+ }
1550
+ const resolvedConfigDir = path.resolve(configDir);
1551
+ if (!fs.existsSync(resolvedConfigDir)) {
1552
+ error(`Config directory not found: ${resolvedConfigDir}`, ERROR_REASON.USAGE);
1553
+ }
1561
1554
 
1562
- case 'scaffold': {
1563
- const scaffoldType = args[1];
1564
- const scaffoldOptions = {
1565
- phase: parseNamedArgs(args, ['phase']).phase,
1566
- name: parseMultiwordArg(args, 'name'),
1567
- };
1568
- commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
1569
- break;
1570
- }
1555
+ const manifestPath = path.join(resolvedConfigDir, 'gsd-file-manifest.json');
1556
+ if (!fs.existsSync(manifestPath)) {
1557
+ // No manifest — cannot determine what is custom. Return empty list
1558
+ // (same behaviour as saveLocalPatches in install.js when no manifest).
1559
+ const out = { custom_files: [], custom_count: 0, manifest_found: false };
1560
+ process.stdout.write(JSON.stringify(out, null, 2));
1561
+ return;
1562
+ }
1571
1563
 
1572
- case 'init': {
1573
- // #1688: warn (at most once per process) if the user edited model_overrides
1574
- // without re-running `gsd install <runtime>` on a static-frontmatter runtime.
1575
- // Best-effort, stderr-only, swallowed errors — never blocks the command.
1576
- try { warnIfStaleBake(cwd); } catch { /* guard must never break init */ }
1577
- routeInitCommand({
1578
- init,
1579
- args,
1580
- cwd,
1581
- raw,
1582
- error,
1583
- });
1584
- break;
1585
- }
1564
+ let manifest;
1565
+ try {
1566
+ manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
1567
+ } catch {
1568
+ const out = { custom_files: [], custom_count: 0, manifest_found: false, error: 'manifest parse error' };
1569
+ process.stdout.write(JSON.stringify(out, null, 2));
1570
+ return;
1571
+ }
1586
1572
 
1587
- case 'loop': {
1588
- // loop render-hooks <point>
1589
- const loopSubcommand = args[1];
1590
- if (loopSubcommand === 'render-hooks') {
1591
- let loopConfigDir = null;
1592
- const configDirEqArg = args.find(arg => arg.startsWith('--config-dir='));
1593
- const configDirIdx = args.indexOf('--config-dir');
1594
- if (configDirEqArg) {
1595
- const value = configDirEqArg.slice('--config-dir='.length).trim();
1596
- if (!value) error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1597
- loopConfigDir = value;
1598
- } else if (configDirIdx !== -1) {
1599
- const value = args[configDirIdx + 1];
1600
- if (!value || value.startsWith('--')) {
1601
- error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1573
+ const manifestKeys = new Set(Object.keys(manifest.files || {}));
1574
+
1575
+ // GSD-managed directories to scan for user-added files. Whole-owned
1576
+ // roots are wiped recursively; shared runtime roots are pruned by the
1577
+ // same gsd-* top-level prefix used by install.js _removeGsdEntries.
1578
+ const GSD_WHOLE_MANAGED_DIRS = [
1579
+ 'gsd-core',
1580
+ path.join('commands', 'gsd'),
1581
+ ];
1582
+ const GSD_PREFIX_MANAGED_DIRS = [
1583
+ 'agents',
1584
+ 'hooks',
1585
+ 'skills',
1586
+ ];
1587
+
1588
+ function collectCustomFiles(dir, baseDir, manifestKeys, out) {
1589
+ if (!fs.existsSync(dir)) return;
1590
+ const stat = fs.statSync(dir);
1591
+ if (stat.isFile()) {
1592
+ const relPath = path.relative(baseDir, dir).replace(/\\/g, '/');
1593
+ if (!manifestKeys.has(relPath)) {
1594
+ out.push(relPath);
1595
+ }
1596
+ return;
1597
+ }
1598
+ if (!stat.isDirectory()) return;
1599
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1600
+ const fullPath = path.join(dir, entry.name);
1601
+ if (entry.isDirectory()) {
1602
+ collectCustomFiles(fullPath, baseDir, manifestKeys, out);
1603
+ continue;
1604
+ }
1605
+ // Use forward slashes for cross-platform manifest key compatibility
1606
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
1607
+ if (!manifestKeys.has(relPath)) {
1608
+ out.push(relPath);
1609
+ }
1610
+ }
1602
1611
  }
1603
- loopConfigDir = value;
1604
- }
1605
- // --active-cap <capId>: parse and validate before delegating
1606
- let loopActiveCap = undefined;
1607
- const activeCapEqArg = args.find(arg => arg.startsWith('--active-cap='));
1608
- const activeCapIdx = args.indexOf('--active-cap');
1609
- if (activeCapEqArg) {
1610
- const value = activeCapEqArg.slice('--active-cap='.length).trim();
1611
- if (!value) error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1612
- loopActiveCap = value;
1613
- } else if (activeCapIdx !== -1) {
1614
- const value = args[activeCapIdx + 1];
1615
- if (!value || value.startsWith('--')) {
1616
- error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1612
+
1613
+ const customFiles = [];
1614
+ for (const managedDir of GSD_WHOLE_MANAGED_DIRS) {
1615
+ const absDir = path.join(resolvedConfigDir, managedDir);
1616
+ if (!fs.existsSync(absDir)) continue;
1617
+ collectCustomFiles(absDir, resolvedConfigDir, manifestKeys, customFiles);
1617
1618
  }
1618
- loopActiveCap = value;
1619
- }
1620
- // --runtime <r> (#2003): explicit runtime override so the config-dir
1621
- // resolution bypasses the persisted-runtime fallback (GSD_RUNTIME →
1622
- // config.runtime). Mirrors the --config-dir dual-form (--runtime X /
1623
- // --runtime=X) and the capability-set --runtime precedent.
1624
- let loopRuntime = undefined;
1625
- const runtimeEqArg = args.find(arg => arg.startsWith('--runtime='));
1626
- const runtimeIdx = args.indexOf('--runtime');
1627
- if (runtimeEqArg) {
1628
- const value = runtimeEqArg.slice('--runtime='.length).trim();
1629
- if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1630
- loopRuntime = value;
1631
- } else if (runtimeIdx !== -1) {
1632
- const value = args[runtimeIdx + 1];
1633
- if (!value || value.startsWith('--')) {
1634
- error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1619
+ for (const managedDir of GSD_PREFIX_MANAGED_DIRS) {
1620
+ const absDir = path.join(resolvedConfigDir, managedDir);
1621
+ if (!fs.existsSync(absDir)) continue;
1622
+ for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
1623
+ if (!entry.name.startsWith('gsd-')) continue;
1624
+ collectCustomFiles(path.join(absDir, entry.name), resolvedConfigDir, manifestKeys, customFiles);
1625
+ }
1635
1626
  }
1636
- loopRuntime = value;
1637
- }
1638
- loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {
1639
- configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined,
1640
- activeCap: loopActiveCap,
1641
- runtime: loopRuntime,
1642
- });
1643
- } else {
1644
- error(
1645
- `Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks`,
1646
- ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
1647
- );
1648
- }
1649
- break;
1650
- }
1651
1627
 
1652
- case 'capability': {
1653
- // capability state [--config-dir <path>]
1654
- // Root resolution: 'capability' is NOT in SKIP_ROOT_RESOLUTION for the
1655
- // same reason 'loop' is not: both are registry/config queries that need
1656
- // the project root (cwd) for .planning/config.json activation resolution.
1657
- // If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
1658
- // be added at the same time to keep them consistent.
1659
- const capSubcommand = args[1];
1660
- // --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
1661
- // Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
1662
- // lazy-required inside each mutating branch so the common state/set paths never load them. ---
1663
- const capFlagValue = (name) => {
1664
- const i = args.indexOf(name);
1665
- if (i === -1) return undefined;
1666
- const v = args[i + 1];
1667
- if (!v || v.startsWith('--')) {
1668
- error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1669
- }
1670
- return v;
1671
- };
1672
- const capHasFlag = (name) => args.includes(name);
1673
- const capRepeatedFlag = (name) => {
1674
- const out = [];
1675
- for (let i = 0; i < args.length; i++) {
1676
- if (args[i] === name) {
1677
- const v = args[i + 1];
1678
- if (!v || v.startsWith('--')) {
1679
- error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1628
+ const out = {
1629
+ custom_files: customFiles,
1630
+ custom_count: customFiles.length,
1631
+ manifest_found: true,
1632
+ manifest_version: manifest.version || null,
1633
+ };
1634
+ process.stdout.write(JSON.stringify(out, null, 2));
1635
+ }
1636
+
1637
+ function routeFromGsd2({ args, cwd, raw, error }) {
1638
+ const gsd2Import = require('./lib/gsd2-import.cjs');
1639
+ gsd2Import.cmdFromGsd2(args.slice(1), cwd, raw);
1640
+ }
1641
+
1642
+ async function routePromptBudget({ args, cwd, raw, error }) {
1643
+ const promptBudget = require('./lib/prompt-budget.cjs');
1644
+
1645
+ // ── Collect multi-value --plan-file flags ──────────────────────────
1646
+ const planFiles = [];
1647
+ for (let i = 1; i < args.length; i++) {
1648
+ if (args[i] === '--plan-file' && args[i + 1] && !args[i + 1].startsWith('--')) {
1649
+ planFiles.push(args[i + 1]);
1650
+ i++;
1651
+ }
1652
+ }
1653
+
1654
+ // ── Parse single-value flags ───────────────────────────────────────
1655
+ const flagMap = new Map();
1656
+ for (let i = 1; i < args.length; i++) {
1657
+ const current = args[i];
1658
+ const next = args[i + 1];
1659
+ if (!current.startsWith('--')) continue;
1660
+ if (!next || next.startsWith('--')) {
1661
+ if (!flagMap.has(current)) flagMap.set(current, null);
1662
+ continue;
1680
1663
  }
1681
- out.push(v);
1682
- i++; // skip the consumed value
1664
+ if (!flagMap.has(current)) flagMap.set(current, next);
1665
+ i++;
1683
1666
  }
1684
- }
1685
- return out;
1686
- };
1687
- // Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
1688
- // .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
1689
- // read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
1690
- // project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
1691
- // via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
1692
- // Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
1693
- // regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
1694
- const capResolveScope = (scope) => {
1695
- const s = scope || 'global';
1696
- if (s !== 'global' && s !== 'project') {
1697
- error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1698
- }
1699
- if (s === 'project') return { scope: 'project', runtimeDir: cwd };
1700
- const os = require('node:os');
1701
- return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
1702
- };
1703
- // capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
1704
- // loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
1705
- // undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
1706
- // malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
1707
- // silently downgraded to permissive here.
1708
- const capReadStrict = () => {
1709
- let cfgPath;
1710
- try {
1711
- const { planningDir } = require('./lib/planning-workspace.cjs');
1712
- cfgPath = path.join(planningDir(cwd), 'config.json');
1713
- } catch {
1714
- return undefined; // cannot even resolve the project config dir — permissive default
1715
- }
1716
- if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
1717
- let cfg;
1718
- try {
1719
- cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
1720
- } catch {
1721
- // Config is PRESENT but unreadable/unparseable: a security policy must not silently
1722
- // downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
1723
- // still allowed) until the config is fixed.
1724
- return [];
1725
- }
1726
- if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
1727
- return cfg.capabilities.strict_known_registries;
1728
- }
1729
- return undefined;
1730
- };
1731
- // Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
1732
- // #1920: prefer the authoritative gsd-core/VERSION the installer writes for EVERY runtime
1733
- // (gsd-core/bin/ -> ../VERSION), so installed layouts report the true version even when the
1734
- // walked-up ../../package.json is the versionless CommonJS marker or the user's own project.
1735
- // Fall back to the runtime-root package.json (dev/source tree), then fail-closed. Mirrors
1736
- // readHostVersion() in capability-loader.cts.
1737
- const capHostVersion = () => {
1738
- const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
1739
- try {
1740
- const v = fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim();
1741
- if (SEMVER_PREFIX.test(v)) return v;
1742
- } catch { /* not an installed tree (no gsd-core/VERSION) */ }
1743
- try {
1744
- const pkg = require(path.join(__dirname, '..', '..', 'package.json')); // gsd-core/bin/ -> repo root is two up
1745
- if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version;
1746
- } catch { /* runtime root has no package.json */ }
1747
- return '0.0.0';
1748
- };
1749
- // #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
1750
- // live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
1751
- // written here is the record the loader checks.
1752
- const capConsentHome = () => {
1753
- const osMod = require('node:os');
1754
- return process.env.GSD_HOME || osMod.homedir();
1755
- };
1756
- // #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
1757
- // record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
1758
- // path cannot be realpath'd (e.g. it does not exist yet).
1759
- const capProjectRoot = () => {
1760
- try { return fs.realpathSync(cwd); } catch { return cwd; }
1761
- };
1762
- // UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
1763
- // (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
1764
- // bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
1765
- // reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
1766
- // follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
1767
- // #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
1768
- // half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
1769
- // record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
1770
- // reconcile revokes nothing.
1771
- const capRunReconcile = (runtimeDir, lifecycle, scope) => {
1772
- try {
1773
- const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
1774
- if (report && Array.isArray(report.warnings)) {
1775
- for (const w of report.warnings) {
1776
- try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
1777
- }
1667
+ const getFlag = (flag) => flagMap.get(flag) ?? null;
1668
+
1669
+ const budgetStr = getFlag('--budget');
1670
+ const instructionsFile = getFlag('--instructions-file');
1671
+ const roadmapFile = getFlag('--roadmap-file');
1672
+ const outputPromptFile = getFlag('--output-prompt');
1673
+ const outputMetadataFile = getFlag('--output-metadata');
1674
+ const safetyMarginStr = getFlag('--safety-margin-pct');
1675
+ const projectMdHeadLinesStr = getFlag('--project-md-head-lines');
1676
+ const projectFile = getFlag('--project-file');
1677
+ const contextFile = getFlag('--context-file');
1678
+ const researchFile = getFlag('--research-file');
1679
+ const requirementsFile = getFlag('--requirements-file');
1680
+
1681
+ // ── Validate required args ─────────────────────────────────────────
1682
+ if (!budgetStr) {
1683
+ throw new ExitError(1, 'Error: --budget <N> is required');
1778
1684
  }
1779
- } catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
1780
- };
1781
- if (capSubcommand === 'state') {
1782
- const configDirIdx = args.indexOf('--config-dir');
1783
- let configDir = null;
1784
- if (configDirIdx !== -1) {
1785
- const configDirVal = args[configDirIdx + 1];
1786
- // Validate that --config-dir has a following non-flag value.
1787
- if (!configDirVal || configDirVal.startsWith('--')) {
1788
- error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1685
+ const budget = parseInt(budgetStr, 10);
1686
+ if (!Number.isFinite(budget) || budget <= 0) {
1687
+ throw new ExitError(1, 'Error: --budget must be a positive integer');
1789
1688
  }
1790
- configDir = configDirVal;
1791
- }
1792
- const resolvedConfigDir = configDir ? path.resolve(configDir) : null;
1793
- // --runtime <r> (#2003): explicit runtime override so the config-dir
1794
- // resolution bypasses the persisted-runtime fallback. Dual-form like
1795
- // --config-dir (--runtime X / --runtime=X).
1796
- let stateRuntime = undefined;
1797
- const stateRuntimeEqArg = args.find(arg => arg.startsWith('--runtime='));
1798
- const stateRuntimeIdx = args.indexOf('--runtime');
1799
- if (stateRuntimeEqArg) {
1800
- const value = stateRuntimeEqArg.slice('--runtime='.length).trim();
1801
- if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1802
- stateRuntime = value;
1803
- } else if (stateRuntimeIdx !== -1) {
1804
- const value = args[stateRuntimeIdx + 1];
1805
- if (!value || value.startsWith('--')) {
1806
- error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1689
+ if (!instructionsFile) {
1690
+ throw new ExitError(1, 'Error: --instructions-file <path> is required');
1807
1691
  }
1808
- stateRuntime = value;
1809
- }
1810
- capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, { runtime: stateRuntime });
1811
- } else if (capSubcommand === 'set') {
1812
- // capability set <id> [--on|--off|--enable|--disable] [--gate <key>=<bool>]... [--config-dir <dir>] [--runtime <r>] [--scope <s>]
1813
- const capId = args[2];
1814
- if (!capId || capId.startsWith('--')) {
1815
- error('Missing capability id for: capability set <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1816
- }
1817
- // Parse --config-dir
1818
- const setConfigDirIdx = args.indexOf('--config-dir');
1819
- let setConfigDir = null;
1820
- if (setConfigDirIdx !== -1) {
1821
- const setConfigDirVal = args[setConfigDirIdx + 1];
1822
- if (!setConfigDirVal || setConfigDirVal.startsWith('--')) {
1823
- error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1692
+ if (!roadmapFile) {
1693
+ throw new ExitError(1, 'Error: --roadmap-file <path> is required');
1824
1694
  }
1825
- setConfigDir = setConfigDirVal;
1826
- }
1827
- const resolvedSetConfigDir = setConfigDir ? path.resolve(setConfigDir) : null;
1828
- // Parse --on/--enable and --off/--disable (mutually exclusive)
1829
- const hasOn = args.includes('--on') || args.includes('--enable');
1830
- const hasOff = args.includes('--off') || args.includes('--disable');
1831
- if (hasOn && hasOff) {
1832
- error('Conflicting flags: --on/--enable and --off/--disable cannot both be present', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1833
- }
1834
- let setEnabled;
1835
- if (hasOn) {
1836
- setEnabled = true;
1837
- } else if (hasOff) {
1838
- setEnabled = false;
1839
- }
1840
- // Parse --gate <key>=<bool> (repeatable)
1841
- const setGates = {};
1842
- for (let gi = 0; gi < args.length; gi++) {
1843
- if (args[gi] === '--gate') {
1844
- const gateVal = args[gi + 1];
1845
- if (!gateVal || gateVal.startsWith('--')) {
1846
- error('Missing value for --gate (expected <key>=<true|false>)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1695
+ if (planFiles.length === 0) {
1696
+ throw new ExitError(1, 'Error: at least one --plan-file <path> is required');
1697
+ }
1698
+ if (!outputPromptFile) {
1699
+ throw new ExitError(1, 'Error: --output-prompt <path> is required');
1700
+ }
1701
+ if (!outputMetadataFile) {
1702
+ throw new ExitError(1, 'Error: --output-metadata <path> is required');
1703
+ }
1704
+
1705
+ // ── Validate and read required files ──────────────────────────────
1706
+ async function readRequired(filePath, flagName) {
1707
+ const resolved = path.resolve(filePath);
1708
+ try {
1709
+ return await fs.promises.readFile(resolved, 'utf8');
1710
+ } catch (err) {
1711
+ if (err && err.code === 'ENOENT') {
1712
+ throw new ExitError(1, `Error: file not found for ${flagName}: ${resolved}`);
1713
+ }
1714
+ throw new ExitError(1, `Error: cannot read file for ${flagName}: ${resolved}`);
1847
1715
  }
1848
- const eqIdx = gateVal.indexOf('=');
1849
- if (eqIdx === -1) {
1850
- error(`Malformed --gate value "${gateVal}": expected <key>=<true|false>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1716
+ }
1717
+
1718
+ async function readOptional(filePath) {
1719
+ if (!filePath) return null;
1720
+ const resolved = path.resolve(filePath);
1721
+ try {
1722
+ return await fs.promises.readFile(resolved, 'utf8');
1723
+ } catch (err) {
1724
+ if (err && err.code === 'ENOENT') return null;
1725
+ throw new ExitError(1, `Error: cannot read optional file: ${resolved}`);
1851
1726
  }
1852
- const gateKey = gateVal.slice(0, eqIdx);
1853
- const gateBoolStr = gateVal.slice(eqIdx + 1);
1854
- if (gateBoolStr !== 'true' && gateBoolStr !== 'false') {
1855
- error(`Malformed --gate value "${gateVal}": bool must be true or false`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1727
+ }
1728
+
1729
+ const instructions = await readRequired(instructionsFile, '--instructions-file');
1730
+ const roadmap = await readRequired(roadmapFile, '--roadmap-file');
1731
+ const plans = await Promise.all(planFiles.map(async (p) => {
1732
+ const resolved = path.resolve(p);
1733
+ try {
1734
+ const content = await fs.promises.readFile(resolved, 'utf8');
1735
+ return { file: path.basename(p), content };
1736
+ } catch (err) {
1737
+ if (err && err.code === 'ENOENT') {
1738
+ throw new ExitError(1, `Error: plan file not found: ${resolved}`);
1739
+ }
1740
+ throw new ExitError(1, `Error: cannot read plan file: ${resolved}`);
1856
1741
  }
1857
- setGates[gateKey] = gateBoolStr === 'true';
1858
- gi++; // skip consumed value
1742
+ }));
1743
+
1744
+ const projectMd = await readOptional(projectFile);
1745
+ const context = await readOptional(contextFile);
1746
+ const research = await readOptional(researchFile);
1747
+ const requirements = await readOptional(requirementsFile);
1748
+
1749
+ // ── Build options ─────────────────────────────────────────────────
1750
+ const options = {};
1751
+ if (safetyMarginStr !== null) {
1752
+ const pct = parseInt(safetyMarginStr, 10);
1753
+ if (Number.isFinite(pct)) options.safetyMarginPct = pct;
1859
1754
  }
1860
- }
1861
- // Parse --runtime and --scope (validate that values are present and not flags)
1862
- const runtimeIdx = args.indexOf('--runtime');
1863
- let setRuntime;
1864
- if (runtimeIdx !== -1) {
1865
- const runtimeVal = args[runtimeIdx + 1];
1866
- if (!runtimeVal || runtimeVal.startsWith('--')) {
1867
- error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1755
+ if (projectMdHeadLinesStr !== null) {
1756
+ const lines = parseInt(projectMdHeadLinesStr, 10);
1757
+ if (Number.isFinite(lines)) options.projectMdHeadLines = lines;
1868
1758
  }
1869
- setRuntime = runtimeVal;
1870
- }
1871
- const scopeIdx = args.indexOf('--scope');
1872
- let setScope;
1873
- if (scopeIdx !== -1) {
1874
- const scopeVal = args[scopeIdx + 1];
1875
- if (!scopeVal || scopeVal.startsWith('--')) {
1876
- error('Missing value for --scope', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1759
+
1760
+ // ── Call applyBudget ──────────────────────────────────────────────
1761
+ const sections = { instructions, roadmap, plans, projectMd, context, research, requirements };
1762
+ const { prompt, metadata } = promptBudget.applyBudget({ sections, budget, options });
1763
+
1764
+ // ── Write outputs ─────────────────────────────────────────────────
1765
+ await fs.promises.writeFile(path.resolve(outputMetadataFile), JSON.stringify(metadata, null, 2));
1766
+ await fs.promises.writeFile(path.resolve(outputPromptFile), prompt);
1767
+
1768
+ if (metadata.hardFailed) {
1769
+ throw new ExitError(2);
1877
1770
  }
1878
- setScope = scopeVal;
1879
- }
1880
- capabilityWriter.cmdCapabilitySet(
1881
- cwd,
1882
- resolvedSetConfigDir,
1883
- capId,
1884
- { enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
1885
- raw,
1886
- );
1887
- } else if (capSubcommand === 'install') {
1888
- // capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
1889
- const spec = args[2];
1890
- if (!spec || spec.startsWith('--')) {
1891
- error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1892
- }
1893
- const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
1894
- const lifecycle = require('./lib/capability-lifecycle.cjs');
1895
- const trust = require('./lib/capability-trust.cjs');
1896
- // Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
1897
- // staging, or any shared-config write — so an over-cap install fails fast with a clear count
1898
- // error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
1899
- // depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
1900
- const installSharedFiles = capRepeatedFlag('--shared-file');
1901
- const ledgerModInstall = require('./lib/capability-ledger.cjs');
1902
- if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
1903
- error(
1904
- `capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
1905
- `exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
1906
- ERROR_REASON ? ERROR_REASON.USAGE : undefined,
1907
- );
1908
- }
1909
- capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
1910
- const res = await lifecycle.installCapability(spec, {
1911
- runtimeDir,
1912
- hostVersion: capHostVersion(),
1913
- consentGranted: capHasFlag('--yes'),
1914
- integrity: capFlagValue('--integrity'),
1915
- sharedFiles: installSharedFiles,
1916
- strictKnownRegistries: capReadStrict(),
1917
- // #1459: bind a user consent record for a CONSENTED project install (under the user-owned
1918
- // consent home, NOT in the repo). The lifecycle records nothing for global scope.
1919
- scope,
1920
- consentStoreDir: capConsentHome(),
1921
- });
1922
- if (res.status === 'installed') {
1923
- output({
1924
- status: 'installed',
1925
- id: res.id,
1926
- version: res.version,
1927
- scope,
1928
- disclosure: trust.summarizeDisclosure(res.disclosure || {}),
1929
- }, raw);
1930
- } else if (res.status === 'aborted') {
1931
- // 'aborted' always means "executable surface needs consent" in the lifecycle contract —
1932
- // match it regardless of the requiresConsent flag so a future aborted path can't fall
1933
- // through to the generic "blocked: unknown reason" arm with a misleading message.
1934
- const disclosure = trust.summarizeDisclosure(res.disclosure || {});
1935
- // UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
1936
- // can detect the consent requirement programmatically. We throw ExitError (not error(),
1937
- // which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
1938
- // is flushed before exit; the human-readable guidance still lands on stderr.
1939
- output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
1940
- throw new ExitError(
1941
- 1,
1942
- ['Error: This capability declares executable surfaces and needs your consent before install:']
1943
- .concat(disclosure.map((l) => ' ' + l))
1944
- .concat(['Re-run with --yes to grant consent and install.'])
1945
- .join('\n'),
1946
- );
1947
- } else {
1948
- error(
1949
- `capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
1950
- ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
1951
- );
1952
- }
1953
- } else if (capSubcommand === 'update') {
1954
- // capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
1955
- const all = capHasFlag('--all');
1956
- const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
1957
- if (!all && !id) {
1958
- error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1959
- }
1960
- if (all && id) {
1961
- error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1962
- }
1963
- const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
1964
- const lifecycle = require('./lib/capability-lifecycle.cjs');
1965
- const ledgerMod = require('./lib/capability-ledger.cjs');
1966
- const trust = require('./lib/capability-trust.cjs');
1967
- // Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
1968
- // the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
1969
- // re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
1970
- // a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
1971
- const updateSharedFiles = capRepeatedFlag('--shared-file');
1972
- if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
1973
- error(
1974
- `capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
1975
- `exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
1976
- ERROR_REASON ? ERROR_REASON.USAGE : undefined,
1977
- );
1978
- }
1979
- capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
1980
- // readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError
1981
- // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
1982
- // corrupt-but-present ledger fails closed rather than silently reporting not_installed (<id>)
1983
- // or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
1984
- let ledger;
1985
- try {
1986
- ledger = ledgerMod.readLedgerStrict(runtimeDir);
1987
- } catch (err) {
1988
- error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1989
- }
1990
- const entries = (ledger && ledger.entries) || {};
1991
- const upgradeOne = async (capId) => {
1992
- const entry = entries[capId];
1993
- if (!entry) return { id: capId, status: 'not_installed' };
1994
- // expectedId pins the op to the requested id: a retargeted/edited source that now resolves
1995
- // to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
1996
- const r = await lifecycle.upgradeCapability(entry.source, {
1997
- runtimeDir,
1998
- hostVersion: capHostVersion(),
1999
- consentGranted: capHasFlag('--yes'),
2000
- sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
2001
- strictKnownRegistries: capReadStrict(),
2002
- expectedId: capId,
2003
- // #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
2004
- scope,
2005
- consentStoreDir: capConsentHome(),
2006
- });
2007
- // UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
2008
- // them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
2009
- // stable per-entry shape for `--all` consumers.
2010
- return {
2011
- id: capId,
2012
- status: r.status,
2013
- fromVersion: r.fromVersion ?? null,
2014
- toVersion: r.toVersion ?? null,
2015
- requiresConsent: r.requiresConsent ?? null,
2016
- blockReasons: r.blockReasons ?? null,
2017
- disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
2018
- };
2019
- };
2020
- if (all) {
2021
- // Sequential by design: each upgrade takes the per-scope capability lock; parallel
2022
- // runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
2023
- const results = [];
2024
- for (const capId of Object.keys(entries)) {
2025
- results.push(await upgradeOne(capId));
1771
+ }
1772
+
1773
+ function routeUpdateContext({ args, cwd, raw, error }) {
1774
+ // #498: resolve the installed GSD version, scope, runtime, and config dir
1775
+ // for /gsd:update. Replaces ~280 lines of inline bash in update.md with a
1776
+ // tested projection. Emits the contract as JSON: { installedVersion,
1777
+ // scope, runtime, gsdDir }. Optional --config-dir / --runtime carry the
1778
+ // workflow's execution_context hints (the one thing only it can know).
1779
+ const { loadUpdateContext } = require('./lib/update-context.cjs');
1780
+ const ucArgs = args.slice(1);
1781
+ let preferredConfigDir = '';
1782
+ let preferredRuntime = '';
1783
+ for (let i = 0; i < ucArgs.length; i++) {
1784
+ const a = ucArgs[i];
1785
+ if (a.startsWith('--config-dir=')) { preferredConfigDir = a.slice('--config-dir='.length); continue; }
1786
+ if (a.startsWith('--runtime=')) { preferredRuntime = a.slice('--runtime='.length); continue; }
1787
+ if (a === '--config-dir') {
1788
+ const v = ucArgs[i + 1];
1789
+ if (v === undefined || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
1790
+ preferredConfigDir = v; i++; continue;
1791
+ }
1792
+ if (a === '--runtime') {
1793
+ const v = ucArgs[i + 1];
1794
+ if (v === undefined || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
1795
+ preferredRuntime = v; i++; continue;
1796
+ }
1797
+ if (a === '--json') continue; // JSON is the only output; accepted for symmetry
1798
+ if (a.startsWith('-')) error(`Unknown flag for update-context: ${a}`, ERROR_REASON.USAGE);
2026
1799
  }
2027
- const failed = results.filter((x) => x.status !== 'upgraded');
2028
- if (failed.length > 0) {
2029
- // UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
2030
- // alike), then set a non-zero exit. Previously the results JSON was embedded inside the
2031
- // error STRING on stderr, so automation could not parse a partial-failure run as
2032
- // structured data. We throw ExitError (not error(), which calls process.exit and would
2033
- // bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
2034
- // concise reason still lands on stderr.
2035
- output({ scope, updated: results }, raw);
2036
- throw new ExitError(
2037
- 1,
2038
- `Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
2039
- `(see the JSON result on stdout for per-capability status).`,
2040
- );
1800
+ const ctx = loadUpdateContext({ preferredConfigDir, preferredRuntime });
1801
+ process.stdout.write(JSON.stringify(ctx) + '\n');
1802
+ }
1803
+
1804
+ async function routeClassifyConfidence({ args, cwd, raw, error }) {
1805
+ const researchProvider = require('./lib/research-provider.cjs');
1806
+ const providerIdx = args.indexOf('--provider');
1807
+ const provider = providerIdx !== -1 ? args[providerIdx + 1] : null;
1808
+ if (!provider || provider.startsWith('--')) {
1809
+ error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
2041
1810
  }
2042
- output({ scope, updated: results }, raw);
2043
- } else {
2044
- const r = await upgradeOne(id);
2045
- if (r.status === 'upgraded') {
2046
- output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
2047
- } else if (r.status === 'not_installed') {
2048
- error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2049
- } else if (r.status === 'aborted') {
2050
- // 'aborted' always means "needs consent" (see install) — handle it independently of the
2051
- // requiresConsent flag so it never falls through to the generic blocked arm.
2052
- error(
2053
- [`capability update for "${id}" changes its executable surface and needs your consent:`]
2054
- .concat((r.disclosure || []).map((l) => ' ' + l))
2055
- .concat(['Re-run with --yes to grant consent and update.'])
2056
- .join('\n'),
2057
- ERROR_REASON ? ERROR_REASON.USAGE : undefined,
2058
- );
2059
- } else {
2060
- error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1811
+ const verified = args.includes('--verified');
1812
+ const pkgIdx = args.indexOf('--package');
1813
+ const pkg = pkgIdx !== -1 ? args[pkgIdx + 1] : null;
1814
+ const ecoIdx = args.indexOf('--ecosystem');
1815
+ const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null;
1816
+ let legitimacyVerdict = null;
1817
+ if (pkg && (!pkg.startsWith('--'))) {
1818
+ const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
1819
+ if (!ecosystem || ecosystem.startsWith('--') || !VALID_ECOSYSTEMS.has(ecosystem)) {
1820
+ error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
1821
+ }
1822
+ const pkgLegitimacy = require('./lib/package-legitimacy.cjs');
1823
+ const results = await pkgLegitimacy.checkPackages({ ecosystem, packages: [pkg] }, {});
1824
+ legitimacyVerdict = results[0] ? results[0].verdict : null;
2061
1825
  }
2062
- }
2063
- } else if (capSubcommand === 'remove') {
2064
- // capability remove <id> [--purge-data] [--scope global|project]
2065
- const id = args[2];
2066
- if (!id || id.startsWith('--')) {
2067
- error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2068
- }
2069
- const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
2070
- const lifecycle = require('./lib/capability-lifecycle.cjs');
2071
- const ledgerMod = require('./lib/capability-ledger.cjs');
2072
- capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
2073
- // Ledger first: an installed overlay is removable even if its id shadows a first-party name.
2074
- // Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
2075
- // Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
2076
- // silently reporting "first-party cannot be removed" for any id (finding 7).
2077
- let removeLedger;
2078
- try {
2079
- removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
2080
- } catch (err) {
2081
- error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
2082
- }
2083
- const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
2084
- if (!inLedger) {
2085
- const base = require('./lib/capability-loader.cjs').loadRegistry();
2086
- if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
2087
- error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1826
+ const confidence = researchProvider.classifyConfidence({ provider, verifiedAgainstOfficial: verified, legitimacyVerdict });
1827
+ output({ provider, package: pkg || null, ecosystem: ecosystem || null, legitimacyVerdict, verified, confidence }, raw);
1828
+ }
1829
+
1830
+ async function routePackageLegitimacy({ args, cwd, raw, error }) {
1831
+ const pkgLegitimacy = require('./lib/package-legitimacy.cjs');
1832
+ const subcommand = args[1];
1833
+ if (subcommand !== 'check') {
1834
+ error('Unknown package-legitimacy subcommand. Available: check', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2088
1835
  }
2089
- }
2090
- const res = lifecycle.removeCapability(id, {
2091
- runtimeDir,
2092
- removeData: capHasFlag('--purge-data'),
2093
- // #1459: a project-scope removal revokes the user consent record so a later repo-dropped
2094
- // bundle of the same id cannot silently re-activate against a stale consent.
2095
- scope,
2096
- consentStoreDir: capConsentHome(),
2097
- });
2098
- if (res.status === 'removed') {
2099
- // #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
2100
- // could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
2101
- // record remains. Surface it on stderr + in the JSON so the user knows to clear it.
2102
- if (res.consentRevokeFailed) {
2103
- process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
1836
+ const ecoIdx = args.indexOf('--ecosystem');
1837
+ const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null;
1838
+ const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
1839
+ if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) {
1840
+ error('Usage: gsd-tools package-legitimacy check --ecosystem <npm|pypi|crates> <pkg1> ...', ERROR_REASON.USAGE);
2104
1841
  }
2105
- output({
2106
- status: 'removed',
2107
- id,
2108
- scope,
2109
- removedFiles: res.removedFiles,
2110
- strippedEdits: res.strippedEdits,
2111
- dataPreserved: res.dataPreserved,
2112
- consentRevokeFailed: res.consentRevokeFailed || undefined,
2113
- consentRevokeWarning: res.consentRevokeWarning || undefined,
2114
- }, raw);
2115
- } else if (res.status === 'not_installed') {
2116
- error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2117
- } else {
2118
- error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
2119
- }
2120
- } else if (capSubcommand === 'list') {
2121
- // capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
2122
- // When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
2123
- // corrupt unrelated ledger in another scope does not block a scoped list).
2124
- const loader = require('./lib/capability-loader.cjs');
2125
- const ledgerMod = require('./lib/capability-ledger.cjs');
2126
- const semver = require('./lib/semver-compare.cjs');
2127
- const host = capHostVersion();
2128
- const rows = [];
2129
- const listScopeArg = capFlagValue('--scope');
2130
- // Validate --scope if provided.
2131
- if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
2132
- error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2133
- }
2134
- // First-party capabilities are always included (they have no scope concept).
2135
- const base = loader.loadRegistry();
2136
- const fp = (base && base.capabilities) || {};
2137
- // #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
2138
- // (a bundle whose project ledger looks committed but has no user consent record on THIS
2139
- // machine) is marked status:'inactive' with a reason, instead of silently appearing active.
2140
- // loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
2141
- const inactiveById = {};
2142
- try {
2143
- const composed = loader.loadRegistry({ includeInstalled: true, cwd });
2144
- const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
2145
- for (const w of overlayWarnings) {
2146
- // #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
2147
- // human-readable reason prose (which is free to change without breaking this filter).
2148
- if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
2149
- inactiveById[`${w.scope} ${w.id}`] = w.reason;
1842
+ // Collect positional package names.
1843
+ // Only --ecosystem takes a value. Every non-flag arg is a package name.
1844
+ // Any unknown --flag is a usage error (do not silently skip+consume the next arg).
1845
+ const packages = [];
1846
+ for (let i = 2; i < args.length; i++) {
1847
+ const a = args[i];
1848
+ if (a === '--ecosystem') { i++; continue; }
1849
+ if (a.startsWith('--')) {
1850
+ error(`package-legitimacy: unknown flag ${a}`, ERROR_REASON.USAGE);
2150
1851
  }
1852
+ packages.push(a);
2151
1853
  }
2152
- } catch { /* best-effort — list still works without the inactive annotation */ }
2153
- // Issue #2045 (DEFECT 3): derive each capability's SURFACED state from the
2154
- // SAME resolver `capability state` uses (resolveCapabilityRuntimeState), so
2155
- // `list` and `state` stop disagreeing. `list` previously derived `status`
2156
- // purely from ledger-entry existence — an installed-but-not-surfaced cap
2157
- // reported active in `list` and absent in `state`. Surfaced is evaluated at
2158
- // the default runtime config dir (the resolver resolves it when undefined),
2159
- // matching `capability state <id>` with no --config-dir. Best-effort: a
2160
- // resolver failure leaves surfacedById empty (rows report surfaced:null).
2161
- const surfacedById = {};
2162
- // surfacedById is keyed by capId only (NOT `${scope} ${capId}`): surface
2163
- // state is single-source — one runtime config dir → one .gsd-surface.json
2164
- // → one surfaced truth per capId — and the loader dedupes overlay caps to
2165
- // one registry entry per id (first-party-wins). So a cap installed in both
2166
- // scopes correctly shares one surfaced value across its list rows.
2167
- try {
2168
- const surfaceState = capabilityState.resolveCapabilityRuntimeState(cwd, undefined);
2169
- for (const cap of (surfaceState && surfaceState.capabilities) || []) {
2170
- if (cap && typeof cap.id === 'string') {
2171
- surfacedById[cap.id] = cap.surfaced === true;
2172
- }
1854
+ if (packages.length === 0) {
1855
+ error('Usage: gsd-tools package-legitimacy check --ecosystem <eco> <pkg1> <pkg2> ...', ERROR_REASON.USAGE);
2173
1856
  }
2174
- } catch { /* best-effort — list still works without the surfaced annotation */ }
2175
- for (const capId of Object.keys(fp)) {
2176
- const cap = fp[capId] || {};
2177
- rows.push({
2178
- id: capId,
2179
- role: cap.role || null,
2180
- version: cap.version || null,
2181
- tier: cap.tier || null,
2182
- source: 'first-party',
2183
- scope: 'first-party',
2184
- status: 'active',
2185
- surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null,
2186
- title: cap.title || null,
2187
- });
2188
- }
2189
- // Overlay scopes: honor --scope to read only the requested scope (finding 8).
2190
- const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
2191
- for (const sc of overlayScopes) {
2192
- const { runtimeDir } = capResolveScope(sc);
2193
- // readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
2194
- // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
2195
- // corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
2196
- // dropping overlay entries and returning a first-party-only list (site A fix, #1462).
2197
- let ledger;
1857
+ let pkgResults;
2198
1858
  try {
2199
- ledger = ledgerMod.readLedgerStrict(runtimeDir);
2200
- } catch (err) {
2201
- // UX-3: name the offending scope so the user knows WHICH ledger to fix.
2202
- error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1859
+ pkgResults = await pkgLegitimacy.checkPackages({ ecosystem, packages }, {});
1860
+ } catch (pkgErr) {
1861
+ error(`package-legitimacy: ${pkgErr && pkgErr.message ? pkgErr.message : String(pkgErr)}`, ERROR_REASON.UNKNOWN);
2203
1862
  }
2204
- if (!ledger || !ledger.entries) continue;
2205
- for (const capId of Object.keys(ledger.entries)) {
2206
- const entry = ledger.entries[capId];
2207
- let manifest = {};
2208
- try {
2209
- // #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
2210
- // bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
2211
- // a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
2212
- // an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
2213
- // declarative capability.json. A null (genuinely missing) or a bounded-reader throw
2214
- // (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
2215
- // (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
2216
- const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
2217
- manifest = raw === null ? {} : JSON.parse(raw);
2218
- } catch { manifest = {}; }
2219
- let status = 'active';
2220
- let reason = null;
2221
- const range = manifest.engines && manifest.engines.gsd;
2222
- if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
2223
- // #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
2224
- const inactiveReason = inactiveById[`${sc} ${capId}`];
2225
- if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
2226
- rows.push({
2227
- id: capId,
2228
- role: manifest.role || null,
2229
- version: entry.version || null,
2230
- tier: manifest.tier || null,
2231
- source: entry.source || null,
2232
- scope: sc,
2233
- status,
2234
- reason,
2235
- // Issue #2045 (DEFECT 3): surfaced reflects surface composition, so
2236
- // list and state agree. An inactive (unconsented/incompatible) cap is
2237
- // surfaced:false by definition; otherwise defer to the resolver.
2238
- surfaced: status === 'active'
2239
- ? (Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null)
2240
- : false,
2241
- title: manifest.title || null,
2242
- });
1863
+ output(pkgResults, raw);
1864
+ }
1865
+
1866
+ function routeEffort({ args, cwd, raw, error }) {
1867
+ const subcommand = args[1];
1868
+ if (subcommand === 'sync') {
1869
+ const effortSyncArgs = args.slice(2);
1870
+ let dryRun = true;
1871
+ let effortSyncConfigDir;
1872
+ let effortSyncRuntime;
1873
+ for (let i = 0; i < effortSyncArgs.length; i++) {
1874
+ const a = effortSyncArgs[i];
1875
+ if (a === '--apply') { dryRun = false; continue; }
1876
+ if (a === '--dry-run') { dryRun = true; continue; }
1877
+ if (a.startsWith('--config-dir=')) { effortSyncConfigDir = a.slice('--config-dir='.length); continue; }
1878
+ if (a === '--config-dir') {
1879
+ const v = effortSyncArgs[i + 1];
1880
+ if (!v || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
1881
+ effortSyncConfigDir = v; i++; continue;
1882
+ }
1883
+ if (a.startsWith('--runtime=')) { effortSyncRuntime = a.slice('--runtime='.length); continue; }
1884
+ if (a === '--runtime') {
1885
+ const v = effortSyncArgs[i + 1];
1886
+ if (!v || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
1887
+ effortSyncRuntime = v; i++; continue;
1888
+ }
1889
+ if (a === '--raw') continue;
1890
+ if (a.startsWith('-')) error(`Unknown flag for effort sync: ${a}`, ERROR_REASON.USAGE);
1891
+ error(`effort sync takes no positional arguments; got: ${a}`, ERROR_REASON.USAGE);
1892
+ }
1893
+ commands.cmdEffortSync(cwd, raw, { dryRun, configDir: effortSyncConfigDir, runtime: effortSyncRuntime });
1894
+ } else {
1895
+ error('Unknown effort subcommand. Available: sync', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2243
1896
  }
2244
- }
2245
- output(rows, raw || capHasFlag('--json'));
2246
- } else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
2247
- // capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
2248
- const id = args[2];
2249
- if (!id || id.startsWith('--')) {
2250
- error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2251
- }
2252
- const dCfg = capFlagValue('--config-dir');
2253
- capabilityWriter.cmdCapabilitySet(
2254
- cwd,
2255
- dCfg ? path.resolve(dCfg) : null,
2256
- id,
2257
- { enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
2258
- raw,
2259
- );
2260
- } else if (capSubcommand === 'outdated') {
2261
- // capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
2262
- // For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
2263
- // latest available version and report whether a newer one exists. This never re-clones/re-packs;
2264
- // a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
2265
- const lifecycle = require('./lib/capability-lifecycle.cjs');
2266
- const outdatedScopeArg = capFlagValue('--scope');
2267
- if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
2268
- error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2269
- }
2270
- // Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
2271
- const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
2272
- const records = [];
2273
- for (const sc of outdatedScopes) {
2274
- const { runtimeDir } = capResolveScope(sc);
2275
- // outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
2276
- const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
2277
- for (const r of scRecords) records.push({ ...r, scope: sc });
2278
- }
2279
- const asJson = raw || capHasFlag('--json');
2280
- if (asJson) {
2281
- output(records, false); // machine output: the records array (JSON).
2282
- } else {
2283
- // Human-readable table: ID | Source | Current | Latest | Status.
2284
- const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
2285
- const cell = (v) => (v === null || v === undefined ? '-' : String(v));
2286
- const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
2287
- const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
2288
- const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
2289
- const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
2290
- for (const row of tableRows) lines.push(fmt(row));
2291
- if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
2292
- output(records, true, lines.join('\n') + '\n');
2293
- }
2294
- } else if (capSubcommand === 'trust') {
2295
- // capability trust list [--scope project] [--json]
2296
- // capability trust revoke <id> [--project <path>]
2297
- // The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
2298
- const consentMod = require('./lib/capability-consent.cjs');
2299
- const trustSub = args[2];
2300
- if (trustSub === 'list') {
2301
- // --scope is accepted for symmetry; only 'project' records exist today.
2302
- const listScope = capFlagValue('--scope');
2303
- if (listScope && listScope !== 'project') {
2304
- error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1897
+ }
1898
+
1899
+ function routeUserStory({ args, cwd, raw, error }) {
1900
+ const subcommand = args[1];
1901
+ if (subcommand !== 'validate') {
1902
+ error(`Unknown user-story subcommand: ${subcommand || '(none)'}. Available: validate`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1903
+ return;
2305
1904
  }
2306
- const store = consentMod.readConsentStore(capConsentHome());
2307
- const rows = Object.keys(store.records).map((k) => {
2308
- const r = store.records[k];
2309
- // #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
2310
- // binding against the current bundle (e.g. `gsd capability list` showing inactive after a
2311
- // tamper) and understand why a consented cap deactivated. The contentHash is THE security
2312
- // binding the loader checks; disclosureSignature is the executable-surface re-consent key.
2313
- return {
2314
- id: r.id, scope: r.scope, projectRoot: r.projectRoot,
2315
- integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
2316
- consentedAt: r.consentedAt,
2317
- };
2318
- });
2319
- output(rows, raw || capHasFlag('--json'));
2320
- } else if (trustSub === 'revoke') {
2321
- const id = args[3];
2322
- if (!id || id.startsWith('--')) {
2323
- error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1905
+
1906
+ const storyIdx = args.indexOf('--story');
1907
+ const story = (storyIdx !== -1 && args[storyIdx + 1] && !args[storyIdx + 1].startsWith('--'))
1908
+ ? args[storyIdx + 1]
1909
+ : '';
1910
+
1911
+ // Canonical extraction regex — requires non-whitespace content in each slot
1912
+ // (\S.*? ensures the slot isn't whitespace-only).
1913
+ // Named groups: role / capability / outcome.
1914
+ const USER_STORY_RE = /^As a (\S.*?), I want to (\S.*?), so that (\S.*?)\.$/;
1915
+
1916
+ const errors = [];
1917
+ const trimmed = story.trim();
1918
+ let slots = null;
1919
+
1920
+ if (!trimmed) {
1921
+ errors.push('Story is empty. Required format: "As a [role], I want to [capability], so that [outcome]."');
1922
+ } else {
1923
+ // Per-clause guards produce targeted, actionable error messages before
1924
+ // attempting the full regex. Guards are ordered: role → capability → outcome → period.
1925
+ if (!/^As a \S/i.test(trimmed)) {
1926
+ errors.push('Story must start with "As a [user role]," (role must be non-empty).');
1927
+ }
1928
+ if (!/, I want to \S/i.test(trimmed)) {
1929
+ errors.push('Story must include ", I want to [capability]," (capability must be non-empty).');
1930
+ }
1931
+ if (!/, so that \S/i.test(trimmed)) {
1932
+ errors.push('Story must include ", so that [outcome]." (outcome must be non-empty).');
1933
+ }
1934
+ if (!trimmed.endsWith('.')) {
1935
+ errors.push('Story must end with a period (.).');
1936
+ }
1937
+ // Full-regex check only when per-clause guards all passed — avoids
1938
+ // redundant "format mismatch" noise on top of specific error messages.
1939
+ if (errors.length === 0) {
1940
+ const m = USER_STORY_RE.exec(trimmed);
1941
+ if (!m) {
1942
+ errors.push('Story does not match the canonical format: "As a [role], I want to [capability], so that [outcome]."');
1943
+ } else {
1944
+ slots = { role: m[1], capability: m[2], outcome: m[3] };
1945
+ }
1946
+ }
2324
1947
  }
2325
- // --project pins the project root whose consent is revoked; defaults to realpath(cwd).
2326
- const projFlag = capFlagValue('--project');
2327
- let projectRoot;
2328
- try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
2329
- catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
2330
- // #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
2331
- // (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
2332
- // error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
2333
- // consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
1948
+
1949
+ output({ valid: errors.length === 0, errors, slots }, raw);
1950
+ }
1951
+
1952
+ function routeDriftGuard({ args, cwd, raw, error }) {
1953
+ // ADR-22: deterministic authority resolution + severity classification.
1954
+ // Subcommands:
1955
+ // drift-guard authority → effective authority string
1956
+ // drift-guard severity --status <S> [--authority <A>] → {severity, hardBlock}
1957
+ const subcommand = args[1];
1958
+
1959
+ // Read config.json directly for both plan_review.source_grounding_authority
1960
+ // and intel.enabled. Neither key is in the config-loader.cjs whitelist that
1961
+ // config-loader.cjs's loadConfig() whitelist does not return; plan_review is only in config.cjs's private
1962
+ // buildConfig(), and intel is a federated capability config key.
1963
+ let configuredAuthority = 'grep';
1964
+ let intelEnabled = false;
2334
1965
  try {
2335
- consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
2336
- } catch (err) {
2337
- error(
2338
- `capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
2339
- `(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
2340
- ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
2341
- );
1966
+ const { planningDir } = require('./lib/planning-workspace.cjs');
1967
+ const cfgPath = require('path').join(planningDir(cwd), 'config.json');
1968
+ if (require('fs').existsSync(cfgPath)) {
1969
+ const rawCfg = JSON.parse(require('fs').readFileSync(cfgPath, 'utf-8'));
1970
+ if (rawCfg && rawCfg.plan_review && rawCfg.plan_review.source_grounding_authority) {
1971
+ configuredAuthority = String(rawCfg.plan_review.source_grounding_authority);
1972
+ }
1973
+ if (rawCfg && rawCfg.intel && rawCfg.intel.enabled === true) {
1974
+ intelEnabled = true;
1975
+ }
1976
+ }
1977
+ } catch {
1978
+ // not fatal — defaults apply
2342
1979
  }
2343
- output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
2344
- } else {
2345
- error(
2346
- `Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
2347
- ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
2348
- );
2349
- }
2350
- } else {
2351
- error(
2352
- `Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
2353
- ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
2354
- );
2355
- }
2356
- break;
2357
- }
2358
1980
 
2359
- case 'phase-plan-index': {
2360
- phase.cmdPhasePlanIndex(cwd, args[1], raw);
2361
- break;
2362
- }
1981
+ const effectiveAuthority = getEffectiveAuthority(configuredAuthority, intelEnabled);
2363
1982
 
2364
- case 'state-snapshot': {
2365
- state.cmdStateSnapshot(cwd, raw);
2366
- break;
2367
- }
1983
+ if (subcommand === 'authority') {
1984
+ // Pass rawValue as 3rd arg so --raw returns unquoted string (not JSON)
1985
+ output(effectiveAuthority, raw, effectiveAuthority);
1986
+ return;
1987
+ }
2368
1988
 
2369
- case 'summary-extract': {
2370
- const summaryPath = args[1];
2371
- const fieldsIndex = args.indexOf('--fields');
2372
- const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
2373
- commands.cmdSummaryExtract(cwd, summaryPath, fields, raw);
2374
- break;
2375
- }
1989
+ if (subcommand === 'severity') {
1990
+ const statusIdx = args.indexOf('--status');
1991
+ const statusVal = statusIdx !== -1 ? args[statusIdx + 1] : undefined;
1992
+ if (!statusVal || statusVal.startsWith('--')) {
1993
+ error('drift-guard severity requires --status <VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE>', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1994
+ return;
1995
+ }
1996
+ const authIdx = args.indexOf('--authority');
1997
+ const authVal = authIdx !== -1 ? args[authIdx + 1] : undefined;
1998
+ const authorityForClassify = (authVal && !authVal.startsWith('--'))
1999
+ ? authVal
2000
+ : effectiveAuthority;
2001
+ const result = classifyDriftSeverity({ status: statusVal, authority: authorityForClassify });
2002
+ output(result, raw);
2003
+ return;
2004
+ }
2376
2005
 
2377
- case 'websearch': {
2378
- const query = args[1];
2379
- const limitIdx = args.indexOf('--limit');
2380
- const freshnessIdx = args.indexOf('--freshness');
2381
- await commands.cmdWebsearch(query, {
2382
- limit: limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10,
2383
- freshness: freshnessIdx !== -1 ? args[freshnessIdx + 1] : null,
2384
- }, raw);
2385
- break;
2386
- }
2006
+ error(
2007
+ `Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity`,
2008
+ ERROR_REASON.SDK_UNKNOWN_COMMAND,
2009
+ );
2010
+ }
2387
2011
 
2388
- case 'workstream': {
2389
- const subcommand = args[1];
2390
- if (subcommand === 'create') {
2391
- const migrateNameIdx = args.indexOf('--migrate-name');
2392
- const noMigrate = args.includes('--no-migrate');
2393
- workstream.cmdWorkstreamCreate(cwd, args[2], {
2394
- migrate: !noMigrate,
2395
- migrateName: migrateNameIdx !== -1 ? args[migrateNameIdx + 1] : null,
2396
- }, raw);
2397
- } else if (subcommand === 'list') {
2398
- workstream.cmdWorkstreamList(cwd, raw);
2399
- } else if (subcommand === 'status') {
2400
- workstream.cmdWorkstreamStatus(cwd, args[2], raw);
2401
- } else if (subcommand === 'complete') {
2402
- workstream.cmdWorkstreamComplete(cwd, args[2], {}, raw);
2403
- } else if (subcommand === 'set') {
2404
- workstream.cmdWorkstreamSet(cwd, args[2], raw);
2405
- } else if (subcommand === 'get') {
2406
- workstream.cmdWorkstreamGet(cwd, raw);
2407
- } else if (subcommand === 'progress') {
2408
- workstream.cmdWorkstreamProgress(cwd, raw);
2409
- } else {
2410
- error('Unknown workstream subcommand. Available: create, list, status, complete, set, get, progress', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2411
- }
2412
- break;
2413
- }
2414
2012
 
2415
- case 'worktree': {
2416
- const subcommand = args[1];
2417
- const worktreeSafety = require('./lib/worktree-safety.cjs');
2418
- if (subcommand === 'cleanup-wave') {
2419
- worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2));
2420
- } else if (subcommand === 'record-agent') {
2421
- worktreeSafety.cmdWorktreeRecordAgent(cwd, args.slice(2));
2422
- } else if (subcommand === 'reap-orphans') {
2423
- worktreeSafety.cmdWorktreeReapOrphans(cwd);
2424
- } else if (subcommand === 'base-check') {
2425
- require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
2426
- } else if (subcommand === 'set-baseref') {
2427
- require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
2428
- } else {
2429
- error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2430
- }
2431
- break;
2432
- }
2013
+ const HOST_COMMAND_ROUTERS = {
2014
+ // Each entry wraps its `route*Command` router so it receives the module-scope
2015
+ // lib the old `case` arm passed, plus the per-dispatch context
2016
+ // { args, cwd, raw, error }. Closes over module-scope libs (state/phase/…)
2017
+ // exactly as the old inline arms did — byte-identical dispatch.
2018
+ state: (ctx) => routeStateCommand({ state, ...ctx }),
2019
+ phase: (ctx) => routePhaseCommand({ phase, ...ctx }),
2020
+ roadmap: (ctx) => routeRoadmapCommand({ roadmap, ...ctx }),
2021
+ verify: (ctx) => routeVerifyCommand({ verify, ...ctx }),
2022
+ // validate additionally binds the module-scope `output` emitter.
2023
+ validate: (ctx) => routeValidateCommand({ verify, output, ...ctx }),
2024
+ // init preserves the #1688 stale-bake warning (best-effort, swallowed) that
2025
+ // ran before the router in the old `case 'init':` arm.
2026
+ init: (ctx) => {
2027
+ try { warnIfStaleBake(ctx.cwd); } catch { /* guard must never break init */ }
2028
+ routeInitCommand({ init, ...ctx });
2029
+ },
2030
+ // capability → routeCapabilityCommand (ADR-2346 P2). The router is async
2031
+ // (install/upgrade/consent ops await the lifecycle); dispatchHostCommand
2032
+ // awaits it. The router imports its own io/cli-exit/deps, so no module
2033
+ // injection needed — it receives {args,cwd,raw} (+error, ignored).
2034
+ capability: routeCapabilityCommand,
2035
+ // ADR-2346 P3: resolve/git/config/research host routers. Each body was
2036
+ // relocated verbatim from its `case` arm to a module-scope function above.
2037
+ 'resolve-model': routeResolveModel,
2038
+ 'resolve-granularity': routeResolveGranularity,
2039
+ 'resolve-execution': routeResolveExecution,
2040
+ git: routeGit,
2041
+ 'config-ensure-section': routeConfigEnsureSection,
2042
+ 'config-set': routeConfigSet,
2043
+ 'config-set-model-profile': routeConfigSetModelProfile,
2044
+ 'config-get': routeConfigGet,
2045
+ 'config-new-project': routeConfigNewProject,
2046
+ 'config-path': routeConfigPath,
2047
+ 'migrate-config': routeMigrateConfig,
2048
+ 'research-store': routeResearchStore,
2049
+ 'research-plan': routeResearchPlan,
2050
+ // ADR-2346 P4: all remaining leaf commands
2051
+ 'agent': routeAgent,
2052
+ 'smart-entry': routeSmartEntry,
2053
+ 'check': routeCheck,
2054
+ 'find-phase': routeFindPhase,
2055
+ 'commit': routeCommit,
2056
+ 'check-commit': routeCheckCommit,
2057
+ 'commit-to-subrepo': routeCommitToSubrepo,
2058
+ 'pr-subrepo': routePrSubrepo,
2059
+ 'verify-summary': routeVerifySummary,
2060
+ 'template': routeTemplate,
2061
+ 'task': routeTask,
2062
+ 'frontmatter': routeFrontmatter,
2063
+ 'eval': routeEval,
2064
+ 'verification': routeVerification,
2065
+ 'generate-slug': routeGenerateSlug,
2066
+ 'current-timestamp': routeCurrentTimestamp,
2067
+ 'project-instruction-file': routeProjectInstructionFile,
2068
+ 'list-todos': routeListTodos,
2069
+ 'list-seeds': routeListSeeds,
2070
+ 'verify-path-exists': routeVerifyPathExists,
2071
+ 'quick-tasks-append': routeQuickTasksAppend,
2072
+ 'normalize-test-command': routeNormalizeTestCommand,
2073
+ 'dispatch-should-flatten': routeDispatchShouldFlatten,
2074
+ 'agent-skills': routeAgentSkills,
2075
+ 'skill-manifest': routeSkillManifest,
2076
+ 'history-digest': routeHistoryDigest,
2077
+ 'phases': routePhases,
2078
+ 'assumption-delta': routeAssumptionDelta,
2079
+ 'requirements': routeRequirements,
2080
+ 'gap-analysis': routeGapAnalysis,
2081
+ 'milestone': routeMilestone,
2082
+ 'progress': routeProgress,
2083
+ 'uat': routeUat,
2084
+ 'stats': routeStats,
2085
+ 'todo': routeTodo,
2086
+ 'scaffold': routeScaffold,
2087
+ 'loop': routeLoop,
2088
+ 'phase-plan-index': routePhasePlanIndex,
2089
+ 'state-snapshot': routeStateSnapshot,
2090
+ 'summary-extract': routeSummaryExtract,
2091
+ 'websearch': routeWebsearch,
2092
+ 'workstream': routeWorkstream,
2093
+ 'worktree': routeWorktree,
2094
+ 'docs-init': routeDocsInit,
2095
+ 'learnings': routeLearnings,
2096
+ 'teams-status': routeTeamsStatus,
2097
+ 'detect-custom-files': routeDetectCustomFiles,
2098
+ 'from-gsd2': routeFromGsd2,
2099
+ 'prompt-budget': routePromptBudget,
2100
+ 'update-context': routeUpdateContext,
2101
+ 'classify-confidence': routeClassifyConfidence,
2102
+ 'package-legitimacy': routePackageLegitimacy,
2103
+ 'effort': routeEffort,
2104
+ 'user-story': routeUserStory,
2105
+ 'drift-guard': routeDriftGuard,
2106
+ 'windows': routeWindows,
2107
+ };
2108
+
2109
+ // Returns true when consumed (suppress "Unknown command"), false to fall
2110
+ // through. Prototype-pollution-safe: own-property lookup rejects
2111
+ // `__proto__`/`constructor`/`prototype` command keys (same guard as
2112
+ // dispatchCapabilityCommand).
2113
+ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext }) {
2114
+ if (
2115
+ command === '__proto__' ||
2116
+ command === 'constructor' ||
2117
+ command === 'prototype'
2118
+ ) {
2119
+ return false;
2120
+ }
2121
+ if (!Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, command)) {
2122
+ return false;
2123
+ }
2124
+ const router = HOST_COMMAND_ROUTERS[command];
2125
+ if (typeof router !== 'function') return false;
2126
+ // `await` so async host routers (e.g. capability's install/upgrade ops)
2127
+ // complete before runCommand returns; sync routers pass through unchanged.
2128
+ await router({ args, cwd, raw, error, defaultValue, workstreamContext });
2129
+ return true; // consumed — don't emit "Unknown command"
2130
+ }
2433
2131
 
2434
- // ─── Documentation ────────────────────────────────────────────────────
2435
-
2436
- case 'docs-init': {
2437
- // Phase 6 (#3575): dispatch via SDK executeForCjs when available.
2438
- // SDK handler: docsInit in sdk/src/query/docs-init.ts.
2439
- const handled = _dispatchNonFamily({
2440
- registryCommand: 'docs-init',
2441
- registryArgs: args.slice(1),
2442
- legacyCommand: 'docs-init',
2443
- legacyArgs: args.slice(1),
2444
- cwd,
2445
- raw,
2446
- error,
2447
- output: output,
2448
- });
2449
- if (!handled) docs.cmdDocsInit(cwd, raw);
2450
- break;
2451
- }
2132
+ // ─── Arg parsing helpers ──────────────────────────────────────────────────────
2452
2133
 
2453
- // ─── Learnings ─────────────────────────────────────────────────────────
2454
-
2455
- case 'learnings': {
2456
- const subcommand = args[1];
2457
- if (subcommand === 'list') {
2458
- learnings.cmdLearningsList(raw);
2459
- } else if (subcommand === 'query') {
2460
- const tagIdx = args.indexOf('--tag');
2461
- const tag = tagIdx !== -1 ? args[tagIdx + 1] : null;
2462
- if (!tag) error('Usage: gsd-tools learnings query --tag <tag>', ERROR_REASON.USAGE);
2463
- learnings.cmdLearningsQuery(tag, raw);
2464
- } else if (subcommand === 'copy') {
2465
- learnings.cmdLearningsCopy(cwd, raw);
2466
- } else if (subcommand === 'prune') {
2467
- const olderIdx = args.indexOf('--older-than');
2468
- const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
2469
- if (!olderThan) error('Usage: gsd-tools learnings prune --older-than <duration>', ERROR_REASON.USAGE);
2470
- learnings.cmdLearningsPrune(olderThan, raw);
2471
- } else if (subcommand === 'delete') {
2472
- const id = args[2];
2473
- if (!id) error('Usage: gsd-tools learnings delete <id>', ERROR_REASON.USAGE);
2474
- learnings.cmdLearningsDelete(id, raw);
2475
- } else {
2476
- error('Unknown learnings subcommand. Available: list, query, copy, prune, delete', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2477
- }
2478
- break;
2479
- }
2134
+ // ─── run-with-timeout (#2351) ─────────────────────────────────────────────────
2135
+ // Portable, coreutils-independent wall-clock cap for a spawned command. Replaces
2136
+ // the GNU-only `timeout <n> …` calls that were hardcoded across gsd
2137
+ // workflow/agent files: stock macOS ships neither `timeout` nor `gtimeout`, so
2138
+ // those calls exited 127 ("command not found") and a passing build/test was
2139
+ // misreported as a FAILURE. The resolution lives here ONCE — every call site
2140
+ // invokes `gsd_run run-with-timeout <secs> [--] <cmd> [args…]` instead of
2141
+ // hand-rolling a `command -v timeout` probe per file.
2142
+ //
2143
+ // Exit-code contract (kept identical to GNU `timeout` so the existing per-site
2144
+ // dispatch — `-eq 124` for timeout, `-eq 0` for pass, non-zero for fail — is
2145
+ // unchanged):
2146
+ // • command exits normally → exit with the command's own code
2147
+ // • wall-clock budget exceeded → exit 124
2148
+ // • command killed by a signal → exit 128+signum
2149
+ // • command not found / not exec → exit 127 / 126 (spawn ENOENT / EACCES)
2150
+ // • bad wrapper args → exit 2 (usage — a workflow-authoring bug)
2151
+ // • <secs> == 0 → run with NO timer (matches `timeout 0`)
2152
+ // • blank / negative / NaN <secs> → exit 2 (usage — fails SAFE, never unbounded)
2153
+ //
2154
+ // The wrapped command's argv is OPAQUE: this executes BEFORE gsd-tools' own
2155
+ // global-flag parsing (see main()), so a wrapped `--raw`/`--cwd`/`--pick` passes
2156
+ // through verbatim rather than being consumed by this dispatcher. stdio is
2157
+ // inherited so shell pipes (`echo x | gsd_run run-with-timeout …`) and redirects
2158
+ // keep working. No shell is spawned (argv array) — no injection surface beyond
2159
+ // the old `timeout … bash -c "$CMD"`.
2160
+ function runWithTimeout(argv) {
2161
+ const { spawn } = require('node:child_process');
2162
+ const os = require('node:os');
2163
+
2164
+ const USAGE = 'Usage: gsd_run run-with-timeout <seconds> [--] <command> [args...]';
2165
+ const usageError = (msg) => new ExitError(2, `run-with-timeout: ${msg}\n${USAGE}`);
2166
+
2167
+ const rawSecs = argv[0];
2168
+ if (rawSecs === undefined) throw usageError('missing <seconds>');
2169
+ // Accept a bare number or a GNU-style trailing `s` unit (the only unit callers
2170
+ // use). A blank/whitespace value is a USAGE ERROR — never a silent "no timer",
2171
+ // which would drop the wall-clock bound if a config value ever resolved to "".
2172
+ const secsText = String(rawSecs).trim().replace(/s$/, '');
2173
+ const secs = Number(secsText);
2174
+ if (secsText === '' || !Number.isFinite(secs) || secs < 0) {
2175
+ throw usageError(`invalid <seconds>: ${rawSecs}`);
2176
+ }
2480
2177
 
2481
- // ─── teams-status ──────────────────────────────────────────────────────
2482
- // Read-only detector for claude-code's experimental agent-teams feature.
2483
- // issue #1355: stop gsd-core hanging silently under claude-code agent-teams.
2484
- // No capability registration needed — this is a diagnostic query command,
2485
- // not a feature capability.
2486
- case 'teams-status': {
2487
- const teamsStatus = require('./lib/teams-status.cjs');
2488
- teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
2489
- break;
2178
+ let i = 1;
2179
+ if (argv[i] === '--') i += 1; // optional POSIX end-of-options separator
2180
+ const cmd = argv[i];
2181
+ if (cmd === undefined) throw usageError('missing <command>');
2182
+ const cmdArgs = argv.slice(i + 1);
2183
+
2184
+ const isWin = process.platform === 'win32';
2185
+ // Detached (own process group) on POSIX so a timeout can reap the WHOLE tree —
2186
+ // a bare child.kill() misses grandchildren (e.g. a test runner's workers) and
2187
+ // would not actually bound the wall clock. Windows has no POSIX process
2188
+ // groups; a direct kill is the best portable option there.
2189
+ const detached = !isWin && secs > 0;
2190
+ const spawnFailureCode = (err) =>
2191
+ (err && err.code === 'ENOENT' ? 127 : err && err.code === 'EACCES' ? 126 : 125);
2192
+ // Node's setTimeout delay is a 32-bit signed ms int; a larger value silently
2193
+ // clamps to 1ms → a spurious immediate timeout. Cap the budget (~24.8 days).
2194
+ const timerMs = Math.min(Math.round(secs * 1000), 2 ** 31 - 1);
2195
+
2196
+ // Resolve with the numeric exit code — never process.exit() (banned by
2197
+ // n/no-process-exit). main() returns this code and runMain() maps it to
2198
+ // process.exitCode, so stdout/stderr flush and cleanup hooks still fire.
2199
+ return new Promise((resolve) => {
2200
+ let child;
2201
+ try {
2202
+ child = spawn(cmd, cmdArgs, { stdio: 'inherit', detached });
2203
+ } catch (err) {
2204
+ process.stderr.write(`run-with-timeout: ${cmd}: ${err && err.message ? err.message : 'failed to start'}\n`);
2205
+ resolve(spawnFailureCode(err));
2206
+ return;
2490
2207
  }
2491
2208
 
2492
- // ─── detect-custom-files ───────────────────────────────────────────────
2493
- // CJS-native: no SDK counterpart exists in the command registry.
2494
- // detect-custom-files reads a gsd-file-manifest.json against the
2495
- // live filesystem to identify user-added files. It is installer-specific
2496
- // logic that has no async query equivalent in the SDK.
2497
- //
2498
- // Detect user-added files inside GSD-managed directories that are not
2499
- // tracked in gsd-file-manifest.json. Used by the update workflow to back
2500
- // up custom files before the installer wipes those directories.
2501
- //
2502
- // This replaces the fragile bash pattern:
2503
- // MANIFEST_FILES=$(node -e "require('$RUNTIME_DIR/...')" 2>/dev/null)
2504
- // ${filepath#$RUNTIME_DIR/} # unreliable path stripping
2505
- // which silently returns CUSTOM_COUNT=0 when $RUNTIME_DIR is unset or
2506
- // when the stripped path does not match the manifest key format (#1997).
2507
-
2508
- case 'detect-custom-files': {
2509
- const configDirIdx = args.indexOf('--config-dir');
2510
- const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
2511
- if (!configDir) {
2512
- error('Usage: gsd-tools detect-custom-files --config-dir <path>', ERROR_REASON.USAGE);
2513
- }
2514
- const resolvedConfigDir = path.resolve(configDir);
2515
- if (!fs.existsSync(resolvedConfigDir)) {
2516
- error(`Config directory not found: ${resolvedConfigDir}`, ERROR_REASON.USAGE);
2517
- }
2518
-
2519
- const manifestPath = path.join(resolvedConfigDir, 'gsd-file-manifest.json');
2520
- if (!fs.existsSync(manifestPath)) {
2521
- // No manifest — cannot determine what is custom. Return empty list
2522
- // (same behaviour as saveLocalPatches in install.js when no manifest).
2523
- const out = { custom_files: [], custom_count: 0, manifest_found: false };
2524
- process.stdout.write(JSON.stringify(out, null, 2));
2525
- break;
2526
- }
2527
-
2528
- let manifest;
2209
+ const killTree = (signal) => {
2529
2210
  try {
2530
- manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
2531
- } catch {
2532
- const out = { custom_files: [], custom_count: 0, manifest_found: false, error: 'manifest parse error' };
2533
- process.stdout.write(JSON.stringify(out, null, 2));
2534
- break;
2535
- }
2536
-
2537
- const manifestKeys = new Set(Object.keys(manifest.files || {}));
2538
-
2539
- // GSD-managed directories to scan for user-added files. Whole-owned
2540
- // roots are wiped recursively; shared runtime roots are pruned by the
2541
- // same gsd-* top-level prefix used by install.js _removeGsdEntries.
2542
- const GSD_WHOLE_MANAGED_DIRS = [
2543
- 'gsd-core',
2544
- path.join('commands', 'gsd'),
2545
- ];
2546
- const GSD_PREFIX_MANAGED_DIRS = [
2547
- 'agents',
2548
- 'hooks',
2549
- 'skills',
2550
- ];
2551
-
2552
- function collectCustomFiles(dir, baseDir, manifestKeys, out) {
2553
- if (!fs.existsSync(dir)) return;
2554
- const stat = fs.statSync(dir);
2555
- if (stat.isFile()) {
2556
- const relPath = path.relative(baseDir, dir).replace(/\\/g, '/');
2557
- if (!manifestKeys.has(relPath)) {
2558
- out.push(relPath);
2559
- }
2560
- return;
2561
- }
2562
- if (!stat.isDirectory()) return;
2563
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2564
- const fullPath = path.join(dir, entry.name);
2565
- if (entry.isDirectory()) {
2566
- collectCustomFiles(fullPath, baseDir, manifestKeys, out);
2567
- continue;
2568
- }
2569
- // Use forward slashes for cross-platform manifest key compatibility
2570
- const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
2571
- if (!manifestKeys.has(relPath)) {
2572
- out.push(relPath);
2573
- }
2574
- }
2575
- }
2211
+ if (detached && child.pid) {
2212
+ try { process.kill(-child.pid, signal); return; } catch { /* group already gone */ }
2213
+ }
2214
+ child.kill(signal);
2215
+ } catch { /* already exited */ }
2216
+ };
2217
+
2218
+ let timedOut = false;
2219
+ let killTimer = null;
2220
+ // Backstop SIGKILL for a descendant that traps SIGTERM. The child keeps the
2221
+ // event loop alive until this fires, so it stays ref'd (not unref'd).
2222
+ const armEscalation = () => {
2223
+ if (!killTimer) killTimer = setTimeout(() => killTree('SIGKILL'), 3000);
2224
+ };
2225
+
2226
+ const timer = secs > 0
2227
+ ? setTimeout(() => { timedOut = true; killTree('SIGTERM'); armEscalation(); }, timerMs)
2228
+ : null;
2229
+
2230
+ // Forward an interrupt to the child tree rather than dying and orphaning it
2231
+ // (GNU `timeout` forwards received signals). Without this, SIGINT/SIGTERM to
2232
+ // the wrapper — Ctrl-C, CI cancellation — would leave the detached child
2233
+ // running unbounded with no supervisor left to enforce the cap.
2234
+ const onSignal = (sig) => { killTree(sig); armEscalation(); };
2235
+ const onSigint = () => onSignal('SIGINT');
2236
+ const onSigterm = () => onSignal('SIGTERM');
2237
+ process.on('SIGINT', onSigint);
2238
+ process.on('SIGTERM', onSigterm);
2239
+
2240
+ const finish = (exitCode) => {
2241
+ if (timer) clearTimeout(timer);
2242
+ if (killTimer) clearTimeout(killTimer);
2243
+ process.removeListener('SIGINT', onSigint);
2244
+ process.removeListener('SIGTERM', onSigterm);
2245
+ resolve(exitCode);
2246
+ };
2247
+
2248
+ child.on('error', (err) => {
2249
+ process.stderr.write(`run-with-timeout: ${cmd}: ${err && err.message ? err.message : 'failed to start'}\n`);
2250
+ finish(spawnFailureCode(err));
2251
+ });
2576
2252
 
2577
- const customFiles = [];
2578
- for (const managedDir of GSD_WHOLE_MANAGED_DIRS) {
2579
- const absDir = path.join(resolvedConfigDir, managedDir);
2580
- if (!fs.existsSync(absDir)) continue;
2581
- collectCustomFiles(absDir, resolvedConfigDir, manifestKeys, customFiles);
2253
+ child.on('exit', (code, signal) => {
2254
+ if (timedOut) {
2255
+ // The direct child exited on our SIGTERM, but a SIGTERM-trapping descendant
2256
+ // may still hold the inherited stdio — orphaning it would hang a captured
2257
+ // or piped gate. Reap the whole group SYNCHRONOUSLY here; the escalation
2258
+ // timer can't fire once we resolve and the loop drains.
2259
+ killTree('SIGKILL');
2260
+ finish(124); // matches GNU `timeout`
2261
+ return;
2582
2262
  }
2583
- for (const managedDir of GSD_PREFIX_MANAGED_DIRS) {
2584
- const absDir = path.join(resolvedConfigDir, managedDir);
2585
- if (!fs.existsSync(absDir)) continue;
2586
- for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
2587
- if (!entry.name.startsWith('gsd-')) continue;
2588
- collectCustomFiles(path.join(absDir, entry.name), resolvedConfigDir, manifestKeys, customFiles);
2589
- }
2263
+ if (signal) {
2264
+ const num = os.constants.signals[signal] || 0;
2265
+ finish(num ? 128 + num : 1); // bash's 128+signum convention
2266
+ return;
2590
2267
  }
2268
+ finish(code == null ? 1 : code);
2269
+ });
2270
+ });
2271
+ }
2591
2272
 
2592
- const out = {
2593
- custom_files: customFiles,
2594
- custom_count: customFiles.length,
2595
- manifest_found: true,
2596
- manifest_version: manifest.version || null,
2597
- };
2598
- process.stdout.write(JSON.stringify(out, null, 2));
2599
- break;
2600
- }
2273
+ // ─── CLI Router ───────────────────────────────────────────────────────────────
2601
2274
 
2602
- // ─── GSD-2 Reverse Migration ───────────────────────────────────────────
2275
+ async function main() {
2276
+ let args = process.argv.slice(2);
2603
2277
 
2604
- case 'from-gsd2': {
2605
- const gsd2Import = require('./lib/gsd2-import.cjs');
2606
- gsd2Import.cmdFromGsd2(args.slice(1), cwd, raw);
2607
- break;
2278
+ // #2351: run-with-timeout bounds a spawned command's wall clock portably
2279
+ // (coreutils-independent). It MUST intercept HERE, before the global-flag
2280
+ // parsing below — the wrapped command's argv is opaque and may itself contain
2281
+ // --raw / --cwd / --pick that this dispatcher would otherwise consume.
2282
+ {
2283
+ let rwt = args;
2284
+ if (rwt[0] === 'query') rwt = rwt.slice(1);
2285
+ if (rwt[0] === 'run-with-timeout') {
2286
+ // Return the child's exit code; runMain() maps it to process.exitCode.
2287
+ return runWithTimeout(rwt.slice(1));
2608
2288
  }
2289
+ }
2609
2290
 
2610
- // ─── Prompt Budget ────────────────────────────────────────────────────
2611
- //
2612
- // Assemble and deterministically trim review prompt sections to fit a
2613
- // token budget. Used by the /gsd-review workflow before dispatching to
2614
- // small-context local model servers (Ollama, llama.cpp, LM Studio).
2615
- //
2616
- // Required flags:
2617
- // --budget <N> Token budget (integer > 0)
2618
- // --instructions-file <path> Review instructions
2619
- // --roadmap-file <path> Roadmap section
2620
- // --plan-file <path> Plan file (may be repeated)
2621
- // --output-prompt <path> Write trimmed prompt here
2622
- // --output-metadata <path> Write metadata JSON here
2623
- //
2624
- // Optional flags:
2625
- // --safety-margin-pct <N> Default 10
2626
- // --project-md-head-lines <N> Default 40
2627
- // --project-file <path>
2628
- // --context-file <path>
2629
- // --research-file <path>
2630
- // --requirements-file <path>
2631
- //
2632
- // Exit codes:
2633
- // 0 success (trim or no-trim)
2634
- // 1 invocation error (missing required arg, missing file, invalid budget)
2635
- // 2 hardFailed: prompt cannot fit effective budget after trim policy
2636
-
2637
- case 'prompt-budget': {
2638
- const promptBudget = require('./lib/prompt-budget.cjs');
2639
-
2640
- // ── Collect multi-value --plan-file flags ──────────────────────────
2641
- const planFiles = [];
2642
- for (let i = 1; i < args.length; i++) {
2643
- if (args[i] === '--plan-file' && args[i + 1] && !args[i + 1].startsWith('--')) {
2644
- planFiles.push(args[i + 1]);
2645
- i++;
2646
- }
2647
- }
2291
+ // --json-errors / GSD_JSON_ERRORS=1: when active, error() emits structured
2292
+ // JSON ({ ok: false, reason: <ERROR_REASON code>, message }) to stderr
2293
+ // instead of "Error: <text>". Lets test suites assert on typed reason codes
2294
+ // per CONTRIBUTING.md "Prohibited: Raw Text Matching" (#2974).
2295
+ //
2296
+ // Detect early — before any flag parsing that can fire error() — so even
2297
+ // --cwd and workstream-resolution failures emit structured stderr (#3310).
2298
+ // The argv splice must happen here too, otherwise the dispatcher below sees
2299
+ // "--json-errors" as an unknown command. Default off — human operators keep
2300
+ // their plain-text diagnostic.
2301
+ const jsonErrorsIdx = args.indexOf('--json-errors');
2302
+ if (jsonErrorsIdx !== -1) {
2303
+ setJsonErrorMode(true);
2304
+ args.splice(jsonErrorsIdx, 1);
2305
+ } else if (process.env.GSD_JSON_ERRORS === '1') {
2306
+ setJsonErrorMode(true);
2307
+ }
2648
2308
 
2649
- // ── Parse single-value flags ───────────────────────────────────────
2650
- const flagMap = new Map();
2651
- for (let i = 1; i < args.length; i++) {
2652
- const current = args[i];
2653
- const next = args[i + 1];
2654
- if (!current.startsWith('--')) continue;
2655
- if (!next || next.startsWith('--')) {
2656
- if (!flagMap.has(current)) flagMap.set(current, null);
2657
- continue;
2658
- }
2659
- if (!flagMap.has(current)) flagMap.set(current, next);
2660
- i++;
2661
- }
2662
- const getFlag = (flag) => flagMap.get(flag) ?? null;
2663
-
2664
- const budgetStr = getFlag('--budget');
2665
- const instructionsFile = getFlag('--instructions-file');
2666
- const roadmapFile = getFlag('--roadmap-file');
2667
- const outputPromptFile = getFlag('--output-prompt');
2668
- const outputMetadataFile = getFlag('--output-metadata');
2669
- const safetyMarginStr = getFlag('--safety-margin-pct');
2670
- const projectMdHeadLinesStr = getFlag('--project-md-head-lines');
2671
- const projectFile = getFlag('--project-file');
2672
- const contextFile = getFlag('--context-file');
2673
- const researchFile = getFlag('--research-file');
2674
- const requirementsFile = getFlag('--requirements-file');
2675
-
2676
- // ── Validate required args ─────────────────────────────────────────
2677
- if (!budgetStr) {
2678
- throw new ExitError(1, 'Error: --budget <N> is required');
2679
- }
2680
- const budget = parseInt(budgetStr, 10);
2681
- if (!Number.isFinite(budget) || budget <= 0) {
2682
- throw new ExitError(1, 'Error: --budget must be a positive integer');
2683
- }
2684
- if (!instructionsFile) {
2685
- throw new ExitError(1, 'Error: --instructions-file <path> is required');
2686
- }
2687
- if (!roadmapFile) {
2688
- throw new ExitError(1, 'Error: --roadmap-file <path> is required');
2689
- }
2690
- if (planFiles.length === 0) {
2691
- throw new ExitError(1, 'Error: at least one --plan-file <path> is required');
2692
- }
2693
- if (!outputPromptFile) {
2694
- throw new ExitError(1, 'Error: --output-prompt <path> is required');
2695
- }
2696
- if (!outputMetadataFile) {
2697
- throw new ExitError(1, 'Error: --output-metadata <path> is required');
2698
- }
2309
+ // Optional cwd override for sandboxed subagents running outside project root.
2310
+ let cwd = process.cwd();
2311
+ const cwdEqArg = args.find(arg => arg.startsWith('--cwd='));
2312
+ const cwdIdx = args.indexOf('--cwd');
2313
+ if (cwdEqArg) {
2314
+ const value = cwdEqArg.slice('--cwd='.length).trim();
2315
+ if (!value) error('Missing value for --cwd', ERROR_REASON.USAGE);
2316
+ args.splice(args.indexOf(cwdEqArg), 1);
2317
+ cwd = path.resolve(value);
2318
+ } else if (cwdIdx !== -1) {
2319
+ const value = args[cwdIdx + 1];
2320
+ if (!value || value.startsWith('--')) error('Missing value for --cwd', ERROR_REASON.USAGE);
2321
+ args.splice(cwdIdx, 2);
2322
+ cwd = path.resolve(value);
2323
+ }
2699
2324
 
2700
- // ── Validate and read required files ──────────────────────────────
2701
- async function readRequired(filePath, flagName) {
2702
- const resolved = path.resolve(filePath);
2703
- try {
2704
- return await fs.promises.readFile(resolved, 'utf8');
2705
- } catch (err) {
2706
- if (err && err.code === 'ENOENT') {
2707
- throw new ExitError(1, `Error: file not found for ${flagName}: ${resolved}`);
2708
- }
2709
- throw new ExitError(1, `Error: cannot read file for ${flagName}: ${resolved}`);
2710
- }
2711
- }
2325
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
2326
+ error(`Invalid --cwd: ${cwd}`, ERROR_REASON.USAGE);
2327
+ }
2712
2328
 
2713
- async function readOptional(filePath) {
2714
- if (!filePath) return null;
2715
- const resolved = path.resolve(filePath);
2716
- try {
2717
- return await fs.promises.readFile(resolved, 'utf8');
2718
- } catch (err) {
2719
- if (err && err.code === 'ENOENT') return null;
2720
- throw new ExitError(1, `Error: cannot read optional file: ${resolved}`);
2721
- }
2722
- }
2329
+ // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
2330
+ // However, in monorepo worktrees where the subdirectory itself owns .planning/,
2331
+ // skip worktree resolution — the CWD is already the correct project root.
2332
+ const { resolveWorktreeRoot } = require('./lib/worktree-safety.cjs');
2333
+ if (!fs.existsSync(path.join(cwd, '.planning'))) {
2334
+ const worktreeRoot = resolveWorktreeRoot(cwd);
2335
+ if (worktreeRoot !== cwd) {
2336
+ cwd = worktreeRoot;
2337
+ }
2338
+ }
2723
2339
 
2724
- const instructions = await readRequired(instructionsFile, '--instructions-file');
2725
- const roadmap = await readRequired(roadmapFile, '--roadmap-file');
2726
- const plans = await Promise.all(planFiles.map(async (p) => {
2727
- const resolved = path.resolve(p);
2728
- try {
2729
- const content = await fs.promises.readFile(resolved, 'utf8');
2730
- return { file: path.basename(p), content };
2731
- } catch (err) {
2732
- if (err && err.code === 'ENOENT') {
2733
- throw new ExitError(1, `Error: plan file not found: ${resolved}`);
2734
- }
2735
- throw new ExitError(1, `Error: cannot read plan file: ${resolved}`);
2736
- }
2737
- }));
2738
-
2739
- const projectMd = await readOptional(projectFile);
2740
- const context = await readOptional(contextFile);
2741
- const research = await readOptional(researchFile);
2742
- const requirements = await readOptional(requirementsFile);
2743
-
2744
- // ── Build options ─────────────────────────────────────────────────
2745
- const options = {};
2746
- if (safetyMarginStr !== null) {
2747
- const pct = parseInt(safetyMarginStr, 10);
2748
- if (Number.isFinite(pct)) options.safetyMarginPct = pct;
2749
- }
2750
- if (projectMdHeadLinesStr !== null) {
2751
- const lines = parseInt(projectMdHeadLinesStr, 10);
2752
- if (Number.isFinite(lines)) options.projectMdHeadLines = lines;
2753
- }
2340
+ // Optional workstream override for parallel milestone work.
2341
+ // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
2342
+ let workstreamContext = null;
2343
+ try {
2344
+ workstreamContext = resolveActiveWorkstream(cwd, args, process.env, {
2345
+ getStored: getActiveWorkstream,
2346
+ });
2347
+ args = workstreamContext.args;
2348
+ // Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths.
2349
+ applyResolvedWorkstreamEnv(workstreamContext, process.env);
2350
+ } catch (err) {
2351
+ error(err.message || String(err));
2352
+ }
2353
+
2354
+ const rawIndex = args.indexOf('--raw');
2355
+ const raw = rawIndex !== -1;
2356
+ if (rawIndex !== -1) args.splice(rawIndex, 1);
2754
2357
 
2755
- // ── Call applyBudget ──────────────────────────────────────────────
2756
- const sections = { instructions, roadmap, plans, projectMd, context, research, requirements };
2757
- const { prompt, metadata } = promptBudget.applyBudget({ sections, budget, options });
2358
+ // --pick <name>: extract a single field from JSON output (replaces jq dependency).
2359
+ // Supports dot-notation (e.g., --pick workflow.research) and bracket notation
2360
+ // for arrays (e.g., --pick directories[-1]).
2361
+ const pickIdx = args.indexOf('--pick');
2362
+ let pickField = null;
2363
+ if (pickIdx !== -1) {
2364
+ pickField = args[pickIdx + 1];
2365
+ if (!pickField || pickField.startsWith('--')) error('Missing value for --pick', ERROR_REASON.USAGE);
2366
+ args.splice(pickIdx, 2);
2367
+ }
2758
2368
 
2759
- // ── Write outputs ─────────────────────────────────────────────────
2760
- await fs.promises.writeFile(path.resolve(outputMetadataFile), JSON.stringify(metadata, null, 2));
2761
- await fs.promises.writeFile(path.resolve(outputPromptFile), prompt);
2369
+ // --default <value>: for config-get, return this value instead of erroring
2370
+ // when the key is absent. Allows workflows to express optional config reads
2371
+ // without defensive `2>/dev/null || true` boilerplate (#1893).
2372
+ const defaultIdx = args.indexOf('--default');
2373
+ let defaultValue = undefined;
2374
+ if (defaultIdx !== -1) {
2375
+ defaultValue = args[defaultIdx + 1];
2376
+ if (defaultValue === undefined) defaultValue = '';
2377
+ args.splice(defaultIdx, 2);
2378
+ }
2762
2379
 
2763
- if (metadata.hardFailed) {
2764
- throw new ExitError(2);
2765
- }
2766
- break;
2767
- }
2380
+ let command = args[0];
2768
2381
 
2769
- case 'update-context': {
2770
- // #498: resolve the installed GSD version, scope, runtime, and config dir
2771
- // for /gsd:update. Replaces ~280 lines of inline bash in update.md with a
2772
- // tested projection. Emits the contract as JSON: { installedVersion,
2773
- // scope, runtime, gsdDir }. Optional --config-dir / --runtime carry the
2774
- // workflow's execution_context hints (the one thing only it can know).
2775
- const { loadUpdateContext } = require('./lib/update-context.cjs');
2776
- const ucArgs = args.slice(1);
2777
- let preferredConfigDir = '';
2778
- let preferredRuntime = '';
2779
- for (let i = 0; i < ucArgs.length; i++) {
2780
- const a = ucArgs[i];
2781
- if (a.startsWith('--config-dir=')) { preferredConfigDir = a.slice('--config-dir='.length); continue; }
2782
- if (a.startsWith('--runtime=')) { preferredRuntime = a.slice('--runtime='.length); continue; }
2783
- if (a === '--config-dir') {
2784
- const v = ucArgs[i + 1];
2785
- if (v === undefined || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
2786
- preferredConfigDir = v; i++; continue;
2787
- }
2788
- if (a === '--runtime') {
2789
- const v = ucArgs[i + 1];
2790
- if (v === undefined || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
2791
- preferredRuntime = v; i++; continue;
2792
- }
2793
- if (a === '--json') continue; // JSON is the only output; accepted for symmetry
2794
- if (a.startsWith('-')) error(`Unknown flag for update-context: ${a}`, ERROR_REASON.USAGE);
2795
- }
2796
- const ctx = loadUpdateContext({ preferredConfigDir, preferredRuntime });
2797
- process.stdout.write(JSON.stringify(ctx) + '\n');
2798
- break;
2799
- }
2382
+ // Accept `query` as a meta-prefix for canonical dotted/spaced commands.
2383
+ // Workflows may call `node gsd-tools.cjs query <command>` directly.
2384
+ if (command === 'query') {
2385
+ args.shift();
2386
+ command = args[0];
2387
+ }
2800
2388
 
2801
- // ─── Research Store ────────────────────────────────────────────────────
2802
- //
2803
- // research-store get <key> [--kind <k>]
2804
- // -> getResearch(cwd, key, { homeDir }); searches both tiers; output(result, raw)
2805
- // (--kind is accepted for backward compatibility but no longer drives tier selection)
2806
- // research-store put <key> --content <str> --source <s> --provider <p>
2807
- // --confidence <c> --kind <k>
2808
- // -> putResearch(cwd, key, { content, source, provider, confidence, kind })
2809
- //
2810
- // Tier is derived from source: 'curated' source writes to process.env.HOME/.gsd/research-cache;
2811
- // all other sources write to cwd/.planning/research/.cache.
2812
- // Tests may override the home directory by setting the HOME env var.
2813
-
2814
- case 'research-store': {
2815
- const researchStore = require('./lib/research-store.cjs');
2816
- const subcommand = args[1];
2817
- const homeDir = process.env.HOME || require('os').homedir();
2818
- if (subcommand === 'get') {
2819
- const key = args[2];
2820
- if (!key || key.startsWith('--')) {
2821
- error('Usage: gsd-tools research-store get <key> [--kind <k>]', ERROR_REASON.USAGE);
2822
- }
2823
- if (!researchStore.isValidResearchKey(key)) {
2824
- error('research-store: <key> must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE);
2825
- }
2826
- // --kind is accepted but no longer drives tier selection; getResearch searches both tiers
2827
- const result = researchStore.getResearch(cwd, key, { homeDir });
2828
- output(result, raw);
2829
- } else if (subcommand === 'put') {
2830
- const key = args[2];
2831
- if (!key || key.startsWith('--')) {
2832
- error('Usage: gsd-tools research-store put <key> --content <str> --source <s> --provider <p> --confidence <c> --kind <k>', ERROR_REASON.USAGE);
2833
- }
2834
- if (!researchStore.isValidResearchKey(key)) {
2835
- error('research-store: <key> must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE);
2836
- }
2837
- const contentIdx = args.indexOf('--content');
2838
- const sourceIdx = args.indexOf('--source');
2839
- const providerIdx = args.indexOf('--provider');
2840
- const confidenceIdx = args.indexOf('--confidence');
2841
- const kindIdx = args.indexOf('--kind');
2842
- // For each flag, if the following value is missing or itself starts with '--', reject.
2843
- function getFlagValue(idx, flagName) {
2844
- if (idx === -1) return null;
2845
- const val = args[idx + 1];
2846
- if (val === undefined || val.startsWith('--')) {
2847
- error(`research-store put: missing value for ${flagName}`, ERROR_REASON.USAGE);
2848
- }
2849
- return val;
2850
- }
2851
- const content = getFlagValue(contentIdx, '--content');
2852
- const source = getFlagValue(sourceIdx, '--source');
2853
- const provider = getFlagValue(providerIdx, '--provider');
2854
- const confidence = getFlagValue(confidenceIdx, '--confidence');
2855
- const kind = getFlagValue(kindIdx, '--kind');
2856
- if (!content || !source || !provider || !confidence || !kind) {
2857
- error('Usage: gsd-tools research-store put <key> --content <str> --source <s> --provider <p> --confidence <c> --kind <k>', ERROR_REASON.USAGE);
2858
- }
2859
- const entry = researchStore.putResearch(cwd, key, { content, source, provider, confidence, kind }, { homeDir });
2860
- output(entry, raw);
2861
- } else {
2862
- error('Unknown research-store subcommand. Available: get, put', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2863
- }
2864
- break;
2389
+ // #3243: accept dotted canonical form (e.g. `state.update`) as well as the
2390
+ // spaced form (`state update`). Some workflow callers pass the dotted
2391
+ // canonical form directly; this normalization keeps both forms valid.
2392
+ //
2393
+ // Split on the FIRST dot only — `check.decision-coverage-plan` becomes
2394
+ // command='check', args=['check','decision-coverage-plan',...rest].
2395
+ // Guard: head and rest must both be non-empty (rejects leading-dot args like
2396
+ // ".hidden" and bare-dot ".").
2397
+ const originalCommand = command; // preserved for "Unknown command" suggestion
2398
+ if (typeof command === 'string' && command.includes('.')) {
2399
+ const dotIdx = command.indexOf('.');
2400
+ const head = command.slice(0, dotIdx);
2401
+ const rest = command.slice(dotIdx + 1);
2402
+ if (head && rest) {
2403
+ command = head;
2404
+ args = [head, rest, ...args.slice(1)];
2865
2405
  }
2406
+ }
2866
2407
 
2867
- // ─── Research Plan ─────────────────────────────────────────────────────
2868
- //
2869
- // research-plan --input <path>
2870
- // Read+JSON.parse file; call planResearch({ questions, ecosystem, config, cwd })
2871
- // { ecosystem, config, questions: [{ text, kind, library?, version? }] }
2872
-
2873
- case 'research-plan': {
2874
- const researchProvider = require('./lib/research-provider.cjs');
2875
- const inputIdx = args.indexOf('--input');
2876
- const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
2877
- if (!inputPath || inputPath.startsWith('--')) {
2878
- error('Usage: gsd-tools research-plan --input <path>', ERROR_REASON.USAGE);
2879
- }
2880
- let planInput;
2881
- try {
2882
- const raw_ = fs.readFileSync(path.resolve(inputPath), 'utf8');
2883
- planInput = JSON.parse(raw_);
2884
- } catch (readErr) {
2885
- error(`research-plan: cannot read/parse --input file: ${inputPath}`, ERROR_REASON.USAGE);
2886
- }
2887
- if (planInput === null || typeof planInput !== 'object' || Array.isArray(planInput)) {
2888
- error('research-plan: --input must be an object with a questions array', ERROR_REASON.USAGE);
2889
- }
2890
- if (!Array.isArray(planInput.questions)) {
2891
- error('research-plan: --input must be an object with a questions array', ERROR_REASON.USAGE);
2892
- }
2893
- const { ecosystem = '', config: planConfig = {}, questions } = planInput;
2894
- const homeDir = process.env.HOME || require('os').homedir();
2895
- const plan = researchProvider.planResearch({ questions, ecosystem, config: planConfig, cwd, homeDir });
2896
- output(plan, raw);
2897
- break;
2898
- }
2408
+ // Top-level usage string — emitted by `gsd-tools` (no args) and by
2409
+ // `gsd-tools --help` / any `--help` request below.
2410
+ // CR feedback: the command list must enumerate every top-level command
2411
+ // supported by the dispatcher so `--help` is actually useful for
2412
+ // discovery; previously it was a partial subset that didn't include
2413
+ // phase / roadmap / milestone / progress / etc.
2414
+ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
2415
+ 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
2416
+ 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
2417
+ 'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
2418
+ 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
2419
+ 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
2420
+ 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
2421
+ 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, smart-entry, state, ' +
2422
+ 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
2423
+ 'Global flags:\n' +
2424
+ ' --raw Emit raw output without post-processing\n' +
2425
+ ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
2426
+ ' --cwd <path> Override working directory for project-root resolution\n' +
2427
+ ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
2428
+ ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
2429
+ 'For command-specific argument requirements, invoke the command without args ' +
2430
+ '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
2899
2431
 
2900
- // ─── Classify Confidence ──────────────────────────────────────────────
2901
- //
2902
- // classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]
2903
- // -> classifyConfidence({ provider, verifiedAgainstOfficial, legitimacyVerdict }); output(result, raw)
2904
- //
2905
- // legitimacyVerdict is CODE-COMPUTED via checkPackages — never caller-supplied — so an agent cannot self-assert OK→HIGH.
2432
+ if (!command) {
2433
+ error(TOP_LEVEL_USAGE);
2434
+ }
2906
2435
 
2907
- case 'classify-confidence': {
2908
- const researchProvider = require('./lib/research-provider.cjs');
2909
- const providerIdx = args.indexOf('--provider');
2910
- const provider = providerIdx !== -1 ? args[providerIdx + 1] : null;
2911
- if (!provider || provider.startsWith('--')) {
2912
- error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
2913
- }
2914
- const verified = args.includes('--verified');
2915
- const pkgIdx = args.indexOf('--package');
2916
- const pkg = pkgIdx !== -1 ? args[pkgIdx + 1] : null;
2917
- const ecoIdx = args.indexOf('--ecosystem');
2918
- const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null;
2919
- let legitimacyVerdict = null;
2920
- if (pkg && (!pkg.startsWith('--'))) {
2921
- const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
2922
- if (!ecosystem || ecosystem.startsWith('--') || !VALID_ECOSYSTEMS.has(ecosystem)) {
2923
- error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
2924
- }
2925
- const pkgLegitimacy = require('./lib/package-legitimacy.cjs');
2926
- const results = await pkgLegitimacy.checkPackages({ ecosystem, packages: [pkg] }, {});
2927
- legitimacyVerdict = results[0] ? results[0].verdict : null;
2928
- }
2929
- const confidence = researchProvider.classifyConfidence({ provider, verifiedAgainstOfficial: verified, legitimacyVerdict });
2930
- output({ provider, package: pkg || null, ecosystem: ecosystem || null, legitimacyVerdict, verified, confidence }, raw);
2931
- break;
2932
- }
2436
+ // #3019: a `--help` / `-h` flag in argv must render the top-level usage
2437
+ // and exit 0 — not error out with "Unknown flag". The previous shape
2438
+ // erred on agent-hallucinated flags, but it also blocked humans from
2439
+ // discovering the command surface via subcommand help requests routed
2440
+ // through this dispatcher. Rendering top-level usage on --help is strictly
2441
+ // better UX than the old short-circuit that printed unrelated usage text.
2442
+ const HELP_FLAGS = new Set(['-h', '--help', '-?', '--h', '--usage']);
2443
+ if (args.some((a) => HELP_FLAGS.has(a))) {
2444
+ process.stdout.write(TOP_LEVEL_USAGE + '\n');
2445
+ return;
2446
+ }
2933
2447
 
2934
- // ─── Package Legitimacy ────────────────────────────────────────────────
2935
- //
2936
- // package-legitimacy check --ecosystem <eco> <pkg1> <pkg2> ...
2937
- //
2938
- // checkPackages is ASYNC. This entire runCommand function is async, so
2939
- // we can await directly. On rejection we call error() which exits.
2940
-
2941
- case 'package-legitimacy': {
2942
- const pkgLegitimacy = require('./lib/package-legitimacy.cjs');
2943
- const subcommand = args[1];
2944
- if (subcommand !== 'check') {
2945
- error('Unknown package-legitimacy subcommand. Available: check', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2946
- }
2947
- const ecoIdx = args.indexOf('--ecosystem');
2948
- const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null;
2949
- const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
2950
- if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) {
2951
- error('Usage: gsd-tools package-legitimacy check --ecosystem <npm|pypi|crates> <pkg1> ...', ERROR_REASON.USAGE);
2952
- }
2953
- // Collect positional package names.
2954
- // Only --ecosystem takes a value. Every non-flag arg is a package name.
2955
- // Any unknown --flag is a usage error (do not silently skip+consume the next arg).
2956
- const packages = [];
2957
- for (let i = 2; i < args.length; i++) {
2958
- const a = args[i];
2959
- if (a === '--ecosystem') { i++; continue; }
2960
- if (a.startsWith('--')) {
2961
- error(`package-legitimacy: unknown flag ${a}`, ERROR_REASON.USAGE);
2962
- }
2963
- packages.push(a);
2964
- }
2965
- if (packages.length === 0) {
2966
- error('Usage: gsd-tools package-legitimacy check --ecosystem <eco> <pkg1> <pkg2> ...', ERROR_REASON.USAGE);
2967
- }
2968
- let pkgResults;
2969
- try {
2970
- pkgResults = await pkgLegitimacy.checkPackages({ ecosystem, packages }, {});
2971
- } catch (pkgErr) {
2972
- error(`package-legitimacy: ${pkgErr && pkgErr.message ? pkgErr.message : String(pkgErr)}`, ERROR_REASON.UNKNOWN);
2973
- }
2974
- output(pkgResults, raw);
2975
- break;
2448
+ // Reject version flags. AI agents sometimes hallucinate --version on tool
2449
+ // invocations; silently ignoring it can cause destructive operations to
2450
+ // proceed unchecked. (Help flags are handled above.)
2451
+ const NEVER_VALID_FLAGS = new Set(['--version', '-v']);
2452
+ for (const arg of args) {
2453
+ if (NEVER_VALID_FLAGS.has(arg)) {
2454
+ error(`Unknown flag: ${arg}\ngsd-tools does not accept version flags. Run "gsd-tools" with no arguments for usage.`, ERROR_REASON.USAGE);
2976
2455
  }
2456
+ }
2977
2457
 
2978
- case 'effort': {
2979
- const subcommand = args[1];
2980
- if (subcommand === 'sync') {
2981
- const effortSyncArgs = args.slice(2);
2982
- let dryRun = true;
2983
- let effortSyncConfigDir;
2984
- let effortSyncRuntime;
2985
- for (let i = 0; i < effortSyncArgs.length; i++) {
2986
- const a = effortSyncArgs[i];
2987
- if (a === '--apply') { dryRun = false; continue; }
2988
- if (a === '--dry-run') { dryRun = true; continue; }
2989
- if (a.startsWith('--config-dir=')) { effortSyncConfigDir = a.slice('--config-dir='.length); continue; }
2990
- if (a === '--config-dir') {
2991
- const v = effortSyncArgs[i + 1];
2992
- if (!v || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
2993
- effortSyncConfigDir = v; i++; continue;
2994
- }
2995
- if (a.startsWith('--runtime=')) { effortSyncRuntime = a.slice('--runtime='.length); continue; }
2996
- if (a === '--runtime') {
2997
- const v = effortSyncArgs[i + 1];
2998
- if (!v || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
2999
- effortSyncRuntime = v; i++; continue;
3000
- }
3001
- if (a === '--raw') continue;
3002
- if (a.startsWith('-')) error(`Unknown flag for effort sync: ${a}`, ERROR_REASON.USAGE);
3003
- error(`effort sync takes no positional arguments; got: ${a}`, ERROR_REASON.USAGE);
3004
- }
3005
- commands.cmdEffortSync(cwd, raw, { dryRun, configDir: effortSyncConfigDir, runtime: effortSyncRuntime });
3006
- } else {
3007
- error('Unknown effort subcommand. Available: sync', ERROR_REASON.SDK_UNKNOWN_COMMAND);
3008
- }
3009
- break;
3010
- }
2458
+ // Multi-repo guard: resolve project root for commands that read/write .planning/.
2459
+ // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
2460
+ // filesystem traversal on every invocation.
2461
+ // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
2462
+ // Both are registry/config queries that resolve activation via
2463
+ // .planning/config.json; they need the project root (cwd) for correct
2464
+ // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
2465
+ // move the other at the same time (keep them consistent).
2466
+ const SKIP_ROOT_RESOLUTION = new Set([
2467
+ 'generate-slug', 'current-timestamp', 'verify-path-exists',
2468
+ 'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
2469
+ 'worktree', 'prompt-budget',
2470
+ 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
2471
+ 'user-story', // pure string validation — no .planning/ access needed
2472
+ // #1529: pure runtime→filename projection via getProjectInstructionFile; no
2473
+ // .planning/ access needed, and resolving project root would break workflow
2474
+ // invocations that run before .planning/ exists (new-project Step 1).
2475
+ 'project-instruction-file',
2476
+ // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
2477
+ // needs no .planning/ access, so skip the findProjectRoot traversal.
2478
+ 'eval',
2479
+ ]);
2480
+ if (!SKIP_ROOT_RESOLUTION.has(command)) {
2481
+ cwd = findProjectRoot(cwd);
2482
+ }
3011
2483
 
3012
- // ─── User Story Validation (bug #1145) ────────────────────────────────────
3013
- //
3014
- // Invocation shapes (from mvp-phase.md and verify-work.md):
3015
- // gsd_run query user-story.validate --story "$USER_STORY"
3016
- // gsd_run query user-story.validate --story "$PHASE_GOAL" --pick valid
3017
- //
3018
- // Returns JSON: { valid: boolean, errors: string[], slots: { role, capability, outcome } | null }
3019
- // - valid: true only when the story fully matches the canonical format
3020
- // - errors: per-slot diagnostic strings (empty on success)
3021
- // - slots: extracted role/capability/outcome on success; null on failure
3022
- //
3023
- // Canonical format (user-story-template.md):
3024
- // "As a [user role], I want to [capability], so that [outcome]."
3025
- // Each slot must be non-empty and contain non-whitespace content.
3026
- //
3027
- // No .planning/ access needed — pure string validation.
3028
-
3029
- // #1146: single base-branch resolver for all forking workflows.
3030
- // Workflows call `gsd_run query git.base-branch` (dotted form normalised to
3031
- // command='git', args=['git','base-branch']).
3032
- case 'git': {
3033
- const subcommand = args[1];
3034
- if (subcommand !== 'base-branch') {
3035
- error(
3036
- `Unknown git subcommand: ${subcommand || '(none)'}. Available: base-branch`,
3037
- ERROR_REASON.SDK_UNKNOWN_COMMAND,
3038
- );
3039
- break;
3040
- }
3041
- cmdGitBaseBranch(cwd, args.slice(2));
3042
- break;
2484
+ // When --pick is active, capture stdout and extract the requested field.
2485
+ if (pickField) {
2486
+ const captured = await captureStdoutSyncWrites(async () => {
2487
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
2488
+ });
2489
+ const resolved = resolveAtFileOutput(captured);
2490
+ try {
2491
+ const obj = JSON.parse(resolved);
2492
+ const value = extractField(obj, pickField);
2493
+ const result = value === null || value === undefined ? '' : String(value);
2494
+ fs.writeSync(1, result);
2495
+ } catch {
2496
+ fs.writeSync(1, captured);
3043
2497
  }
2498
+ return;
2499
+ }
3044
2500
 
3045
- case 'user-story': {
3046
- const subcommand = args[1];
3047
- if (subcommand !== 'validate') {
3048
- error(`Unknown user-story subcommand: ${subcommand || '(none)'}. Available: validate`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
3049
- break;
3050
- }
3051
-
3052
- const storyIdx = args.indexOf('--story');
3053
- const story = (storyIdx !== -1 && args[storyIdx + 1] && !args[storyIdx + 1].startsWith('--'))
3054
- ? args[storyIdx + 1]
3055
- : '';
3056
-
3057
- // Canonical extraction regex — requires non-whitespace content in each slot
3058
- // (\S.*? ensures the slot isn't whitespace-only).
3059
- // Named groups: role / capability / outcome.
3060
- const USER_STORY_RE = /^As a (\S.*?), I want to (\S.*?), so that (\S.*?)\.$/;
2501
+ // Intercept stdout to transparently resolve @file: references (#1891).
2502
+ // io.cjs output() writes @file:<path> when JSON > 50KB. The --pick path
2503
+ // already resolves this, but the normal path wrote @file: to stdout, forcing
2504
+ // every workflow to have a bash-specific `if [[ "$INIT" == @file:* ]]` check
2505
+ // that breaks on PowerShell and other non-bash shells.
2506
+ const captured = await captureStdoutSyncWrites(async () => {
2507
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext);
2508
+ });
2509
+ fs.writeSync(1, resolveAtFileOutput(captured));
2510
+ }
3061
2511
 
3062
- const errors = [];
3063
- const trimmed = story.trim();
3064
- let slots = null;
2512
+ function captureStdoutSyncWrites(run) {
2513
+ const originalWriteSync = fs.writeSync;
2514
+ let captured = '';
3065
2515
 
3066
- if (!trimmed) {
3067
- errors.push('Story is empty. Required format: "As a [role], I want to [capability], so that [outcome]."');
3068
- } else {
3069
- // Per-clause guards produce targeted, actionable error messages before
3070
- // attempting the full regex. Guards are ordered: role → capability → outcome → period.
3071
- if (!/^As a \S/i.test(trimmed)) {
3072
- errors.push('Story must start with "As a [user role]," (role must be non-empty).');
3073
- }
3074
- if (!/, I want to \S/i.test(trimmed)) {
3075
- errors.push('Story must include ", I want to [capability]," (capability must be non-empty).');
3076
- }
3077
- if (!/, so that \S/i.test(trimmed)) {
3078
- errors.push('Story must include ", so that [outcome]." (outcome must be non-empty).');
3079
- }
3080
- if (!trimmed.endsWith('.')) {
3081
- errors.push('Story must end with a period (.).');
3082
- }
3083
- // Full-regex check only when per-clause guards all passed — avoids
3084
- // redundant "format mismatch" noise on top of specific error messages.
3085
- if (errors.length === 0) {
3086
- const m = USER_STORY_RE.exec(trimmed);
3087
- if (!m) {
3088
- errors.push('Story does not match the canonical format: "As a [role], I want to [capability], so that [outcome]."');
3089
- } else {
3090
- slots = { role: m[1], capability: m[2], outcome: m[3] };
3091
- }
3092
- }
2516
+ fs.writeSync = function patchedWriteSync(fd, data, ...rest) {
2517
+ if (fd === 1) {
2518
+ if (Buffer.isBuffer(data)) {
2519
+ captured += data.toString('utf-8');
2520
+ return data.length;
3093
2521
  }
3094
-
3095
- output({ valid: errors.length === 0, errors, slots }, raw);
3096
- break;
2522
+ const text = String(data);
2523
+ captured += text;
2524
+ let encoding = 'utf-8';
2525
+ if (typeof rest[1] === 'string') encoding = rest[1];
2526
+ return Buffer.byteLength(text, encoding);
3097
2527
  }
2528
+ return originalWriteSync.call(fs, fd, data, ...rest);
2529
+ };
3098
2530
 
3099
- case 'drift-guard': {
3100
- // ADR-22: deterministic authority resolution + severity classification.
3101
- // Subcommands:
3102
- // drift-guard authority → effective authority string
3103
- // drift-guard severity --status <S> [--authority <A>] → {severity, hardBlock}
3104
- const subcommand = args[1];
3105
-
3106
- // Read config.json directly for both plan_review.source_grounding_authority
3107
- // and intel.enabled. Neither key is in the config-loader.cjs whitelist that
3108
- // config-loader.cjs's loadConfig() whitelist does not return; plan_review is only in config.cjs's private
3109
- // buildConfig(), and intel is a federated capability config key.
3110
- let configuredAuthority = 'grep';
3111
- let intelEnabled = false;
3112
- try {
3113
- const { planningDir } = require('./lib/planning-workspace.cjs');
3114
- const cfgPath = require('path').join(planningDir(cwd), 'config.json');
3115
- if (require('fs').existsSync(cfgPath)) {
3116
- const rawCfg = JSON.parse(require('fs').readFileSync(cfgPath, 'utf-8'));
3117
- if (rawCfg && rawCfg.plan_review && rawCfg.plan_review.source_grounding_authority) {
3118
- configuredAuthority = String(rawCfg.plan_review.source_grounding_authority);
3119
- }
3120
- if (rawCfg && rawCfg.intel && rawCfg.intel.enabled === true) {
3121
- intelEnabled = true;
3122
- }
3123
- }
3124
- } catch {
3125
- // not fatal — defaults apply
3126
- }
3127
-
3128
- const effectiveAuthority = getEffectiveAuthority(configuredAuthority, intelEnabled);
2531
+ const restore = () => {
2532
+ fs.writeSync = originalWriteSync;
2533
+ };
3129
2534
 
3130
- if (subcommand === 'authority') {
3131
- // Pass rawValue as 3rd arg so --raw returns unquoted string (not JSON)
3132
- output(effectiveAuthority, raw, effectiveAuthority);
3133
- break;
2535
+ return Promise.resolve()
2536
+ .then(() => run())
2537
+ .then(() => {
2538
+ restore();
2539
+ return captured;
2540
+ }, (err) => {
2541
+ restore();
2542
+ // The wrapped command may have written to stdout BEFORE it threw — e.g. a --raw
2543
+ // command that emits a JSON result/error envelope and THEN throws ExitError to set a
2544
+ // non-zero exit code (capability set/disable on an unknown id). Without this flush that
2545
+ // captured output is silently discarded (the success-path flush at the call site never
2546
+ // runs on a throw). Emit it now; the error still propagates so the exit code is preserved.
2547
+ if (captured) {
2548
+ try { originalWriteSync.call(fs, 1, resolveAtFileOutput(captured)); } catch { /* best-effort flush */ }
3134
2549
  }
2550
+ throw err;
2551
+ });
2552
+ }
3135
2553
 
3136
- if (subcommand === 'severity') {
3137
- const statusIdx = args.indexOf('--status');
3138
- const statusVal = statusIdx !== -1 ? args[statusIdx + 1] : undefined;
3139
- if (!statusVal || statusVal.startsWith('--')) {
3140
- error('drift-guard severity requires --status <VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE>', ERROR_REASON.SDK_UNKNOWN_COMMAND);
3141
- break;
3142
- }
3143
- const authIdx = args.indexOf('--authority');
3144
- const authVal = authIdx !== -1 ? args[authIdx + 1] : undefined;
3145
- const authorityForClassify = (authVal && !authVal.startsWith('--'))
3146
- ? authVal
3147
- : effectiveAuthority;
3148
- const result = classifyDriftSeverity({ status: statusVal, authority: authorityForClassify });
3149
- output(result, raw);
3150
- break;
3151
- }
2554
+ function resolveAtFileOutput(captured) {
2555
+ if (!captured.startsWith('@file:')) return captured;
2556
+ return fs.readFileSync(captured.slice(6), 'utf-8');
2557
+ }
3152
2558
 
3153
- error(
3154
- `Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity`,
3155
- ERROR_REASON.SDK_UNKNOWN_COMMAND,
3156
- );
3157
- break;
2559
+ /**
2560
+ * Extract a field from an object using dot-notation and bracket syntax.
2561
+ * Supports: 'field', 'parent.child', 'arr[-1]', 'arr[0]'
2562
+ */
2563
+ function extractField(obj, fieldPath) {
2564
+ const parts = fieldPath.split('.');
2565
+ let current = obj;
2566
+ for (const part of parts) {
2567
+ if (current === null || current === undefined) return undefined;
2568
+ const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/);
2569
+ if (bracketMatch) {
2570
+ const key = bracketMatch[1];
2571
+ const index = parseInt(bracketMatch[2], 10);
2572
+ current = current[key];
2573
+ if (!Array.isArray(current)) return undefined;
2574
+ current = index < 0 ? current[current.length + index] : current[index];
2575
+ } else {
2576
+ current = current[part];
3158
2577
  }
2578
+ }
2579
+ return current;
2580
+ }
2581
+
2582
+ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
2583
+ switch (command) {
3159
2584
 
3160
2585
  default: {
3161
2586
  // ADR-959: try capability-registry dispatch before emitting the unknown-command error.
@@ -3171,6 +2596,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
3171
2596
  // require()-ing its router FROM the capability's install root (confined to that root).
3172
2597
  if (dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error })) break;
3173
2598
 
2599
+ // ADR-2346 (epic #2345): host dispatch table — core, non-capability
2600
+ // commands (state, …) routed via their `route*Command` router instead of
2601
+ // a hardcoded `case` arm. Tried after capability/overlay dispatch and
2602
+ // before the unknown-command error.
2603
+ if (await dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext })) break;
2604
+
3174
2605
  // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
3175
2606
  // above split it so `command` here is the head ("foo"). Use
3176
2607
  // originalCommand to reconstruct the original dotted form and suggest
@@ -3202,4 +2633,5 @@ if (require.main === module) {
3202
2633
  // synthetic registry + requireModule injections.
3203
2634
  // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
3204
2635
  // the third-party overlay dispatch + install-root confinement tests.
3205
- module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot };
2636
+ module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot, dispatchHostCommand, HOST_COMMAND_ROUTERS };
2637
+