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