@opengsd/gsd-core 1.20.1 → 1.21.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 (135) 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 +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -169,7 +169,7 @@
169
169
  * --schema plan|summary|verification
170
170
  *
171
171
  * Verification Suite:
172
- * verify plan-structure <file> Check PLAN.md structure + tasks
172
+ * verify plan-structure <file> [--replay] Check PLAN.md structure + tasks
173
173
  * verify phase-completeness <phase> Check all plans have summaries
174
174
  * verify references <file> Check @-refs + paths resolve
175
175
  * verify commits <h1> [h2] ... Batch verify commit hashes
@@ -296,6 +296,7 @@ const { ExitError, runMain, resolveContractVersion } = require('./lib/cli-exit.c
296
296
  const io = require('./lib/io.cjs');
297
297
  const { error, ERROR_REASON, setJsonErrorMode, output, formatDiagnosticToken, captureStdoutSyncWrites, resolveAtFileOutput } = io;
298
298
  const projectRoot = require('./lib/project-root.cjs');
299
+ const { resolveProjectContext, legacyDispatchCwd } = require('./lib/project-context.cjs');
299
300
  // Resolve findProjectRoot lazily at call time rather than binding it at module
300
301
  // load. It is sourced from project-root.cjs; a call-time lookup is robust
301
302
  // against any require/load-ordering edge where the export isn't bound yet
@@ -371,10 +372,36 @@ const { routeAgentCommand, AGENT_FAILURE_CLASSES } = require('./lib/agent-comman
371
372
  const smartEntryMod = require('./lib/smart-entry.cjs');
372
373
  const { routeCheckCommand } = require('./lib/check-command-router.cjs');
373
374
  const { routeTaskCommand } = require('./lib/task-command-router.cjs');
374
- const { parseNamedArgsOrExit, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
375
+ const { parseNamedArgsOrExit, isFlagToken } = require('./lib/command-arg-projection.cjs');
376
+ const { specFor, GLOBAL_FLAGS } = require('./lib/cli-surface.cjs');
375
377
  const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
376
378
  const { getEffectiveAuthority, classifyDriftSeverity, comparePhaseStatus } = require('./lib/plan-drift-guard.cjs');
377
379
 
380
+ /**
381
+ * Free positional words from `args[from..]` for a spec that sets `freePositionals`: every
382
+ * non-flag token that is not the value of a declared value flag. The walk mirrors the seam's
383
+ * own advance (a value flag takes the next token unless that is flag-shaped; an eq-form token
384
+ * takes none), so a word is never mistaken for a flag value or the reverse. Flag names come
385
+ * from the spec, never from literals.
386
+ */
387
+ function positionalWords(args, spec, from = 1) {
388
+ const valued = new Set([...(spec.valueFlags ?? []), ...(spec.optionalValueFlags ?? [])]);
389
+ const eq = new Set(spec.eqFlags ?? []);
390
+ const words = [];
391
+ for (let i = from; i < args.length; i++) {
392
+ const tok = args[i];
393
+ if (!isFlagToken(tok)) {
394
+ words.push(tok);
395
+ continue;
396
+ }
397
+ const eqAt = tok.indexOf('=');
398
+ if (eqAt > 2 && eq.has(tok.slice(2, eqAt))) continue;
399
+ const next = args[i + 1];
400
+ if (valued.has(tok.slice(2)) && next !== undefined && !isFlagToken(next)) i += 1;
401
+ }
402
+ return words;
403
+ }
404
+
378
405
  // ─── Bridge collapsed (Phase 4) ────────────────────────────────────────────────
379
406
  // Non-family commands now run through their CJS handlers directly. Keep the
380
407
  // helper contract so existing call sites remain unchanged during the phase
@@ -662,116 +689,49 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
662
689
  // arrive via the destructured context.
663
690
 
664
691
  function routeResolveModel({ args, cwd, raw }) {
692
+ parseNamedArgsOrExit(args, specFor('resolve-model'), error);
665
693
  commands.cmdResolveModel(cwd, args[1], raw);
666
694
  }
667
695
 
668
696
  function routeResolveGranularity({ args, cwd, raw }) {
669
- const granArgs = args.slice(1);
670
- let granOverride;
671
- const granPositionals = [];
672
- for (let i = 0; i < granArgs.length; i++) {
673
- const a = granArgs[i];
674
- if (a === '--granularity' && granArgs[i + 1] !== undefined && !granArgs[i + 1].startsWith('--')) {
675
- if (granOverride === undefined) { granOverride = granArgs[++i]; } else { ++i; }
676
- } else {
677
- granPositionals.push(a);
678
- }
679
- }
680
- commands.cmdResolveGranularity(cwd, granPositionals[0], raw, granOverride);
697
+ const spec = specFor('resolve-granularity');
698
+ const parsed = parseNamedArgsOrExit(args, spec, error);
699
+ // The phase-type positional may sit before or after --granularity: it is the first
700
+ // non-flag token that is not the value --granularity consumed.
701
+ const [positional] = positionalWords(args, spec);
702
+ commands.cmdResolveGranularity(cwd, positional, raw, parsed.granularity ?? undefined);
681
703
  }
682
704
 
683
705
  function routeResolveExecution({ args, cwd, raw }) {
684
- const execArgs = args.slice(1);
685
- let effortOverride;
686
- let fastModeOverride;
687
- let attempt;
688
- let failureClass;
689
- let host;
690
- const positionals = [];
706
+ const spec = specFor('resolve-execution');
707
+ const parsed = parseNamedArgsOrExit(args, spec, error);
691
708
  // #2296: the valid classes come from the classifier's own frozen enum, so
692
709
  // this validator can never drift from what `agent classify-failure` emits.
693
710
  const validFailureClasses = Object.values(AGENT_FAILURE_CLASSES);
694
- const setFailureClass = (v) => {
695
- if (!validFailureClasses.includes(v)) {
696
- error(
697
- `--failure-class must be one of: ${validFailureClasses.join(', ')}`,
698
- ERROR_REASON.USAGE,
699
- );
700
- }
701
- failureClass = v;
702
- };
703
- for (let i = 0; i < execArgs.length; i++) {
704
- const a = execArgs[i];
705
- if (a.startsWith('--effort=')) {
706
- effortOverride = a.slice('--effort='.length);
707
- continue;
708
- }
709
- if (a.startsWith('--fast-mode=')) {
710
- const v = a.slice('--fast-mode='.length);
711
- fastModeOverride = v === 'true' ? true : v === 'false' ? false : undefined;
712
- continue;
713
- }
714
- if (a.startsWith('--attempt=')) {
715
- const v = a.slice('--attempt='.length);
716
- const n = parseInt(v, 10);
717
- if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
718
- attempt = n;
719
- continue;
720
- }
721
- if (a.startsWith('--failure-class=')) {
722
- setFailureClass(a.slice('--failure-class='.length));
723
- continue;
724
- }
725
- if (a === '--effort') {
726
- const val = execArgs[i + 1];
727
- if (val === undefined || val.startsWith('--')) error('Missing value for --effort', ERROR_REASON.USAGE);
728
- effortOverride = val;
729
- i++;
730
- continue;
731
- }
732
- if (a === '--fast-mode') {
733
- const val = execArgs[i + 1];
734
- if (val === undefined || val.startsWith('--')) error('Missing value for --fast-mode', ERROR_REASON.USAGE);
735
- fastModeOverride = val === 'true' ? true : val === 'false' ? false : undefined;
736
- i++;
737
- continue;
738
- }
739
- if (a === '--attempt') {
740
- const val = execArgs[i + 1];
741
- if (val === undefined || val.startsWith('--')) error('Missing value for --attempt', ERROR_REASON.USAGE);
742
- const n = parseInt(val, 10);
743
- if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
744
- attempt = n;
745
- i++;
746
- continue;
747
- }
748
- if (a === '--failure-class') {
749
- const val = execArgs[i + 1];
750
- if (val === undefined || val.startsWith('--')) error('Missing value for --failure-class', ERROR_REASON.USAGE);
751
- setFailureClass(val);
752
- i++;
753
- continue;
754
- }
755
- if (a === '--host') {
756
- const val = execArgs[i + 1];
757
- if (val === undefined || val.startsWith('--')) error('Missing value for --host', ERROR_REASON.USAGE);
758
- host = val;
759
- i++;
760
- continue;
761
- }
762
- if (a === '--raw') continue;
763
- if (a.startsWith('-')) error(`Unknown flag for resolve-execution: ${a}`, ERROR_REASON.USAGE);
764
- positionals.push(a);
711
+ const failureClass = parsed['failure-class'] ?? undefined;
712
+ if (failureClass !== undefined && !validFailureClasses.includes(failureClass)) {
713
+ error(
714
+ `--failure-class must be one of: ${validFailureClasses.join(', ')}`,
715
+ ERROR_REASON.USAGE,
716
+ );
765
717
  }
718
+ const fastMode = parsed['fast-mode'];
719
+ const fastModeOverride = fastMode === 'true' ? true : fastMode === 'false' ? false : undefined;
720
+ let attempt;
721
+ if (parsed.attempt !== null) {
722
+ attempt = parseInt(parsed.attempt, 10);
723
+ if (!Number.isInteger(attempt) || attempt < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
724
+ }
725
+ const positionals = positionalWords(args, spec);
766
726
  if (positionals.length === 0) error('agent-type required', ERROR_REASON.USAGE);
767
727
  if (positionals.length > 1) error(`resolve-execution requires exactly one agent-type argument; got: ${positionals.join(', ')}`, ERROR_REASON.USAGE);
768
728
  const agentTypeArg = positionals[0];
769
729
  commands.cmdResolveExecution(cwd, agentTypeArg, raw, {
770
- effortOverride,
730
+ effortOverride: parsed.effort ?? undefined,
771
731
  fastModeOverride,
772
732
  attempt,
773
733
  failureClass,
774
- host,
734
+ host: parsed.host ?? undefined,
775
735
  });
776
736
  }
777
737
 
@@ -802,6 +762,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
802
762
  }
803
763
 
804
764
  function routeConfigSet({ args, cwd, raw }) {
765
+ const parsed = parseNamedArgsOrExit(args, specFor('config-set'), error);
805
766
  const handled = _dispatchNonFamily({
806
767
  registryCommand: 'config-set',
807
768
  registryArgs: args.slice(1),
@@ -812,7 +773,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
812
773
  error,
813
774
  output: output,
814
775
  });
815
- if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw, { dryRun: args.includes('--dry-run') });
776
+ if (!handled) config.cmdConfigSet(cwd, args[1], args[2], raw, { dryRun: parsed['dry-run'] });
816
777
  }
817
778
 
818
779
  function routeConfigSetModelProfile({ args, cwd, raw }) {
@@ -847,6 +808,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
847
808
  }
848
809
 
849
810
  function routeConfigNewProject({ args, cwd, raw }) {
811
+ // A flag is a dash followed by a letter (`-x`, `--force`); `-1` is a negative-number JSON argument.
812
+ // The spec's rejectShortFlags/unknownFlagMessage refuse both forms with today's wording (#5268 pins both).
813
+ const parsed = parseNamedArgsOrExit(args, specFor('config-new-project'), error);
850
814
  const handled = _dispatchNonFamily({
851
815
  registryCommand: 'config-new-project',
852
816
  registryArgs: args.slice(1),
@@ -859,42 +823,38 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
859
823
  });
860
824
  if (handled) return;
861
825
  // #5268: this verb's flag surface is closed, as `todo complete`'s is
862
- // (#4096). `--dry-run` is parsed and plumbed, and any OTHER flag fails loudly
863
- // instead of being silently ignored: an accepted-but-ignored `--dry-run`
864
- // wrote the config it was meant to preview. (`--raw` is spliced by the
865
- // dispatcher before routing; it stays in the set as a guard against that
866
- // splice ever moving.) The choices JSON is the one positional argument,
867
- // wherever the flag sits, and a second one is refused for the same reason.
868
- // A flag is a dash followed by a letter: `-1` is a (negative-number) JSON
869
- // argument, refused below as choices that are not an object, not a flag.
870
- const CONFIG_NEW_PROJECT_KNOWN_FLAGS = new Set(['--dry-run', '--raw']);
871
- const positionals = [];
872
- for (const a of args.slice(1)) {
873
- if (typeof a === 'string' && /^--?[A-Za-z]/.test(a)) {
874
- if (!CONFIG_NEW_PROJECT_KNOWN_FLAGS.has(a)) {
875
- error(`Unknown flag for config-new-project: ${a}`, ERROR_REASON.USAGE);
876
- }
877
- continue;
878
- }
879
- positionals.push(a);
880
- }
826
+ // (#4096). `--dry-run` is the only flag (declared in the CLI surface registry),
827
+ // and any OTHER flag fails loudly in the seam instead of being silently
828
+ // ignored: an accepted-but-ignored `--dry-run` wrote the config it was meant
829
+ // to preview. The choices JSON is the one positional argument, wherever the
830
+ // flag sits (the spec's freePositionals), and a second one is refused for the
831
+ // same reason. `-1` is a (negative-number) JSON argument, refused below as
832
+ // choices that are not an object, not a flag.
833
+ const positionals = args.slice(1).filter((a) => !isFlagToken(a));
881
834
  if (positionals.length > 1) {
882
835
  error(`Unexpected argument for config-new-project: ${positionals[1]}. Usage: config-new-project [choices-json] [--dry-run]`, ERROR_REASON.USAGE);
883
836
  }
884
- config.cmdConfigNewProject(cwd, positionals[0], raw, { dryRun: args.includes('--dry-run') });
837
+ config.cmdConfigNewProject(cwd, positionals[0], raw, { dryRun: parsed['dry-run'] });
885
838
  }
886
839
 
887
- function routeConfigPath({ cwd, raw, workstreamContext }) {
840
+ function routeConfigPath({ args, cwd, raw, workstreamContext }) {
841
+ parseNamedArgsOrExit(args, specFor('config-path'), error);
888
842
  config.cmdConfigPath(cwd, raw, workstreamContext);
889
843
  }
890
844
 
891
- async function routeMigrateConfig({ cwd, raw }) {
845
+ async function routeMigrateConfig({ args, cwd, raw }) {
846
+ parseNamedArgsOrExit(args, specFor('migrate-config'), error);
892
847
  await config.cmdMigrateConfig(cwd, raw);
893
848
  }
894
849
 
895
850
  function routeResearchStore({ args, cwd, raw }) {
896
- const researchStore = require('./lib/research-store.cjs');
897
851
  const subcommand = args[1];
852
+ if (subcommand !== 'get' && subcommand !== 'put') {
853
+ error('Unknown research-store subcommand. Available: get, put', ERROR_REASON.SDK_UNKNOWN_COMMAND);
854
+ return;
855
+ }
856
+ const parsed = parseNamedArgsOrExit(args, specFor(`research-store.${subcommand}`), error);
857
+ const researchStore = require('./lib/research-store.cjs');
898
858
  const homeDir = process.env.HOME || require('os').homedir();
899
859
  if (subcommand === 'get') {
900
860
  const key = args[2];
@@ -906,7 +866,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
906
866
  }
907
867
  const result = researchStore.getResearch(cwd, key, { homeDir });
908
868
  output(result, raw);
909
- } else if (subcommand === 'put') {
869
+ } else {
910
870
  const key = args[2];
911
871
  if (!key || key.startsWith('--')) {
912
872
  error('Usage: gsd-tools research-store put <key> --content <str> --source <s> --provider <p> --confidence <c> --kind <k>', ERROR_REASON.USAGE);
@@ -914,41 +874,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
914
874
  if (!researchStore.isValidResearchKey(key)) {
915
875
  error('research-store: <key> must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE);
916
876
  }
917
- const contentIdx = args.indexOf('--content');
918
- const sourceIdx = args.indexOf('--source');
919
- const providerIdx = args.indexOf('--provider');
920
- const confidenceIdx = args.indexOf('--confidence');
921
- const kindIdx = args.indexOf('--kind');
922
- function getFlagValue(idx, flagName) {
923
- if (idx === -1) return null;
924
- const val = args[idx + 1];
925
- if (val === undefined || val.startsWith('--')) {
926
- error(`research-store put: missing value for ${flagName}`, ERROR_REASON.USAGE);
927
- }
928
- return val;
929
- }
930
- const content = getFlagValue(contentIdx, '--content');
931
- const source = getFlagValue(sourceIdx, '--source');
932
- const provider = getFlagValue(providerIdx, '--provider');
933
- const confidence = getFlagValue(confidenceIdx, '--confidence');
934
- const kind = getFlagValue(kindIdx, '--kind');
935
- if (!content || !source || !provider || !confidence || !kind) {
936
- error('Usage: gsd-tools research-store put <key> --content <str> --source <s> --provider <p> --confidence <c> --kind <k>', ERROR_REASON.USAGE);
937
- }
877
+ const { content, source, provider, confidence, kind } = parsed;
938
878
  const entry = researchStore.putResearch(cwd, key, { content, source, provider, confidence, kind }, { homeDir });
939
879
  output(entry, raw);
940
- } else {
941
- error('Unknown research-store subcommand. Available: get, put', ERROR_REASON.SDK_UNKNOWN_COMMAND);
942
880
  }
943
881
  }
944
882
 
945
883
  function routeResearchPlan({ args, cwd, raw }) {
884
+ const { input: inputPath } = parseNamedArgsOrExit(args, specFor('research-plan'), error);
946
885
  const researchProvider = require('./lib/research-provider.cjs');
947
- const inputIdx = args.indexOf('--input');
948
- const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
949
- if (!inputPath || inputPath.startsWith('--')) {
950
- error('Usage: gsd-tools research-plan --input <path>', ERROR_REASON.USAGE);
951
- }
952
886
  let planInput;
953
887
  try {
954
888
  const raw_ = fs.readFileSync(path.resolve(inputPath), 'utf8');
@@ -1034,7 +968,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1034
968
  */
1035
969
  function routeSelectRevertCommits({ args, cwd, raw, error, invokingCwd }) {
1036
970
  // #4906/#4465 review fix: `cwd` here has already been remapped to the
1037
- // MAIN worktree root by `resolveMainWorktreeCwd` (main(), applied
971
+ // MAIN worktree root by the retired `resolveMainWorktreeCwd` (now legacyDispatchCwd, applied
1038
972
  // BLANKET to every command before dispatch — correct for a router that
1039
973
  // reads `.planning/`, e.g. `find-phase`, but wrong for this one). This
1040
974
  // command reads NO `.planning/` content — it only runs `git log` — and
@@ -1086,11 +1020,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1086
1020
  // guard below is therefore exactly the safe/unsafe boundary.
1087
1021
  const normalizePaddedPhase = (id) => (/^\d/.test(id) ? normalizePhaseName(id) : id);
1088
1022
 
1089
- const parsed = parseNamedArgsOrExit(
1090
- args,
1091
- { valueFlags: ['phase', 'plan', 'range'], positionals: 1 },
1092
- error,
1093
- );
1023
+ // `--range` is a required flag of the spec, so the seam refuses a missing one.
1024
+ const parsed = parseNamedArgsOrExit(args, specFor('select-revert-commits'), error);
1094
1025
  const rawPhase = parsed.phase;
1095
1026
  const rawPlan = parsed.plan;
1096
1027
  const range = parsed.range;
@@ -1099,10 +1030,6 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1099
1030
  error('select-revert-commits requires exactly one of --phase <id> or --plan <id>', ERROR_REASON.USAGE);
1100
1031
  return;
1101
1032
  }
1102
- if (!range) {
1103
- error('select-revert-commits requires --range <git-revision-range>', ERROR_REASON.USAGE);
1104
- return;
1105
- }
1106
1033
  // Belt-and-suspenders alongside the --end-of-options guard below (mirrors
1107
1034
  // src/git-base-branch.cts's isSafeRevisionRef posture for the same class
1108
1035
  // of argument): refuse an option-shaped range before it ever reaches git.
@@ -1212,9 +1139,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1212
1139
  output(result, raw, rawLines);
1213
1140
  }
1214
1141
 
1215
- function routeCommit({ args, cwd, raw, error }) {
1216
- const amend = args.includes('--amend');
1217
- const noVerify = args.includes('--no-verify');
1142
+ // #6043 (ADR-4908 §3): the one sanctioned source of roots for workflow shell.
1143
+ // Prints the ProjectContext the dispatcher resolved once for this process;
1144
+ // `--raw` prints the worktree root.
1145
+ function routeProjectContext({ args, raw, error, ctx }) {
1146
+ parseNamedArgsOrExit(args, specFor('project-context'), error);
1147
+ output(ctx, raw, ctx.worktreeRoot);
1148
+ }
1149
+
1150
+ function routeCommit({ args, cwd, raw, error, ctx }) {
1151
+ const spec = specFor('commit');
1152
+ const parsed = parseNamedArgsOrExit(args, spec, error);
1218
1153
  // #4208: `--files` and `--files-removed` are two path lists, each
1219
1154
  // running from its flag to the NEXT LIST FLAG. A boolean flag
1220
1155
  // inside a list (`--files a --amend b`) is skipped, not a
@@ -1226,27 +1161,29 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1226
1161
  // A REPEATED list flag (`--files a --files b`) merges, as the old
1227
1162
  // slice-to-end parse merged it: every occurrence contributes its
1228
1163
  // run, and none of them ends another's silently.
1229
- const firstListFlag = args.findIndex((a, i) => i > 0 && COMMIT_LIST_FLAGS.has(a));
1230
- // Collect all positional args between command name and first flag,
1164
+ // The seam's `listFlags` extraction implements exactly that contract.
1165
+ const listTokens = new Set(spec.listFlags.map((f) => `--${f}`));
1166
+ const firstListFlag = args.findIndex((a, i) => i > 0 && listTokens.has(a));
1167
+ // Collect all positional args between command name and first list flag,
1231
1168
  // then join them — handles both quoted ("multi word msg") and
1232
1169
  // unquoted (multi word msg) invocations from different shells
1233
- const endIndex = firstListFlag !== -1 ? firstListFlag : args.length;
1234
- const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--'));
1235
- const message = messageArgs.join(' ') || undefined;
1236
- const files = collectListFlagValues(args, '--files');
1237
- const filesRemoved = collectListFlagValues(args, '--files-removed');
1238
- commands.cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved);
1170
+ const head = firstListFlag !== -1 ? args.slice(0, firstListFlag) : args;
1171
+ const message = positionalWords(head, spec).join(' ') || undefined;
1172
+ commands.cmdCommit(cwd, message, parsed.files, raw, parsed.amend, parsed['no-verify'], parsed['files-removed'], ctx);
1239
1173
  }
1240
1174
 
1241
1175
  function routeCheckCommit({ args, cwd, raw, error }) {
1176
+ parseNamedArgsOrExit(args, specFor('check-commit'), error);
1242
1177
  commands.cmdCheckCommit(cwd, raw);
1243
1178
  }
1244
1179
 
1245
1180
  function routeCommitDocsGuard({ args, cwd, raw, error }) {
1246
1181
  const subcommand = args[1];
1247
1182
  if (subcommand === 'enable') {
1183
+ parseNamedArgsOrExit(args, specFor('commit-docs-guard.enable'), error);
1248
1184
  commands.cmdCommitDocsGuardEnable(cwd, raw);
1249
1185
  } else if (subcommand === 'disable') {
1186
+ parseNamedArgsOrExit(args, specFor('commit-docs-guard.disable'), error);
1250
1187
  commands.cmdCommitDocsGuardDisable(cwd, raw);
1251
1188
  } else {
1252
1189
  error('Unknown commit-docs-guard subcommand. Available: enable, disable', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -1254,41 +1191,46 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1254
1191
  }
1255
1192
 
1256
1193
  function routeCommitToSubrepo({ args, cwd, raw, error }) {
1257
- const message = args[1];
1258
- const filesIndex = args.indexOf('--files');
1259
- const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
1260
- commands.cmdCommitToSubrepo(cwd, message, files, raw);
1194
+ const parsed = parseNamedArgsOrExit(args, specFor('commit-to-subrepo'), error);
1195
+ commands.cmdCommitToSubrepo(cwd, args[1], parsed.files, raw);
1261
1196
  }
1262
1197
 
1263
1198
  function routePrSubrepo({ args, cwd, raw, error }) {
1264
- const message = args[1];
1265
- // #3884: the commit message is an optional leading positional the
1266
- // caller owns (args[1]) — but when it is OMITTED, args[1] is itself
1267
- // the first flag (e.g. `--repo`), and a static `positionals: 2`
1268
- // treats that flag's own value as an unexpected trailing positional
1269
- // before cmdPrSubrepo's own "commit message required" guard ever
1270
- // runs. Widen the boundary only when args[1] genuinely looks like a
1271
- // message (not flag-shaped), mirroring the same fix applied to
1272
- // `state complete-phase`.
1273
- const messagePresent = message !== undefined && !message.startsWith('--');
1274
- const { repo, branch } = parseNamedArgsOrExit(args, { valueFlags: ['repo', 'branch'], positionals: messagePresent ? 2 : 1 }, error);
1275
- commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
1199
+ // #3884: the commit message is an optional leading positional the caller
1200
+ // owns (args[1]); when it is OMITTED, args[1] is itself the first flag
1201
+ // (e.g. `--repo`). The spec makes words past the verb free, so a flag's own
1202
+ // value is never mistaken for a trailing positional before cmdPrSubrepo's
1203
+ // own "commit message required" guard runs.
1204
+ const prSubrepoSpec = specFor('pr-subrepo');
1205
+ const { repo, branch } = parseNamedArgsOrExit(args, prSubrepoSpec, error);
1206
+ // At most the one optional message word may stand outside a flag: a second free word is the same
1207
+ // stray positional the strict boundary (2 with a message, else 1) reported.
1208
+ const prSubrepoWords = positionalWords(args, prSubrepoSpec);
1209
+ const prSubrepoMessageCount = args[1] !== undefined && !isFlagToken(args[1]) ? 1 : 0;
1210
+ if (prSubrepoWords.length > prSubrepoMessageCount) {
1211
+ error(
1212
+ `unexpected positional argument ${formatDiagnosticToken(prSubrepoWords[prSubrepoMessageCount])}`,
1213
+ ERROR_REASON.USAGE,
1214
+ );
1215
+ }
1216
+ commands.cmdPrSubrepo(cwd, repo, branch, args[1], raw);
1276
1217
  }
1277
1218
 
1278
1219
  function routeVerifySummary({ args, cwd, raw, error }) {
1220
+ const parsed = parseNamedArgsOrExit(args, specFor('verify-summary'), error);
1279
1221
  const summaryPath = args[1];
1280
- const countIndex = args.indexOf('--check-count');
1281
- const checkCount = countIndex !== -1 ? parseInt(args[countIndex + 1], 10) : 2;
1222
+ const checkCount = parsed['check-count'] !== null ? parseInt(parsed['check-count'], 10) : 2;
1282
1223
  verify.cmdVerifySummary(cwd, summaryPath, checkCount, raw);
1283
1224
  }
1284
1225
 
1285
1226
  function routeTemplate({ args, cwd, raw, error }) {
1286
1227
  const subcommand = args[1];
1287
1228
  if (subcommand === 'select') {
1229
+ parseNamedArgsOrExit(args, specFor('template.select'), error);
1288
1230
  template.cmdTemplateSelect(cwd, args[2], raw);
1289
1231
  } else if (subcommand === 'fill') {
1290
1232
  const templateType = args[2];
1291
- const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgsOrExit(args, { valueFlags: ['phase', 'plan', 'name', 'type', 'wave', 'fields'], positionals: 3 }, error);
1233
+ const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgsOrExit(args, specFor('template.fill'), error);
1292
1234
  let fields = {};
1293
1235
  if (fieldsRaw) {
1294
1236
  const { safeJsonParse } = require('./lib/security.cjs');
@@ -1337,14 +1279,23 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1337
1279
  }
1338
1280
  // CJS fallback (SDK unavailable or unknown subcommand)
1339
1281
  if (subcommand === 'get') {
1340
- frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['field'], positionals: 3 }, error).field, raw);
1282
+ // The field is `--field <k>` or one positional word after the file (shipped callers pass
1283
+ // `frontmatter.get <file> <field>`); a second word, or a word beside --field, is a stray.
1284
+ const fmGetSpec = specFor('frontmatter.get');
1285
+ const fmGetParsed = parseNamedArgsOrExit(args, fmGetSpec, error);
1286
+ const fmGetWords = positionalWords(args, fmGetSpec, 3);
1287
+ const fmGetAllowed = fmGetParsed.field === null ? 1 : 0;
1288
+ if (fmGetWords.length > fmGetAllowed) {
1289
+ error(`unexpected positional argument ${formatDiagnosticToken(fmGetWords[fmGetAllowed])}`, ERROR_REASON.USAGE);
1290
+ }
1291
+ frontmatter.cmdFrontmatterGet(cwd, file, fmGetParsed.field ?? fmGetWords[0], raw);
1341
1292
  } else if (subcommand === 'set') {
1342
- const { field, value } = parseNamedArgsOrExit(args, { valueFlags: ['field', 'value'], positionals: 3 }, error);
1293
+ const { field, value } = parseNamedArgsOrExit(args, specFor('frontmatter.set'), error);
1343
1294
  frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw);
1344
1295
  } else if (subcommand === 'merge') {
1345
- frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['data'], positionals: 3 }, error).data, raw);
1296
+ frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgsOrExit(args, specFor('frontmatter.merge'), error).data, raw);
1346
1297
  } else if (subcommand === 'validate') {
1347
- frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgsOrExit(args, { valueFlags: ['schema'], positionals: 3 }, error).schema, raw);
1298
+ frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgsOrExit(args, specFor('frontmatter.validate'), error).schema, raw);
1348
1299
  } else {
1349
1300
  error('Unknown frontmatter subcommand. Available: get, set, merge, validate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1350
1301
  }
@@ -1385,10 +1336,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1385
1336
  // Rationale: it is a pure local formatter and avoids SDK bridge startup
1386
1337
  // in tight subprocess loops where Windows CI has shown intermittent
1387
1338
  // native crashes (0xC0000005 / 3221225477).
1339
+ parseNamedArgsOrExit(args, specFor('current-timestamp'), error);
1388
1340
  commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1389
1341
  }
1390
1342
 
1391
- function routeRuntimeIdentity({ raw }) {
1343
+ function routeRuntimeIdentity({ args, raw }) {
1344
+ parseNamedArgsOrExit(args, specFor('runtime-identity'), error);
1392
1345
  // #3146: report this runtime's package identity so a shipped workflow can
1393
1346
  // tell whether it reached THIS package's gsd-tools or a colliding one.
1394
1347
  // Kept on the CJS fast path for the same reason as current-timestamp — the
@@ -1398,6 +1351,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1398
1351
  }
1399
1352
 
1400
1353
  function routeSkillsRoot({ args, raw, error }) {
1354
+ parseNamedArgsOrExit(args, specFor('skills-root'), error);
1401
1355
  // #3024: resolve the global skills base directory for a runtime.
1402
1356
  // The sync-skills workflow previously shelled out to install.js --skills-root,
1403
1357
  // but install.js is not shipped in installed trees. gsd-tools IS shipped, so
@@ -1435,17 +1389,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1435
1389
  // source of truth (getProjectInstructionFile in runtime-name-policy.cjs).
1436
1390
  // No SDK bridge — pure local lookup, runs before .planning/ exists.
1437
1391
  const { getProjectInstructionFile } = require('./lib/runtime-name-policy.cjs');
1438
- // Parse --runtime <value> (space or = form); default to empty so the
1439
- // safe AGENTS.md cross-agent default applies.
1440
- const pifArgs = args.slice(1);
1441
- let pifRuntime = '';
1442
- for (let i = 0; i < pifArgs.length; i++) {
1443
- const a = pifArgs[i];
1444
- if (a === '--runtime' && pifArgs[i + 1] !== undefined) { pifRuntime = pifArgs[++i]; continue; }
1445
- if (a.startsWith('--runtime=')) { pifRuntime = a.slice('--runtime='.length); continue; }
1446
- // First positional that isn't a flag also works (lenient); otherwise ignore unknown flags.
1447
- if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; }
1448
- }
1392
+ // --runtime <value> (space or = form); default to empty so the safe
1393
+ // AGENTS.md cross-agent default applies. The first bare word also works
1394
+ // (lenient), and the flag wins over it.
1395
+ const pifSpec = specFor('project-instruction-file');
1396
+ const pifParsed = parseNamedArgsOrExit(args, pifSpec, error);
1397
+ const pifRuntime = pifParsed.runtime
1398
+ || positionalWords(args, pifSpec).find((w) => !w.startsWith('-'))
1399
+ || '';
1449
1400
  // A retired runtime id now THROWS rather than resolving (#4709 AC#1).
1450
1401
  // Map it to the same clean single-line error routeSkillsRoot emits for
1451
1402
  // an unknown runtime — a CLI must not answer a bad flag value with a
@@ -1462,18 +1413,22 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1462
1413
  }
1463
1414
 
1464
1415
  function routeListTodos({ args, cwd, raw, error }) {
1416
+ parseNamedArgsOrExit(args, specFor('list-todos'), error);
1465
1417
  commands.cmdListTodos(cwd, args[1], raw);
1466
1418
  }
1467
1419
 
1468
1420
  function routeListSeeds({ args, cwd, raw, error }) {
1421
+ parseNamedArgsOrExit(args, specFor('list-seeds'), error);
1469
1422
  commands.cmdListSeeds(cwd, args[1], raw);
1470
1423
  }
1471
1424
 
1472
1425
  function routeVerifyPathExists({ args, cwd, raw, error }) {
1426
+ parseNamedArgsOrExit(args, specFor('verify-path-exists'), error);
1473
1427
  commands.cmdVerifyPathExists(cwd, args[1], raw);
1474
1428
  }
1475
1429
 
1476
- function routeQuickTasksMigrate({ cwd, raw }) {
1430
+ function routeQuickTasksMigrate({ args, cwd, raw }) {
1431
+ parseNamedArgsOrExit(args, specFor('quick-tasks-migrate'), error);
1477
1432
  // #3730 (option b, maintainer decision 2026-09-02): the supported repair
1478
1433
  // path for a legacy Quick Tasks table GSD's own pre-registry prose created.
1479
1434
  // Silent no-op when the section is absent or the table is already canonical
@@ -1510,15 +1465,14 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1510
1465
  // `awk NF-2` Quick Tasks column arithmetic. Row construction is delegated
1511
1466
  // to the pure appendQuickTaskRow (markdown-table.cjs); this case only
1512
1467
  // handles the I/O (read STATE.md, resolve date/commit, write STATE.md).
1513
- const qtaArgs = args.slice(1);
1514
1468
  // Ambiguous boundary (ADR-3473 §8.4 Item 2 note): this command accepts
1515
- // EITHER a positional free-text description (qtaArgs[0]) OR --task
1469
+ // EITHER a positional free-text description (args[1]) OR --task
1516
1470
  // <value> — the same token index is a caller-owned positional in one
1517
1471
  // input shape and a flag in the other, which a single fixed
1518
- // `positionals` cursor cannot represent. `positionals: 'rest'`
1519
- // disables the boundary walk (as with `init quick`) so extraction
1520
- // (used for the --task form) and the `|| args[1]` fallback (used for
1521
- // the positional form) both keep working unchanged.
1472
+ // `positionals` cursor cannot represent. The spec declares the
1473
+ // description a free word (`freePositionals`), so the `|| args[1]`
1474
+ // fallback (positional form) and the --task form both keep working,
1475
+ // while an undeclared flag is refused.
1522
1476
  // #3356 defect 1: `--quick-id` / `--slug` / `--directory` are
1523
1477
  // OPTIONAL widenings. A caller with no quick id or task directory
1524
1478
  // (fast.md, the original #2133 caller) omits them and keeps the
@@ -1542,12 +1496,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1542
1496
  // Omitted, `status` stays undefined and `appendQuickTaskRow` falls
1543
1497
  // back to its own `'—'` default — unchanged for every existing
1544
1498
  // caller (fast.md, quick.md's non-VALIDATE_MODE branch).
1545
- const qtaParsed = parseNamedArgsOrExit(
1546
- qtaArgs,
1547
- { valueFlags: ['task', 'quick-id', 'slug', 'directory', 'status'], positionals: 'rest' },
1548
- error,
1549
- );
1550
- const qtaTask = qtaParsed.task || args[1];
1499
+ const qtaSpec = specFor('quick-tasks-append');
1500
+ const qtaParsed = parseNamedArgsOrExit(args, qtaSpec, error);
1501
+ // The positional description is the first free word, never a flag (`--status ok desc` reads `desc`).
1502
+ const qtaTask = qtaParsed.task || positionalWords(args, qtaSpec)[0];
1551
1503
  if (!qtaTask) {
1552
1504
  error('quick-tasks-append requires --task <description> (or a positional description)', ERROR_REASON.USAGE);
1553
1505
  }
@@ -1647,10 +1599,6 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1647
1599
  const cfgLoader = require('./lib/config-loader.cjs');
1648
1600
  const capabilityLoader = require('./lib/capability-loader.cjs');
1649
1601
 
1650
- const flag = (name) => {
1651
- const i = args.indexOf(name);
1652
- return i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith('--') ? args[i + 1] : null;
1653
- };
1654
1602
  const sub = args[1];
1655
1603
  // Fail fast on an unrecognized subcommand. Without this check, `sub` fell through
1656
1604
  // to the `sub !== 'invoke'` usage-error branch far below (after loading the
@@ -1671,8 +1619,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1671
1619
  error(REVIEW_LANE_USAGE);
1672
1620
  return;
1673
1621
  }
1674
- const runDir = flag('--run-dir') || '.';
1675
- const repoRoot = flag('--repo-root') || cwd;
1622
+ // Each sub declares its own flags (`--explicit` is a value under dispatch-step and a boolean
1623
+ // under invoke), and the parse comes before any config, registry or lane work.
1624
+ const parsedFlags = parseNamedArgsOrExit(args, specFor(`review-lane.${sub}`), error);
1625
+ const runDir = parsedFlags['run-dir'] || '.';
1626
+ const repoRoot = parsedFlags['repo-root'] || cwd;
1676
1627
 
1677
1628
  // Resolved config, read ONCE. Reading in-process (rather than shelling out to config-get per
1678
1629
  // key, as the legs did) is what removes the stringly `"null"` sentinel the bash had to test for.
@@ -1796,7 +1747,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1796
1747
  warn: (m) => process.stderr.write(`${m}\n`),
1797
1748
  });
1798
1749
 
1799
- const selected = (flag('--selected') || '')
1750
+ const selected = (parsedFlags.selected || '')
1800
1751
  .split(',').map((s) => s.trim()).filter(Boolean);
1801
1752
  // #5266: `<slug>:available|missing` per openai-http lane. Host + default come from the lane
1802
1753
  // descriptor (resolveLanePlan), probing is runner.probeLane — review.md keeps no host table.
@@ -1848,8 +1799,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1848
1799
  // flag-parsing surface — do not replicate inline bash parsing" for exactly this reason).
1849
1800
  // Everything after `--` is a candidate flag; matched lane slugs print sorted and comma-joined.
1850
1801
  if (sub === 'explicit-from-argv') {
1851
- const sepIdx = args.indexOf('--');
1852
- const candidateArgs = new Set(sepIdx === -1 ? [] : args.slice(sepIdx + 1));
1802
+ const candidateArgs = new Set(parsedFlags['--']);
1853
1803
  const slugs = [];
1854
1804
  for (const lane of mergedLanes) {
1855
1805
  const flags = Array.isArray(lane.flags) ? lane.flags : [];
@@ -1938,17 +1888,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1938
1888
  // requested slugs, not the whole roster.
1939
1889
  if (sub === 'dispatch-step') {
1940
1890
  const { dispatchReviewerLanes } = require('./lib/reviewer-step-dispatch.cjs');
1941
- const explicitFlags = (flag('--explicit') || '').split(',').map((s) => s.trim()).filter(Boolean);
1942
- const depth = flag('--depth') || '';
1943
- const baseSha = flag('--base-sha') || '';
1891
+ const explicitFlags = (parsedFlags.explicit || '').split(',').map((s) => s.trim()).filter(Boolean);
1892
+ const depth = parsedFlags.depth || '';
1893
+ const baseSha = parsedFlags['base-sha'] || '';
1944
1894
  // #4209: this command IS the reusable capability/step-dispatch trait check — see
1945
1895
  // gsd-core/references/loop-hook-dispatch.md for what supportsReviewerLanes means and why
1946
1896
  // this is the one place it's resolved. Calls the SAME resolver `loop render-hooks` uses,
1947
1897
  // `resolveActiveHooksForPoint`, directly in-process — no subprocess, no JSON re-parse, and
1948
1898
  // no exposure to `io.cjs`'s `@file:` overflow protocol (which only applies to the
1949
1899
  // rendered-string envelope this path never touches).
1950
- const capId = flag('--cap-id') || '';
1951
- const point = flag('--point') || '';
1900
+ const capId = parsedFlags['cap-id'] || '';
1901
+ const point = parsedFlags.point || '';
1952
1902
  let trait = false;
1953
1903
  if (capId && point) {
1954
1904
  try {
@@ -2085,8 +2035,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2085
2035
  return;
2086
2036
  }
2087
2037
 
2088
- const slug = flag('--slug');
2089
- if (!slug) { error('review-lane invoke requires --slug'); return; }
2038
+ const slug = parsedFlags.slug;
2090
2039
  const entry = plans.find((p) => p.slug === slug);
2091
2040
  if (!entry || !entry.ok) {
2092
2041
  output({ slug, ok: false, reason: entry ? entry.reason : 'malformed_lane', detail: entry ? entry.detail : 'unknown lane' }, raw);
@@ -2102,9 +2051,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2102
2051
  // lane's invocation. Everything the lane declares — probe, channels, timeout, empty-output
2103
2052
  // policy, handler — then applies to the instance unchanged, which is the whole reason to route
2104
2053
  // them here: a cross-cutting fix reaches instances for free.
2105
- const asIdentity = flag('--as');
2106
- const instanceModel = flag('--model');
2107
- const instanceAgent = flag('--agent');
2054
+ const asIdentity = parsedFlags.as || null;
2055
+ const instanceModel = parsedFlags.model || null;
2056
+ const instanceAgent = parsedFlags.agent || null;
2108
2057
 
2109
2058
  if (asIdentity) {
2110
2059
  // Write under the INSTANCE name so two instances of one adapter never overwrite each other.
@@ -2185,7 +2134,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2185
2134
  // to whichever channel this lane actually reads from (stdin, argv, an argv-embedded file
2186
2135
  // reference, or the http promptPath), so one flag serves every lane (#5049). It is no longer a
2187
2136
  // trim hand-off: nothing in review.md trims a prompt to fit a lane since #4559.
2188
- const promptOverride = flag('--prompt-file');
2137
+ const promptOverride = parsedFlags['prompt-file'] || null;
2189
2138
  if (promptOverride) {
2190
2139
  entry.plan = applyPromptOverride(entry.plan, promptOverride, repoRoot);
2191
2140
  }
@@ -2253,7 +2202,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2253
2202
 
2254
2203
  const result = await runner.runLane(entry.plan, deps, {
2255
2204
  consentedHost,
2256
- explicitlyRequested: args.includes('--explicit'),
2205
+ explicitlyRequested: parsedFlags.explicit,
2257
2206
  repoRoot,
2258
2207
  });
2259
2208
  output(result, raw);
@@ -2264,11 +2213,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2264
2213
  // watch-mode runner (vitest/jest) cannot hang a verification gate. Shared
2265
2214
  // by the regression gate and the post-merge gate. args[1] is the raw
2266
2215
  // resolved command; --cwd (already parsed into `cwd`) locates package.json.
2216
+ parseNamedArgsOrExit(args, specFor('normalize-test-command'), error);
2267
2217
  const testCommandNormalizer = require('./lib/normalize-test-command.cjs');
2268
2218
  testCommandNormalizer.cmdNormalizeTestCommand(cwd, args[1]);
2269
2219
  }
2270
2220
 
2271
2221
  function routeResolvePackageManager({ args, cwd }) {
2222
+ parseNamedArgsOrExit(args, specFor('resolve-package-manager'), error);
2272
2223
  // #5346: the gates' no-configured-command fallback asks here for the
2273
2224
  // project's package manager (packageManager field, then lockfile, else npm).
2274
2225
  // args[1] is the script name; --cwd (already parsed into `cwd`) locates package.json.
@@ -2276,6 +2227,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2276
2227
  }
2277
2228
 
2278
2229
  function routeDispatchShouldFlatten({ args, cwd, raw, error }) {
2230
+ const parsed = parseNamedArgsOrExit(args, specFor('dispatch-should-flatten'), error);
2279
2231
  // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
2280
2232
  //
2281
2233
  // Resolves the current runtime (GSD_RUNTIME > config.runtime > per-install
@@ -2308,8 +2260,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2308
2260
  ? hostIntegration.shouldFlattenDispatch(dispatch)
2309
2261
  : true; // fail-closed: unknown runtime → inline
2310
2262
 
2311
- const jsonIdx = args.indexOf('--json');
2312
- if (jsonIdx !== -1) {
2263
+ if (parsed.json) {
2313
2264
  output({
2314
2265
  runtime: runtimeId,
2315
2266
  shouldFlatten: shouldFlat,
@@ -2351,6 +2302,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2351
2302
  }
2352
2303
 
2353
2304
  function routeDispatchIsolation({ args, cwd, raw, error }) {
2305
+ const parsed = parseNamedArgsOrExit(args, specFor('dispatch-isolation'), error);
2354
2306
  // #2584 Phase 3 (#2627): typed query exposing the negotiated
2355
2307
  // `dispatch.isolation` to the execute-phase wave scheduler, so the
2356
2308
  // scheduler branches on a declared capability instead of on a runtime id.
@@ -2394,7 +2346,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2394
2346
  // `per-plan-worktree-gate.md`) override the naturally-resolved mode
2395
2347
  // while still going through this single write path. Best-effort: a
2396
2348
  // sentinel write failure here must never fail the wave.
2397
- const decision = resolveDispatchIsolationDecision({ args, cwd });
2349
+ const decision = resolveDispatchIsolationDecision({ parsed, cwd });
2398
2350
  const runtimeId = decision.runtimeId;
2399
2351
  let { isolation, exec, harnessFlag } = decision;
2400
2352
 
@@ -2404,8 +2356,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2404
2356
  // ignored rather than erroring — this is a best-effort recording call,
2405
2357
  // not a hard usage gate. Forcing to 'none' clears harnessFlag/exec since
2406
2358
  // neither applies to sequential dispatch.
2407
- const forceIdx = args.indexOf('--force-isolation');
2408
- const forcedIsolation = forceIdx !== -1 ? args[forceIdx + 1] : undefined;
2359
+ const forcedIsolation = parsed['force-isolation'];
2409
2360
  if (forcedIsolation && DISPATCH_ISOLATION_VOCABULARY.has(forcedIsolation)) {
2410
2361
  isolation = forcedIsolation;
2411
2362
  if (isolation === 'none') {
@@ -2414,14 +2365,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2414
2365
  }
2415
2366
  }
2416
2367
 
2417
- const phaseIdx = args.indexOf('--phase');
2418
- const phaseArg = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
2419
- ? args[phaseIdx + 1]
2420
- : null;
2421
- const planIdx = args.indexOf('--plan');
2422
- const planArg = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
2423
- ? args[planIdx + 1]
2424
- : null;
2368
+ const phaseArg = parsed.phase || null;
2369
+ const planArg = parsed.plan || null;
2425
2370
 
2426
2371
  // #3737: the project-level opt-out (workflow.use_worktrees === false) is
2427
2372
  // decided HERE, before the sentinel write — not only in the workflow
@@ -2451,7 +2396,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2451
2396
  // a { recorded: false } result; this catch is defense in depth only.
2452
2397
  }
2453
2398
 
2454
- if (args.indexOf('--json') !== -1) {
2399
+ if (parsed.json) {
2455
2400
  output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
2456
2401
  } else {
2457
2402
  process.stdout.write(isolation);
@@ -2497,6 +2442,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2497
2442
  * default → same as --raw
2498
2443
  */
2499
2444
  function routeDispatchCapacity({ args, cwd, raw }) {
2445
+ const capacityFlags = parseNamedArgsOrExit(args, specFor('dispatch-capacity'), error);
2500
2446
  const { UNDOCUMENTED, isPositiveSafeInteger } = require('./lib/host-integration.cjs');
2501
2447
 
2502
2448
  let runtimeId = null;
@@ -2557,7 +2503,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2557
2503
  reason = 'non_positive';
2558
2504
  }
2559
2505
 
2560
- if (args.indexOf('--json') !== -1) {
2506
+ if (capacityFlags.json) {
2561
2507
  output({ runtime: runtimeId, capacity, declared, source, reason }, raw);
2562
2508
  } else {
2563
2509
  process.stdout.write(String(capacity));
@@ -2726,7 +2672,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2726
2672
  * drift apart. Resolution only — the caller decides whether the decision is
2727
2673
  * recorded to the sentinel.
2728
2674
  */
2729
- function resolveDispatchIsolationDecision({ args, cwd }) {
2675
+ function resolveDispatchIsolationDecision({ parsed, cwd }) {
2730
2676
  let isolation = 'none';
2731
2677
  let runtimeId = null;
2732
2678
  let exec = null;
@@ -2770,11 +2716,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2770
2716
  }
2771
2717
 
2772
2718
  if (isolation === 'orchestrator-worktree') {
2773
- const cwdIdx = args.indexOf('--cwd-target');
2774
- const cwdTarget = cwdIdx !== -1 ? args[cwdIdx + 1] : '';
2719
+ const cwdTarget = parsed['cwd-target'];
2775
2720
  if (cwdTarget) {
2776
- const promptIdx = args.indexOf('--prompt');
2777
- const promptArg = promptIdx !== -1 ? args[promptIdx + 1] : undefined;
2721
+ const promptArg = parsed.prompt ?? undefined;
2778
2722
  const hostIntegration = require('./lib/host-integration.cjs');
2779
2723
  // #3714: resolve an EXPLICIT, non-sentinel per-agent model pin for the
2780
2724
  // spawned worktree executor. Passing `null` as the runtime resolver
@@ -2821,6 +2765,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2821
2765
  model = resolveDispatchModelPin('gsd-executor', pinned);
2822
2766
  }
2823
2767
  let orchestratorExec = runtimeEntry?.runtime?.orchestratorExec;
2768
+ // eslint-disable-next-line local/named-args-spec-from-registry -- compares a runtime descriptor's cwd flag for the host process argv gsd-tools builds, not a gsd-tools CLI flag
2824
2769
  if (orchestratorExec && orchestratorExec.cwdFlag === '--dir' && runtimeId === 'opencode') {
2825
2770
  // OpenCode 2.x `run` has no --dir: bind the worktree through the process cwd (ADR-5331 D5).
2826
2771
  const ocv = require('./lib/opencode-version.cjs');
@@ -2859,6 +2804,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2859
2804
  }
2860
2805
 
2861
2806
  function routeInspectDispatchIsolation({ args, cwd, raw }) {
2807
+ const parsed = parseNamedArgsOrExit(args, specFor('inspect-dispatch-isolation'), error);
2808
+ // The recording-only knobs (--force-isolation, --phase, --plan) are declared in the CLI surface
2809
+ // registry only so they reach this refusal, which says why they have no read-path meaning.
2810
+ const rejected = ['force-isolation', 'phase', 'plan'].filter((name) => parsed[name] !== null).map((name) => `--${name}`);
2811
+ if (rejected.length > 0) {
2812
+ error(
2813
+ `inspect-dispatch-isolation: ${rejected.join(', ')} ${rejected.length === 1 ? 'is a' : 'are'} recording-only argument${rejected.length === 1 ? '' : 's'} and cannot be used on the inspection verb — it resolves the runtime's DECLARED capability and records nothing. Use 'query dispatch-isolation' if you need the override applied and the decision recorded.`,
2814
+ ERROR_REASON.USAGE,
2815
+ );
2816
+ }
2862
2817
  // #2486: sentinel-free sibling of `dispatch-isolation` for INSPECTION
2863
2818
  // surfaces — /gsd:health's W025 check and /gsd:settings' Worktrees
2864
2819
  // branching. The dispatch verb above intentionally records its resolved
@@ -2892,16 +2847,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2892
2847
  // caller receive a silently different answer, this verb REJECTS the
2893
2848
  // recording-only knobs outright — they exist to be recorded, and a
2894
2849
  // read has nothing to record.
2895
- const RECORDING_ONLY_ARGS = ['--force-isolation', '--phase', '--plan'];
2896
- const rejected = RECORDING_ONLY_ARGS.filter((flag) => args.indexOf(flag) !== -1);
2897
- if (rejected.length > 0) {
2898
- error(
2899
- `inspect-dispatch-isolation: ${rejected.join(', ')} ${rejected.length === 1 ? 'is a' : 'are'} recording-only argument${rejected.length === 1 ? '' : 's'} and cannot be used on the inspection verb — it resolves the runtime's DECLARED capability and records nothing. Use 'query dispatch-isolation' if you need the override applied and the decision recorded.`,
2900
- ERROR_REASON.USAGE,
2901
- );
2902
- }
2903
- const { runtimeId, isolation, exec, harnessFlag } = resolveDispatchIsolationDecision({ args, cwd });
2904
- if (args.indexOf('--json') !== -1) {
2850
+ const { runtimeId, isolation, exec, harnessFlag } = resolveDispatchIsolationDecision({ parsed, cwd });
2851
+ if (parsed.json) {
2905
2852
  output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
2906
2853
  } else {
2907
2854
  process.stdout.write(isolation);
@@ -3052,9 +2999,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3052
2999
  //
3053
3000
  // Output: { recorded: true|false, path, error? }
3054
3001
  const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
3055
- const isoIdx = args.indexOf('--isolation');
3056
- const isolation = isoIdx !== -1 ? args[isoIdx + 1] : undefined;
3057
- if (!isolation || !VALID_ISOLATION.has(isolation)) {
3002
+ const parsed = parseNamedArgsOrExit(args, specFor('record-dispatch-isolation'), error);
3003
+ const isolation = parsed.isolation;
3004
+ if (!VALID_ISOLATION.has(isolation)) {
3058
3005
  error(
3059
3006
  'Usage: record-dispatch-isolation --isolation <harness-worktree|orchestrator-worktree|none> ' +
3060
3007
  '[--harness-flag <flag>|--harness-flag=<flag>] [--phase <n>] [--plan <id>]',
@@ -3077,31 +3024,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3077
3024
  // unambiguously and is never subject to that guard — any future runtime
3078
3025
  // whose registered flag happens to be bare-CLI-shaped benefits the same
3079
3026
  // way Cursor's does.
3080
- let harnessFlag = null;
3081
- const flagEqArg = args.find((a) => a.startsWith('--harness-flag='));
3082
- if (flagEqArg) {
3083
- const value = flagEqArg.slice('--harness-flag='.length);
3084
- harnessFlag = value.length > 0 ? value : null;
3085
- } else {
3086
- const flagIdx = args.indexOf('--harness-flag');
3087
- harnessFlag = flagIdx !== -1 && args[flagIdx + 1] && !args[flagIdx + 1].startsWith('--')
3088
- ? args[flagIdx + 1]
3089
- : null;
3090
- }
3091
- const phaseIdx = args.indexOf('--phase');
3092
- const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
3093
- ? args[phaseIdx + 1]
3094
- : null;
3095
- const planIdx = args.indexOf('--plan');
3096
- const plan = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
3097
- ? args[planIdx + 1]
3098
- : null;
3027
+ const harnessFlag = parsed['harness-flag'] || null;
3028
+ const phase = parsed.phase || null;
3029
+ const plan = parsed.plan || null;
3099
3030
 
3100
3031
  const result = writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase, plan });
3101
3032
  output(result, raw);
3102
3033
  }
3103
3034
 
3104
3035
  function routeResolveDispatchType({ args, cwd, raw, error }) {
3036
+ const parsed = parseNamedArgsOrExit(args, specFor('resolve-dispatch-type'), error);
3037
+ const requested = parsed.requested ?? '';
3105
3038
  // #2508 Phase 4 Option A: resolve a requested GSD subagent name to the
3106
3039
  // type an Agent() call should use on the current runtime. On
3107
3040
  // named-dispatch runtimes (Claude, OpenCode, …) the name is returned
@@ -3113,8 +3046,6 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3113
3046
  // --raw (default) → prints the resolved type (e.g. "gsd-planner" or "plan")
3114
3047
  // --json → prints { runtime, requested, resolved, dispatch }
3115
3048
  try {
3116
- const requestedIdx = args.indexOf('--requested');
3117
- const requested = requestedIdx !== -1 ? args[requestedIdx + 1] : '';
3118
3049
  const { resolveRuntime } = require('./lib/runtime-slash.cjs');
3119
3050
  const runtimeId = resolveRuntime(cwd);
3120
3051
  const registry = require('./lib/capability-registry.cjs');
@@ -3124,8 +3055,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3124
3055
  const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null;
3125
3056
  const hostIntegration = require('./lib/host-integration.cjs');
3126
3057
  const resolved = hostIntegration.resolveDispatchType(requested, dispatch);
3127
- const jsonIdx = args.indexOf('--json');
3128
- if (jsonIdx !== -1) {
3058
+ if (parsed.json) {
3129
3059
  output({ runtime: runtimeId, requested, resolved, dispatch }, raw);
3130
3060
  } else {
3131
3061
  process.stdout.write(String(resolved));
@@ -3134,13 +3064,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3134
3064
  // Fail-closed: on any error, echo the requested name unchanged
3135
3065
  // (named-dispatch is the GSD default; degrading to it preserves
3136
3066
  // behavior for every runtime already in the field).
3137
- const requestedIdx = args.indexOf('--requested');
3138
- const requested = requestedIdx !== -1 ? args[requestedIdx + 1] : '';
3139
3067
  process.stdout.write(String(requested));
3140
3068
  }
3141
3069
  }
3142
3070
 
3143
3071
  function routeResolveAgent({ args, cwd, raw, error }) {
3072
+ const parsed = parseNamedArgsOrExit(args, specFor('resolve-agent'), error);
3144
3073
  // #1689: resolve a per-plan agent_hint specialist name to the subagent_type
3145
3074
  // an Agent() call should use. Returns the name unchanged when a
3146
3075
  // matching agent file exists in the active runtime's agent dir(s);
@@ -3153,8 +3082,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3153
3082
  // --json -> prints { runtime, requested, resolved, fallback }
3154
3083
  const FALLBACK = 'gsd-executor';
3155
3084
  try {
3156
- const nameIdx = args.indexOf('--name');
3157
- const requested = nameIdx !== -1 ? args[nameIdx + 1] : '';
3085
+ const requested = parsed.name ?? '';
3158
3086
  const { resolveRuntime } = require('./lib/runtime-slash.cjs');
3159
3087
  const runtimeId = resolveRuntime(cwd);
3160
3088
  const { resolveAgentHint } = require('./lib/agent-install-check.cjs');
@@ -3171,8 +3099,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3171
3099
  // the named agent did not resolve, or resolution errored). Requesting
3172
3100
  // gsd-executor explicitly and resolving to it is NOT a fallback.
3173
3101
  const fellBack = !resolvedOk;
3174
- const jsonIdx = args.indexOf('--json');
3175
- if (jsonIdx !== -1) {
3102
+ if (parsed.json) {
3176
3103
  output({ runtime: runtimeId, requested: requested || null, resolved, fallback: fellBack }, raw);
3177
3104
  } else {
3178
3105
  process.stdout.write(String(resolved));
@@ -3187,15 +3114,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3187
3114
  function routeAgentSkills({ args, cwd, raw, error }) {
3188
3115
  // --json emits typed IR { agent_type, block, skills_count } for test assertions
3189
3116
  // (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
3190
- const jsonIdx = args.indexOf('--json');
3191
- const agentSkillsJsonMode = jsonIdx !== -1;
3192
- if (agentSkillsJsonMode) args.splice(jsonIdx, 1);
3193
- // #5122: the agent's own self-load asks for configured skills only,
3194
- // never the persona fallback served to orchestrator-side injection.
3195
- const skillsOnlyIdx = args.indexOf('--skills-only');
3196
- const agentSkillsOnly = skillsOnlyIdx !== -1;
3197
- if (agentSkillsOnly) args.splice(skillsOnlyIdx, 1);
3198
- init.cmdAgentSkills(cwd, args[1], raw, agentSkillsJsonMode, agentSkillsOnly);
3117
+ // #5122: `--skills-only` is the agent's own self-load, asking for configured
3118
+ // skills only, never the persona fallback served to orchestrator-side injection.
3119
+ // The agent type is the first free word, whichever side of the flags it sits on.
3120
+ const agentSkillsSpec = specFor('agent-skills');
3121
+ const agentSkillsFlags = parseNamedArgsOrExit(args, agentSkillsSpec, error);
3122
+ init.cmdAgentSkills(cwd, positionalWords(args, agentSkillsSpec)[0], raw, agentSkillsFlags.json, agentSkillsFlags['skills-only']);
3199
3123
  }
3200
3124
 
3201
3125
  function routeSkillManifest({ args, cwd, raw, error }) {
@@ -3203,6 +3127,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3203
3127
  }
3204
3128
 
3205
3129
  function routeHistoryDigest({ args, cwd, raw, error }) {
3130
+ parseNamedArgsOrExit(args, specFor('history-digest'), error);
3206
3131
  commands.cmdHistoryDigest(cwd, raw);
3207
3132
  }
3208
3133
 
@@ -3223,6 +3148,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3223
3148
  // deterministic detectAssumptionDelta, emitting the typed IR as JSON.
3224
3149
  const sub = args[1];
3225
3150
  if (sub === 'scan') {
3151
+ const parsed = parseNamedArgsOrExit(args, specFor('assumption-delta.scan'), error);
3226
3152
  const phaseNum = args[2];
3227
3153
  // Reject missing or flag-shaped phase values (QA matrix: values that
3228
3154
  // look like flags). `scan --json` must not treat "--json" as a phase.
@@ -3236,8 +3162,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3236
3162
  // Terms are normalized (deduped, alphanumeric-only, capped) by
3237
3163
  // detectAssumptionDelta's resolveTerms.
3238
3164
  let termsOverride;
3239
- const termsIdx = args.indexOf('--terms');
3240
- const termsVal = termsIdx !== -1 ? args[termsIdx + 1] : undefined;
3165
+ const termsVal = parsed.terms;
3241
3166
  if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
3242
3167
  const list = termsVal
3243
3168
  .split(',')
@@ -3293,31 +3218,28 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3293
3218
  function routeMilestone({ args, cwd, raw, error }) {
3294
3219
  const subcommand = args[1];
3295
3220
  if (subcommand === 'complete') {
3296
- const milestoneName = parseMultiwordArg(args, 'name');
3221
+ const parsed = parseNamedArgsOrExit(args, specFor('milestone.complete'), error);
3222
+ const milestoneName = parsed.name;
3297
3223
  // #1871: archive phase dirs by default on milestone complete so the next
3298
3224
  // new-milestone never inherits un-archived dirs. --no-archive-phases opts out.
3299
- const archivePhases = !args.includes('--no-archive-phases');
3300
- const force = args.includes('--force');
3225
+ const archivePhases = !parsed['no-archive-phases'];
3226
+ const force = parsed.force;
3301
3227
  // #2118: --dry-run prints a preview plan without mutating.
3302
- const dryRun = args.includes('--dry-run');
3228
+ const dryRun = parsed['dry-run'];
3303
3229
  // #2142: quick-task archival is opt-in (default OFF) — unlike
3304
3230
  // --no-archive-phases' inverted shape, absence of this flag means
3305
3231
  // "do nothing" rather than "skip a default-on behavior".
3306
- const archiveQuick = args.includes('--archive-quick');
3232
+ const archiveQuick = parsed['archive-quick'];
3307
3233
  // #3726: explicit mutation opt-in — without --confirm (and without
3308
3234
  // --dry-run) the command refuses before touching anything. Distinct
3309
3235
  // from --force, which bypasses the narrow scope guards only.
3310
- const confirm = args.includes('--confirm');
3236
+ const confirm = parsed.confirm;
3311
3237
  // #5706 (epic #3628): optional caller-supplied release map file.
3312
- // A flag with no usable value fails loud rather than being dropped.
3313
- let releaseMap = null;
3314
- const releaseMapIdx = args.indexOf('--release-map');
3315
- if (releaseMapIdx !== -1) {
3316
- const releaseMapValue = args[releaseMapIdx + 1];
3317
- if (typeof releaseMapValue !== 'string' || releaseMapValue.startsWith('--') || releaseMapValue.trim() === '') {
3318
- error('--release-map requires a file path (a JSON array of release-map entries)');
3319
- }
3320
- releaseMap = releaseMapValue;
3238
+ // A flag with no usable value fails loud rather than being dropped: the spec reads a
3239
+ // bare --release-map (no value, or a flag-shaped one) as '' and an absent one as null.
3240
+ const releaseMap = parsed['release-map'];
3241
+ if (releaseMap !== null && releaseMap.trim() === '') {
3242
+ error('--release-map requires a file path (a JSON array of release-map entries)');
3321
3243
  }
3322
3244
  milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick, confirm, releaseMap }, raw);
3323
3245
  } else if (subcommand === 'archive-quick') {
@@ -3325,8 +3247,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3325
3247
  // touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion
3326
3248
  // guards) — safe to call against an already-completed milestone,
3327
3249
  // unlike `milestone complete --archive-quick`.
3328
- const dryRun = args.includes('--dry-run');
3329
- milestone.cmdQuickArchive(cwd, args[2], { dryRun }, raw);
3250
+ const parsed = parseNamedArgsOrExit(args, specFor('milestone.archive-quick'), error);
3251
+ milestone.cmdQuickArchive(cwd, args[2], { dryRun: parsed['dry-run'] }, raw);
3330
3252
  } else if (subcommand === 'release-tags') {
3331
3253
  // #5705 (epic #3628 Phase 1): repository-scoped annotated release tags.
3332
3254
  milestoneReleaseTags.cmdMilestoneReleaseTags(cwd, args, raw);
@@ -3336,6 +3258,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3336
3258
  }
3337
3259
 
3338
3260
  function routeProgress({ args, cwd, raw, error }) {
3261
+ parseNamedArgsOrExit(args, specFor('progress.bar'), error);
3339
3262
  const subcommand = args[1] || 'json';
3340
3263
  commands.cmdProgressRender(cwd, subcommand, raw);
3341
3264
  }
@@ -3344,16 +3267,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3344
3267
  const subcommand = args[1];
3345
3268
  if (subcommand === 'render-checkpoint') {
3346
3269
  const uat = require('./lib/uat.cjs');
3347
- const options = parseNamedArgsOrExit(args, { valueFlags: ['file'], positionals: 2 }, error);
3270
+ const options = parseNamedArgsOrExit(args, specFor('uat.render-checkpoint'), error);
3348
3271
  uat.cmdRenderCheckpoint(cwd, options, raw);
3349
3272
  } else if (subcommand === 'classify-coverage') {
3350
3273
  const coverage = require('./lib/coverage.cjs');
3351
- const options = parseNamedArgsOrExit(args, { valueFlags: ['summary', 'file'], positionals: 2 }, error);
3274
+ const options = parseNamedArgsOrExit(args, specFor('uat.classify-coverage'), error);
3352
3275
  coverage.cmdClassify(cwd, options, raw);
3353
3276
  } else if (subcommand === 'complete-session') {
3354
3277
  const uat = require('./lib/uat.cjs');
3355
3278
  const uatPath = args[2];
3356
- const options = parseNamedArgsOrExit(args, { valueFlags: ['message'], positionals: 3 }, error);
3279
+ const options = parseNamedArgsOrExit(args, specFor('uat.complete-session'), error);
3357
3280
  return uat.cmdUatCompleteSession(cwd, uatPath, { message: options.message }, raw);
3358
3281
  } else {
3359
3282
  error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage, complete-session', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -3361,6 +3284,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3361
3284
  }
3362
3285
 
3363
3286
  function routeStats({ args, cwd, raw, error }) {
3287
+ parseNamedArgsOrExit(args, specFor('stats.json'), error);
3364
3288
  const subcommand = args[1] || 'json';
3365
3289
  commands.cmdStats(cwd, subcommand, raw);
3366
3290
  }
@@ -3372,17 +3296,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3372
3296
  // and plumbed (mirroring routeMilestone / #2118), and any OTHER
3373
3297
  // flag fails loudly instead of being silently ignored — an
3374
3298
  // accepted-but-ignored safety flag converts a deliberate preview
3375
- // into the mutation it was meant to avoid. (`--raw` is spliced by
3376
- // the dispatcher before routing; it stays in the set as a guard
3377
- // against that splice ever moving.)
3378
- const TODO_COMPLETE_KNOWN_FLAGS = new Set(['--dry-run', '--raw']);
3379
- for (const a of args.slice(3)) {
3380
- if (typeof a === 'string' && a.startsWith('-') && !TODO_COMPLETE_KNOWN_FLAGS.has(a)) {
3381
- error(`Unknown flag for todo complete: ${a}`, ERROR_REASON.USAGE);
3382
- }
3383
- }
3384
- commands.cmdTodoComplete(cwd, args[2], { dryRun: args.includes('--dry-run') }, raw);
3299
+ // into the mutation it was meant to avoid. The spec's rejectShortFlags
3300
+ // and unknownFlagMessage refuse long and short flags with that wording.
3301
+ const parsed = parseNamedArgsOrExit(args, specFor('todo.complete'), error);
3302
+ commands.cmdTodoComplete(cwd, args[2], { dryRun: parsed['dry-run'] }, raw);
3385
3303
  } else if (subcommand === 'match-phase') {
3304
+ parseNamedArgsOrExit(args, specFor('todo.match-phase'), error);
3386
3305
  commands.cmdTodoMatchPhase(cwd, args[2], raw);
3387
3306
  } else {
3388
3307
  error('Unknown todo subcommand. Available: complete, match-phase', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -3391,14 +3310,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3391
3310
 
3392
3311
  function routeScaffold({ args, cwd, raw, error }) {
3393
3312
  const scaffoldType = args[1];
3394
- // `--name` is multi-word (consumed separately by parseMultiwordArg,
3395
- // below) — a token count the single-token-per-flag boundary walk
3396
- // cannot represent. `positionals: 'rest'` disables that walk for
3397
- // this call, matching the existing (unchanged) permissive behavior
3398
- // for --name; --phase extraction is unaffected either way.
3313
+ // `--name` is multi-word: the spec's `multiwordFlags` collects the words up to the
3314
+ // next flag, a token count the single-token-per-flag boundary walk cannot represent.
3315
+ const scaffoldParsed = parseNamedArgsOrExit(args, specFor('scaffold'), error);
3399
3316
  const scaffoldOptions = {
3400
- phase: parseNamedArgsOrExit(args, { valueFlags: ['phase'], positionals: 'rest' }, error).phase,
3401
- name: parseMultiwordArg(args, 'name'),
3317
+ phase: scaffoldParsed.phase,
3318
+ name: scaffoldParsed.name,
3402
3319
  };
3403
3320
  commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
3404
3321
  }
@@ -3407,74 +3324,20 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3407
3324
  // loop render-hooks <point>
3408
3325
  const loopSubcommand = args[1];
3409
3326
  if (loopSubcommand === 'render-hooks') {
3410
- let loopConfigDir = null;
3411
- const configDirEqArg = args.find(arg => arg.startsWith('--config-dir='));
3412
- const configDirIdx = args.indexOf('--config-dir');
3413
- if (configDirEqArg) {
3414
- const value = configDirEqArg.slice('--config-dir='.length).trim();
3415
- if (!value) error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3416
- loopConfigDir = value;
3417
- } else if (configDirIdx !== -1) {
3418
- const value = args[configDirIdx + 1];
3419
- if (!value || value.startsWith('--')) {
3420
- error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3421
- }
3422
- loopConfigDir = value;
3423
- }
3424
- // --active-cap <capId>: parse and validate before delegating
3425
- let loopActiveCap = undefined;
3426
- const activeCapEqArg = args.find(arg => arg.startsWith('--active-cap='));
3427
- const activeCapIdx = args.indexOf('--active-cap');
3428
- if (activeCapEqArg) {
3429
- const value = activeCapEqArg.slice('--active-cap='.length).trim();
3430
- if (!value) error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3431
- loopActiveCap = value;
3432
- } else if (activeCapIdx !== -1) {
3433
- const value = args[activeCapIdx + 1];
3434
- if (!value || value.startsWith('--')) {
3435
- error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3436
- }
3437
- loopActiveCap = value;
3438
- }
3439
- // --runtime <r> (#2003): explicit runtime override so the config-dir
3440
- // resolution bypasses the persisted-runtime fallback (GSD_RUNTIME →
3441
- // config.runtime). Mirrors the --config-dir dual-form (--runtime X /
3442
- // --runtime=X) and the capability-set --runtime precedent.
3443
- let loopRuntime = undefined;
3444
- const runtimeEqArg = args.find(arg => arg.startsWith('--runtime='));
3445
- const runtimeIdx = args.indexOf('--runtime');
3446
- if (runtimeEqArg) {
3447
- const value = runtimeEqArg.slice('--runtime='.length).trim();
3448
- if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3449
- loopRuntime = value;
3450
- } else if (runtimeIdx !== -1) {
3451
- const value = args[runtimeIdx + 1];
3452
- if (!value || value.startsWith('--')) {
3453
- error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3454
- }
3455
- loopRuntime = value;
3456
- }
3457
- // --after-fingerprint <phaseDir> (#5105 R2): gate out verify:post
3458
- // steps whose declared artifact already exists in phaseDir.
3459
- let loopAfterFingerprint = undefined;
3460
- const afterFpEqArg = args.find(arg => arg.startsWith('--after-fingerprint='));
3461
- const afterFpIdx = args.indexOf('--after-fingerprint');
3462
- if (afterFpEqArg) {
3463
- const value = afterFpEqArg.slice('--after-fingerprint='.length).trim();
3464
- if (!value) error('Missing value for --after-fingerprint', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3465
- loopAfterFingerprint = value;
3466
- } else if (afterFpIdx !== -1) {
3467
- const value = args[afterFpIdx + 1];
3468
- if (!value || value.startsWith('--')) {
3469
- error('Missing value for --after-fingerprint', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3470
- }
3471
- loopAfterFingerprint = value;
3472
- }
3327
+ // --config-dir, --active-cap <capId>, --runtime <r> (#2003: explicit runtime override so
3328
+ // the config-dir resolution bypasses the persisted-runtime fallback) and
3329
+ // --after-fingerprint <phaseDir> (#5105 R2: gate out verify:post steps whose declared
3330
+ // artifact already exists) each take a value in the space or `=` form; the seam refuses
3331
+ // a flag that is present without one, before any of it is used.
3332
+ const loopParsed = parseNamedArgsOrExit(args, specFor('loop.render-hooks'), error);
3333
+ // The spec trims the `=` form (`--runtime= claude` reads as `claude`); a space-form value is as typed.
3334
+ const loopValue = (name) => (loopParsed[name] === null ? undefined : loopParsed[name]);
3335
+ const loopConfigDir = loopValue('config-dir');
3473
3336
  loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {
3474
3337
  configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined,
3475
- activeCap: loopActiveCap,
3476
- runtime: loopRuntime,
3477
- afterFingerprint: loopAfterFingerprint,
3338
+ activeCap: loopValue('active-cap'),
3339
+ runtime: loopValue('runtime'),
3340
+ afterFingerprint: loopValue('after-fingerprint'),
3478
3341
  });
3479
3342
  } else {
3480
3343
  error(
@@ -3485,38 +3348,41 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3485
3348
  }
3486
3349
 
3487
3350
  function routePhasePlanIndex({ args, cwd, raw, error }) {
3351
+ parseNamedArgsOrExit(args, specFor('phase-plan-index'), error);
3488
3352
  phase.cmdPhasePlanIndex(cwd, args[1], raw);
3489
3353
  }
3490
3354
 
3491
3355
  function routeStateSnapshot({ args, cwd, raw, error }) {
3356
+ parseNamedArgsOrExit(args, specFor('state-snapshot'), error);
3492
3357
  state.cmdStateSnapshot(cwd, raw);
3493
3358
  }
3494
3359
 
3495
3360
  function routeSummaryExtract({ args, cwd, raw, error }) {
3361
+ const parsed = parseNamedArgsOrExit(args, specFor('summary-extract'), error);
3496
3362
  const summaryPath = args[1];
3497
- const fieldsIndex = args.indexOf('--fields');
3498
- const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
3363
+ const fields = parsed.fields !== null ? parsed.fields.split(',') : null;
3499
3364
  commands.cmdSummaryExtract(cwd, summaryPath, fields, raw);
3500
3365
  }
3501
3366
 
3502
3367
  async function routeWebsearch({ args, cwd, raw, error }) {
3368
+ const parsed = parseNamedArgsOrExit(args, specFor('websearch'), error);
3503
3369
  const query = args[1];
3504
- const limitIdx = args.indexOf('--limit');
3505
- const freshnessIdx = args.indexOf('--freshness');
3506
3370
  await commands.cmdWebsearch(query, {
3507
- limit: limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10,
3508
- freshness: freshnessIdx !== -1 ? args[freshnessIdx + 1] : null,
3371
+ limit: parsed.limit !== null ? parseInt(parsed.limit, 10) : 10,
3372
+ freshness: parsed.freshness,
3509
3373
  }, raw);
3510
3374
  }
3511
3375
 
3512
3376
  function routeWorkstream({ args, cwd, raw, error }) {
3513
3377
  const subcommand = args[1];
3378
+ const WORKSTREAM_SUBCOMMANDS = ['create', 'list', 'status', 'complete', 'set', 'get', 'progress'];
3379
+ const parsed = WORKSTREAM_SUBCOMMANDS.includes(subcommand)
3380
+ ? parseNamedArgsOrExit(args, specFor(`workstream.${subcommand}`), error)
3381
+ : null;
3514
3382
  if (subcommand === 'create') {
3515
- const migrateNameIdx = args.indexOf('--migrate-name');
3516
- const noMigrate = args.includes('--no-migrate');
3517
3383
  workstream.cmdWorkstreamCreate(cwd, args[2], {
3518
- migrate: !noMigrate,
3519
- migrateName: migrateNameIdx !== -1 ? args[migrateNameIdx + 1] : null,
3384
+ migrate: !parsed['no-migrate'],
3385
+ migrateName: parsed['migrate-name'],
3520
3386
  }, raw);
3521
3387
  } else if (subcommand === 'list') {
3522
3388
  workstream.cmdWorkstreamList(cwd, raw);
@@ -3579,20 +3445,18 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3579
3445
 
3580
3446
  function routeLearnings({ args, cwd, raw, error }) {
3581
3447
  const subcommand = args[1];
3448
+ const LEARNINGS_SUBCOMMANDS = ['list', 'query', 'copy', 'prune', 'delete'];
3449
+ const parsed = LEARNINGS_SUBCOMMANDS.includes(subcommand)
3450
+ ? parseNamedArgsOrExit(args, specFor(`learnings.${subcommand}`), error)
3451
+ : null;
3582
3452
  if (subcommand === 'list') {
3583
3453
  learnings.cmdLearningsList(raw);
3584
3454
  } else if (subcommand === 'query') {
3585
- const tagIdx = args.indexOf('--tag');
3586
- const tag = tagIdx !== -1 ? args[tagIdx + 1] : null;
3587
- if (!tag) error('Usage: gsd-tools learnings query --tag <tag>', ERROR_REASON.USAGE);
3588
- learnings.cmdLearningsQuery(tag, raw);
3455
+ learnings.cmdLearningsQuery(parsed.tag, raw);
3589
3456
  } else if (subcommand === 'copy') {
3590
3457
  learnings.cmdLearningsCopy(cwd, raw);
3591
3458
  } else if (subcommand === 'prune') {
3592
- const olderIdx = args.indexOf('--older-than');
3593
- const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
3594
- if (!olderThan) error('Usage: gsd-tools learnings prune --older-than <duration>', ERROR_REASON.USAGE);
3595
- learnings.cmdLearningsPrune(olderThan, raw);
3459
+ learnings.cmdLearningsPrune(parsed['older-than'], raw);
3596
3460
  } else if (subcommand === 'delete') {
3597
3461
  const id = args[2];
3598
3462
  if (!id) error('Usage: gsd-tools learnings delete <id>', ERROR_REASON.USAGE);
@@ -3644,8 +3508,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3644
3508
  }
3645
3509
 
3646
3510
  function routeTeamsStatus({ args, cwd, raw, error }) {
3511
+ const parsed = parseNamedArgsOrExit(args, specFor('teams-status'), error);
3647
3512
  const teamsStatus = require('./lib/teams-status.cjs');
3648
- teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
3513
+ teamsStatus.cmdTeamsStatus(cwd, { active: parsed.active });
3649
3514
  }
3650
3515
 
3651
3516
  // #3023 follow-up (adversarial review finding): the shared hook bundle's
@@ -3729,11 +3594,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3729
3594
  }
3730
3595
 
3731
3596
  async function routeDetectCustomFiles({ args, cwd, raw, error }) {
3732
- const configDirIdx = args.indexOf('--config-dir');
3733
- const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
3734
- if (!configDir) {
3735
- error('Usage: gsd-tools detect-custom-files --config-dir <path>', ERROR_REASON.USAGE);
3736
- }
3597
+ // `--config-dir` is a required flag of the spec; the seam refuses a missing or empty one.
3598
+ const configDir = parseNamedArgsOrExit(args, specFor('detect-custom-files'), error)['config-dir'];
3737
3599
  const resolvedConfigDir = path.resolve(configDir);
3738
3600
  if (!fs.existsSync(resolvedConfigDir)) {
3739
3601
  error(`Config directory not found: ${resolvedConfigDir}`, ERROR_REASON.USAGE);
@@ -4039,14 +3901,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4039
3901
  function routeRestoreCustomFiles({ args, error }) {
4040
3902
  // Last-wins so a duplicated flag resolves rather than erroring, matching
4041
3903
  // the rest of the gsd-tools flag surface.
4042
- let configDir = null;
4043
- for (let i = 0; i < args.length; i++) {
4044
- if (args[i] !== '--config-dir') continue;
4045
- configDir = args[i + 1] === undefined ? null : args[i + 1];
4046
- }
4047
- const apply = args.includes('--apply');
3904
+ const parsed = parseNamedArgsOrExit(args, specFor('restore-custom-files'), error);
3905
+ const configDirs = parsed['config-dir'];
3906
+ const configDir = configDirs[configDirs.length - 1];
3907
+ const apply = parsed.apply;
4048
3908
 
4049
- if (configDir === null || configDir.trim() === '' || configDir.startsWith('--')) {
3909
+ if (configDir.trim() === '') {
4050
3910
  error('Usage: gsd-tools restore-custom-files --config-dir <path> [--apply]', ERROR_REASON.USAGE);
4051
3911
  }
4052
3912
  const resolvedConfigDir = path.resolve(configDir);
@@ -4194,65 +4054,28 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4194
4054
  async function routePromptBudget({ args, cwd, raw, error }) {
4195
4055
  const promptBudget = require('./lib/prompt-budget.cjs');
4196
4056
 
4197
- // ── Collect multi-value --plan-file flags ──────────────────────────
4198
- const planFiles = [];
4199
- for (let i = 1; i < args.length; i++) {
4200
- if (args[i] === '--plan-file' && args[i + 1] && !args[i + 1].startsWith('--')) {
4201
- planFiles.push(args[i + 1]);
4202
- i++;
4203
- }
4204
- }
4205
-
4206
- // ── Parse single-value flags ───────────────────────────────────────
4207
- const flagMap = new Map();
4208
- for (let i = 1; i < args.length; i++) {
4209
- const current = args[i];
4210
- const next = args[i + 1];
4211
- if (!current.startsWith('--')) continue;
4212
- if (!next || next.startsWith('--')) {
4213
- if (!flagMap.has(current)) flagMap.set(current, null);
4214
- continue;
4215
- }
4216
- if (!flagMap.has(current)) flagMap.set(current, next);
4217
- i++;
4218
- }
4219
- const getFlag = (flag) => flagMap.get(flag) ?? null;
4220
-
4221
- const budgetStr = getFlag('--budget');
4222
- const instructionsFile = getFlag('--instructions-file');
4223
- const roadmapFile = getFlag('--roadmap-file');
4224
- const outputPromptFile = getFlag('--output-prompt');
4225
- const outputMetadataFile = getFlag('--output-metadata');
4226
- const safetyMarginStr = getFlag('--safety-margin-pct');
4227
- const projectMdHeadLinesStr = getFlag('--project-md-head-lines');
4228
- const projectFile = getFlag('--project-file');
4229
- const contextFile = getFlag('--context-file');
4230
- const researchFile = getFlag('--research-file');
4231
- const requirementsFile = getFlag('--requirements-file');
4057
+ // The spec reads every value flag first-wins and `--plan-file` as a repeatable list
4058
+ // (an occurrence without a value contributes nothing).
4059
+ const parsed = parseNamedArgsOrExit(args, specFor('prompt-budget'), error);
4060
+ const planFiles = parsed['plan-file'];
4061
+ const budgetStr = parsed.budget;
4062
+ const instructionsFile = parsed['instructions-file'];
4063
+ const roadmapFile = parsed['roadmap-file'];
4064
+ const outputPromptFile = parsed['output-prompt'];
4065
+ const outputMetadataFile = parsed['output-metadata'];
4066
+ const safetyMarginStr = parsed['safety-margin-pct'] || null;
4067
+ const projectMdHeadLinesStr = parsed['project-md-head-lines'] || null;
4068
+ const projectFile = parsed['project-file'];
4069
+ const contextFile = parsed['context-file'];
4070
+ const researchFile = parsed['research-file'];
4071
+ const requirementsFile = parsed['requirements-file'];
4232
4072
 
4233
4073
  // ── Validate required args ─────────────────────────────────────────
4234
- if (!budgetStr) {
4235
- throw new ExitError(1, 'Error: --budget <N> is required');
4236
- }
4074
+ // Presence of the required flags is enforced by the spec's requiredFlags.
4237
4075
  const budget = parseInt(budgetStr, 10);
4238
4076
  if (!Number.isFinite(budget) || budget <= 0) {
4239
4077
  throw new ExitError(1, 'Error: --budget must be a positive integer');
4240
4078
  }
4241
- if (!instructionsFile) {
4242
- throw new ExitError(1, 'Error: --instructions-file <path> is required');
4243
- }
4244
- if (!roadmapFile) {
4245
- throw new ExitError(1, 'Error: --roadmap-file <path> is required');
4246
- }
4247
- if (planFiles.length === 0) {
4248
- throw new ExitError(1, 'Error: at least one --plan-file <path> is required');
4249
- }
4250
- if (!outputPromptFile) {
4251
- throw new ExitError(1, 'Error: --output-prompt <path> is required');
4252
- }
4253
- if (!outputMetadataFile) {
4254
- throw new ExitError(1, 'Error: --output-metadata <path> is required');
4255
- }
4256
4079
 
4257
4080
  // ── Validate and read required files ──────────────────────────────
4258
4081
  async function readRequired(filePath, flagName) {
@@ -4336,7 +4159,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4336
4159
  // by one or more optional criteria (ANDed together)"); no selector is
4337
4160
  // silently dropped or overridden by another.
4338
4161
  //
4339
- // Flag parsing mirrors routePromptBudget's Map-based flagMap: the three
4162
+ // Flags are read through the CLI surface spec (`context-predicates`): the three
4340
4163
  // known flags are recognized in both the space-separated `--flag value`
4341
4164
  // form and the inline-assignment `--flag=value` form (the latter is the
4342
4165
  // escape hatch for a flag-shaped selector value, e.g. `--contains=--dry-run`
@@ -4344,61 +4167,26 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4344
4167
  // design, since a following `--...` token always reads as a missing value).
4345
4168
  // `--class=` (empty value) and `--class==A` (double-equals typo shape)
4346
4169
  // are rejected the same way under either form. On a duplicate flag the
4347
- // FIRST occurrence wins (`Map.set` only fires when the key is absent),
4348
- // which is deterministic across repeated invocations with identical argv.
4170
+ // FIRST occurrence wins, which is deterministic across repeated invocations
4171
+ // with identical argv.
4349
4172
  //
4350
4173
  // Prototype-pollution safety: selector values are only ever compared via
4351
4174
  // `===`/`.startsWith()`/`.includes()` against ordinary string fields — this
4352
4175
  // route never uses a user-supplied string as an object property key
4353
4176
  // (`obj[userValue] = ...`), so `--class __proto__` / `constructor` /
4354
4177
  // `prototype` are just non-matching ordinary strings, not property-access
4355
- // vectors. `flagMap` itself is a `Map`, immune to prototype pollution by
4356
- // construction.
4178
+ // vectors. The parsed flags are an object keyed only by the spec's own fixed
4179
+ // flag names, never by user input.
4357
4180
  function routeContextPredicates({ args, cwd, raw, error }) {
4358
4181
  const { parsePredicates, selectPredicates } = require('./lib/context-predicates.cjs');
4359
4182
 
4360
- const KNOWN_FLAGS = new Set(['--class', '--prefix', '--contains']);
4361
- const flagMap = new Map();
4362
- for (let i = 1; i < args.length; i++) {
4363
- const current = args[i];
4364
- if (typeof current !== 'string' || !current.startsWith('--')) continue;
4365
-
4366
- // Inline-assignment escape hatch (`--flag=value`, mirrors the `--config-dir=`/
4367
- // `--runtime=` convention in routeUpdateContext elsewhere in this file). This is
4368
- // the ONLY way to pass a flag-shaped selector value (e.g. searching CONTEXT.md
4369
- // for the literal substring "--dry-run"): the space-separated form below always
4370
- // treats a following `--...` token as a missing value, by design, so it has no
4371
- // escape hatch on its own (#2928 review finding C).
4372
- const eqFlag = [...KNOWN_FLAGS].find((f) => current.startsWith(`${f}=`));
4373
- if (eqFlag) {
4374
- const value = current.slice(eqFlag.length + 1);
4375
- // Reject an empty value (`--class=`) and the `--class==A` double-equals typo
4376
- // shape (a value starting with `=`) the same way the pre-existing malformed-
4377
- // assignment behavior did — never silently accept "=A" as a literal value.
4378
- if (value === '' || value.startsWith('=')) {
4379
- error(`context-predicates: ${eqFlag} requires a non-empty value`, ERROR_REASON.USAGE);
4380
- return;
4381
- }
4382
- if (!flagMap.has(eqFlag)) flagMap.set(eqFlag, value);
4383
- continue;
4384
- }
4385
-
4386
- if (!KNOWN_FLAGS.has(current)) {
4387
- error(`Unknown flag for context-predicates: ${current}`, ERROR_REASON.USAGE);
4388
- return;
4389
- }
4390
- const next = args[i + 1];
4391
- if (next === undefined || next.startsWith('--')) {
4392
- if (!flagMap.has(current)) flagMap.set(current, null);
4393
- continue;
4394
- }
4395
- if (!flagMap.has(current)) flagMap.set(current, next);
4396
- i++;
4397
- }
4398
-
4399
- const hasClass = flagMap.has('--class');
4400
- const hasPrefix = flagMap.has('--prefix');
4401
- const hasContains = flagMap.has('--contains');
4183
+ // The spec reads `--flag value` and `--flag=value`, refuses a selector that is present
4184
+ // without a value (a following `--...` token reads as missing, `--class=` is empty), and
4185
+ // keeps the FIRST occurrence of a duplicate.
4186
+ const parsed = parseNamedArgsOrExit(args, specFor('context-predicates'), error);
4187
+ const hasClass = parsed.class !== null;
4188
+ const hasPrefix = parsed.prefix !== null;
4189
+ const hasContains = parsed.contains !== null;
4402
4190
 
4403
4191
  if (!hasClass && !hasPrefix && !hasContains) {
4404
4192
  error(
@@ -4409,30 +4197,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4409
4197
  return;
4410
4198
  }
4411
4199
 
4412
- const requireNonEmpty = (flagName, rawValue) => {
4413
- if (rawValue === null || rawValue === undefined || rawValue.trim() === '') {
4414
- error(`context-predicates: ${flagName} requires a non-empty value`, ERROR_REASON.USAGE);
4415
- return null;
4416
- }
4417
- return rawValue;
4418
- };
4419
-
4420
4200
  const opts = {};
4421
- if (hasClass) {
4422
- const v = requireNonEmpty('--class', flagMap.get('--class'));
4423
- if (v === null) return;
4424
- opts.klass = v;
4425
- }
4426
- if (hasPrefix) {
4427
- const v = requireNonEmpty('--prefix', flagMap.get('--prefix'));
4428
- if (v === null) return;
4429
- opts.prefix = v;
4430
- }
4431
- if (hasContains) {
4432
- const v = requireNonEmpty('--contains', flagMap.get('--contains'));
4433
- if (v === null) return;
4434
- opts.contains = v;
4435
- }
4201
+ if (hasClass) opts.klass = parsed.class;
4202
+ if (hasPrefix) opts.prefix = parsed.prefix;
4203
+ if (hasContains) opts.contains = parsed.contains;
4436
4204
 
4437
4205
  const contextMdPath = path.join(__dirname, '..', '..', 'CONTEXT.md');
4438
4206
  let markdown;
@@ -4456,46 +4224,27 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4456
4224
  // scope, runtime, gsdDir }. Optional --config-dir / --runtime carry the
4457
4225
  // workflow's execution_context hints (the one thing only it can know).
4458
4226
  const { loadUpdateContext } = require('./lib/update-context.cjs');
4459
- const ucArgs = args.slice(1);
4460
- let preferredConfigDir = '';
4461
- let preferredRuntime = '';
4462
- for (let i = 0; i < ucArgs.length; i++) {
4463
- const a = ucArgs[i];
4464
- if (a.startsWith('--config-dir=')) { preferredConfigDir = a.slice('--config-dir='.length); continue; }
4465
- if (a.startsWith('--runtime=')) { preferredRuntime = a.slice('--runtime='.length); continue; }
4466
- if (a === '--config-dir') {
4467
- const v = ucArgs[i + 1];
4468
- if (v === undefined || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
4469
- preferredConfigDir = v; i++; continue;
4470
- }
4471
- if (a === '--runtime') {
4472
- const v = ucArgs[i + 1];
4473
- if (v === undefined || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
4474
- preferredRuntime = v; i++; continue;
4475
- }
4476
- if (a === '--json') continue; // JSON is the only output; accepted for symmetry
4477
- if (a.startsWith('-')) error(`Unknown flag for update-context: ${a}`, ERROR_REASON.USAGE);
4478
- }
4479
- const ctx = loadUpdateContext({ preferredConfigDir, preferredRuntime });
4227
+ // `--json` is declared but unread: JSON is the only output, accepted for symmetry.
4228
+ const ucParsed = parseNamedArgsOrExit(args, specFor('update-context'), error);
4229
+ const ctx = loadUpdateContext({
4230
+ preferredConfigDir: ucParsed['config-dir'] ?? '',
4231
+ preferredRuntime: ucParsed.runtime ?? '',
4232
+ });
4480
4233
  process.stdout.write(JSON.stringify(ctx) + '\n');
4481
4234
  }
4482
4235
 
4483
4236
  async function routeClassifyConfidence({ args, cwd, raw, error }) {
4484
4237
  const researchProvider = require('./lib/research-provider.cjs');
4485
- const providerIdx = args.indexOf('--provider');
4486
- const provider = providerIdx !== -1 ? args[providerIdx + 1] : null;
4487
- if (!provider || provider.startsWith('--')) {
4488
- error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
4489
- }
4490
- const verified = args.includes('--verified');
4491
- const pkgIdx = args.indexOf('--package');
4492
- const pkg = pkgIdx !== -1 ? args[pkgIdx + 1] : null;
4493
- const ecoIdx = args.indexOf('--ecosystem');
4494
- const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null;
4238
+ // `--provider` is a required flag of the spec; the seam refuses a missing or flag-shaped one.
4239
+ const ccParsed = parseNamedArgsOrExit(args, specFor('classify-confidence'), error);
4240
+ const provider = ccParsed.provider;
4241
+ const verified = ccParsed.verified;
4242
+ const pkg = ccParsed.package;
4243
+ const ecosystem = ccParsed.ecosystem;
4495
4244
  let legitimacyVerdict = null;
4496
- if (pkg && (!pkg.startsWith('--'))) {
4245
+ if (pkg) {
4497
4246
  const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
4498
- if (!ecosystem || ecosystem.startsWith('--') || !VALID_ECOSYSTEMS.has(ecosystem)) {
4247
+ if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) {
4499
4248
  error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
4500
4249
  }
4501
4250
  const pkgLegitimacy = require('./lib/package-legitimacy.cjs');
@@ -4512,24 +4261,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4512
4261
  if (subcommand !== 'check') {
4513
4262
  error('Unknown package-legitimacy subcommand. Available: check', ERROR_REASON.SDK_UNKNOWN_COMMAND);
4514
4263
  }
4515
- const ecoIdx = args.indexOf('--ecosystem');
4516
- const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null;
4264
+ // `--ecosystem` is a required flag of the spec; an unknown --flag is a usage error
4265
+ // (it is never skipped along with the next arg).
4266
+ const plSpec = specFor('package-legitimacy.check');
4267
+ const ecosystem = parseNamedArgsOrExit(args, plSpec, error).ecosystem;
4517
4268
  const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
4518
- if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) {
4269
+ if (!VALID_ECOSYSTEMS.has(ecosystem)) {
4519
4270
  error('Usage: gsd-tools package-legitimacy check --ecosystem <npm|pypi|crates> <pkg1> ...', ERROR_REASON.USAGE);
4520
4271
  }
4521
- // Collect positional package names.
4522
- // Only --ecosystem takes a value. Every non-flag arg is a package name.
4523
- // Any unknown --flag is a usage error (do not silently skip+consume the next arg).
4524
- const packages = [];
4525
- for (let i = 2; i < args.length; i++) {
4526
- const a = args[i];
4527
- if (a === '--ecosystem') { i++; continue; }
4528
- if (a.startsWith('--')) {
4529
- error(`package-legitimacy: unknown flag ${a}`, ERROR_REASON.USAGE);
4530
- }
4531
- packages.push(a);
4532
- }
4272
+ // Every non-flag word past `check` that is not the --ecosystem value is a package name.
4273
+ const packages = positionalWords(args, plSpec, 2);
4533
4274
  if (packages.length === 0) {
4534
4275
  error('Usage: gsd-tools package-legitimacy check --ecosystem <eco> <pkg1> <pkg2> ...', ERROR_REASON.USAGE);
4535
4276
  }
@@ -4545,31 +4286,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4545
4286
  function routeEffort({ args, cwd, raw, error }) {
4546
4287
  const subcommand = args[1];
4547
4288
  if (subcommand === 'sync') {
4548
- const effortSyncArgs = args.slice(2);
4549
- let dryRun = true;
4550
- let effortSyncConfigDir;
4551
- let effortSyncRuntime;
4552
- for (let i = 0; i < effortSyncArgs.length; i++) {
4553
- const a = effortSyncArgs[i];
4554
- if (a === '--apply') { dryRun = false; continue; }
4555
- if (a === '--dry-run') { dryRun = true; continue; }
4556
- if (a.startsWith('--config-dir=')) { effortSyncConfigDir = a.slice('--config-dir='.length); continue; }
4557
- if (a === '--config-dir') {
4558
- const v = effortSyncArgs[i + 1];
4559
- if (!v || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
4560
- effortSyncConfigDir = v; i++; continue;
4561
- }
4562
- if (a.startsWith('--runtime=')) { effortSyncRuntime = a.slice('--runtime='.length); continue; }
4563
- if (a === '--runtime') {
4564
- const v = effortSyncArgs[i + 1];
4565
- if (!v || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
4566
- effortSyncRuntime = v; i++; continue;
4567
- }
4568
- if (a === '--raw') continue;
4569
- if (a.startsWith('-')) error(`Unknown flag for effort sync: ${a}`, ERROR_REASON.USAGE);
4570
- error(`effort sync takes no positional arguments; got: ${a}`, ERROR_REASON.USAGE);
4571
- }
4572
- commands.cmdEffortSync(cwd, raw, { dryRun, configDir: effortSyncConfigDir, runtime: effortSyncRuntime });
4289
+ const effortSyncParsed = parseNamedArgsOrExit(args, specFor('effort.sync'), error);
4290
+ // Preview is the default; `--apply` writes. When both are given the preview wins, so a
4291
+ // contradictory argv never writes.
4292
+ const dryRun = !effortSyncParsed.apply || effortSyncParsed['dry-run'];
4293
+ commands.cmdEffortSync(cwd, raw, {
4294
+ dryRun,
4295
+ configDir: effortSyncParsed['config-dir'] ?? undefined,
4296
+ runtime: effortSyncParsed.runtime ?? undefined,
4297
+ });
4573
4298
  } else {
4574
4299
  error('Unknown effort subcommand. Available: sync', ERROR_REASON.SDK_UNKNOWN_COMMAND);
4575
4300
  }
@@ -4582,10 +4307,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4582
4307
  return;
4583
4308
  }
4584
4309
 
4585
- const storyIdx = args.indexOf('--story');
4586
- const story = (storyIdx !== -1 && args[storyIdx + 1] && !args[storyIdx + 1].startsWith('--'))
4587
- ? args[storyIdx + 1]
4588
- : '';
4310
+ const parsed = parseNamedArgsOrExit(args, specFor('user-story.validate'), error);
4311
+
4312
+ const story = parsed.story || '';
4589
4313
 
4590
4314
  // Canonical extraction regex — requires non-whitespace content in each slot
4591
4315
  // (\S.*? ensures the slot isn't whitespace-only).
@@ -4634,6 +4358,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4634
4358
  // drift-guard authority → effective authority string
4635
4359
  // drift-guard severity --status <S> [--authority <A>] → {severity, hardBlock}
4636
4360
  const subcommand = args[1];
4361
+ const DRIFT_GUARD_SUBCOMMANDS = ['authority', 'severity', 'phase-status'];
4362
+ const parsed = DRIFT_GUARD_SUBCOMMANDS.includes(subcommand)
4363
+ ? parseNamedArgsOrExit(args, specFor(`drift-guard.${subcommand}`), error)
4364
+ : null;
4637
4365
 
4638
4366
  // Read config.json directly for both plan_review.source_grounding_authority
4639
4367
  // and intel.enabled. Neither key is in the config-loader.cjs whitelist that
@@ -4644,8 +4372,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4644
4372
  try {
4645
4373
  const { planningDir } = require('./lib/planning-workspace.cjs');
4646
4374
  const cfgPath = require('path').join(planningDir(cwd), 'config.json');
4647
- if (require('fs').existsSync(cfgPath)) {
4648
- const rawCfg = JSON.parse(require('fs').readFileSync(cfgPath, 'utf-8'));
4375
+ // #5759: bounded regular-file read — a FIFO/device/oversized config throws to the
4376
+ // catch below (defaults apply) instead of blocking; null is an absent file.
4377
+ const rawCfgText = require('./lib/config-file-read.cjs').readConfigFileBounded(cfgPath);
4378
+ if (rawCfgText !== null) {
4379
+ const rawCfg = JSON.parse(rawCfgText);
4649
4380
  if (rawCfg && rawCfg.plan_review && rawCfg.plan_review.source_grounding_authority) {
4650
4381
  configuredAuthority = String(rawCfg.plan_review.source_grounding_authority);
4651
4382
  }
@@ -4666,17 +4397,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4666
4397
  }
4667
4398
 
4668
4399
  if (subcommand === 'severity') {
4669
- const statusIdx = args.indexOf('--status');
4670
- const statusVal = statusIdx !== -1 ? args[statusIdx + 1] : undefined;
4671
- if (!statusVal || statusVal.startsWith('--')) {
4672
- error('drift-guard severity requires --status <VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE>', ERROR_REASON.SDK_UNKNOWN_COMMAND);
4673
- return;
4674
- }
4675
- const authIdx = args.indexOf('--authority');
4676
- const authVal = authIdx !== -1 ? args[authIdx + 1] : undefined;
4677
- const authorityForClassify = (authVal && !authVal.startsWith('--'))
4678
- ? authVal
4679
- : effectiveAuthority;
4400
+ const statusVal = parsed.status;
4401
+ const authorityForClassify = parsed.authority || effectiveAuthority;
4680
4402
  const result = classifyDriftSeverity({ status: statusVal, authority: authorityForClassify });
4681
4403
  output(result, raw);
4682
4404
  return;
@@ -4696,10 +4418,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4696
4418
  // is scoped to the body.
4697
4419
  const { stripFrontmatter } = require('./lib/frontmatter.cjs');
4698
4420
 
4699
- const phaseIdx = args.indexOf('--phase');
4700
- const phaseArg = (phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--'))
4701
- ? args[phaseIdx + 1]
4702
- : undefined;
4421
+ const phaseArg = parsed.phase || undefined;
4703
4422
 
4704
4423
  const dir = planningDir(cwd);
4705
4424
  const phaseConvention = resolvePhaseIdConvention(cwd);
@@ -4874,31 +4593,6 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4874
4593
  * declares that file "All OS-facing I/O; single platform seam", and a private
4875
4594
  * duplicate here is what made it untrue.
4876
4595
  */
4877
- const COMMIT_LIST_FLAGS = new Set(['--files', '--files-removed']);
4878
-
4879
- // #4208 review: hoisted out of routeCommit's closure so the parser is reachable
4880
- // from a test. It is the whole of the two-list argument contract, and its edge
4881
- // cases (a boolean flag inside a run, a repeated list flag, either order) were
4882
- // already the subject of a review round -- a parser that only the CLI can reach
4883
- // can only be tested by example, one spawn at a time.
4884
- //
4885
- // Every occurrence of `flag` contributes a run; a run ends at the next LIST
4886
- // flag and skips boolean flags on the way, so no token strictly between one
4887
- // list flag and the next is ever dropped. Repeated runs of the same flag merge,
4888
- // as the pre-#4208 slice-to-end parse merged them.
4889
- function collectListFlagValues(args, flag) {
4890
- const values = [];
4891
- args.forEach((a, i) => {
4892
- if (a !== flag) return;
4893
- for (const b of args.slice(i + 1)) {
4894
- if (COMMIT_LIST_FLAGS.has(b)) break;
4895
- if (b.startsWith('--')) continue;
4896
- values.push(b);
4897
- }
4898
- });
4899
- return values;
4900
- }
4901
-
4902
4596
  function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
4903
4597
  const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
4904
4598
  return resolveExecutableBinary(name, { platform, env });
@@ -4946,12 +4640,12 @@ const HOST_COMMAND_ROUTERS = {
4946
4640
  // contract and this one mutates; ADR-2346 promotes a leaf to a family only at
4947
4641
  // >=3 verbs, and this is one.
4948
4642
  'stamp-codebase-map': ({ args, cwd, raw, error }) => {
4949
- const { files } = parseNamedArgsOrExit(args, { valueFlags: ['files'], positionals: 1 }, error);
4950
- // A value flag with no value parses to `null`, same as an absent one, so
4951
- // presence is read off `args`: a bare `--files` (an unquoted empty shell
4952
- // variable) must hit the empty-filter refusal, not widen to all seven.
4953
- const only = args.includes('--files')
4954
- ? String(files ?? '').split(',').map((f) => f.trim()).filter(Boolean)
4643
+ const { files } = parseNamedArgsOrExit(args, specFor('stamp-codebase-map'), error);
4644
+ // The spec reads a bare `--files` as '' and an absent one as null, so a bare
4645
+ // `--files` (an unquoted empty shell variable) hits the empty-filter refusal
4646
+ // instead of widening to all seven.
4647
+ const only = files !== null
4648
+ ? files.split(',').map((f) => f.trim()).filter(Boolean)
4955
4649
  : undefined;
4956
4650
  verify.cmdStampCodebaseMap(cwd, raw, only);
4957
4651
  },
@@ -4971,6 +4665,7 @@ const HOST_COMMAND_ROUTERS = {
4971
4665
  'commit': routeCommit,
4972
4666
  'check-commit': routeCheckCommit,
4973
4667
  'commit-docs-guard': routeCommitDocsGuard,
4668
+ 'project-context': routeProjectContext,
4974
4669
  'commit-to-subrepo': routeCommitToSubrepo,
4975
4670
  'pr-subrepo': routePrSubrepo,
4976
4671
  'verify-summary': routeVerifySummary,
@@ -5046,7 +4741,7 @@ const HOST_COMMAND_ROUTERS = {
5046
4741
  // through. Prototype-pollution-safe: own-property lookup rejects
5047
4742
  // `__proto__`/`constructor`/`prototype` command keys (same guard as
5048
4743
  // dispatchCapabilityCommand).
5049
- async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd }) {
4744
+ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd, ctx }) {
5050
4745
  if (
5051
4746
  command === '__proto__' ||
5052
4747
  command === 'constructor' ||
@@ -5067,7 +4762,9 @@ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValu
5067
4762
  // every other router legitimately wants via `cwd`. Optional — callers/tests
5068
4763
  // that omit it (every existing one) leave routers that don't read it
5069
4764
  // byte-unaffected.
5070
- await router({ args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd });
4765
+ // `ctx` (#6043): the ProjectContext main() resolved once; routers that take a
4766
+ // named root (commit, project-context) read it, the rest ignore it.
4767
+ await router({ args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd, ctx });
5071
4768
  return true; // consumed — don't emit "Unknown command"
5072
4769
  }
5073
4770
 
@@ -5296,7 +4993,7 @@ function runWithTimeout(argv) {
5296
4993
  // independently hand-maintained sites and nothing previously caught them
5297
4994
  // drifting apart when a query command was added to only one or two.
5298
4995
  const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--project-dir <path>] [--ws <name>] [--json-errors] [--exit-contract=<v>]\n' +
5299
- 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-docs-guard, commit-to-subrepo, pr-subrepo, ' +
4996
+ 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-docs-guard, commit-to-subrepo, pr-subrepo, project-context, ' +
5300
4997
  'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, resolve-package-manager, ' +
5301
4998
  'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
5302
4999
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
@@ -5345,6 +5042,9 @@ const SKIP_ROOT_RESOLUTION = new Set([
5345
5042
  // explicitly via --config-dir; it never reads .planning/.
5346
5043
  'restore-custom-files',
5347
5044
  'worktree', 'prompt-budget',
5045
+ // #6043: project-context prints the resolved ProjectContext itself, so it
5046
+ // needs no remapped cwd; the dispatcher hands every router `ctx`.
5047
+ 'project-context',
5348
5048
  // context-predicates is a pure repo-root CONTEXT.md read (like
5349
5049
  // prompt-budget); it never touches .planning/, so it needs no project
5350
5050
  // root resolution and must work from any cwd (including one with no
@@ -5374,34 +5074,22 @@ function skipsRootResolution(command) {
5374
5074
  }
5375
5075
 
5376
5076
  /**
5377
- * Resolve the worktree root for a given cwd, warning to stderr when git
5378
- * could not determine it (reason 'git_timed_out') rather than silently
5379
- * trusting a best-effort fallback (#3050). Extracted from main() so it can
5380
- * be driven directly in tests via injected deps.
5077
+ * Warn to stderr when git could not determine the worktree root (provenance
5078
+ * reason 'git_timed_out') rather than silently trusting a best-effort
5079
+ * fallback (#3050). Keyed on the resolved ProjectContext (#6043).
5381
5080
  *
5382
- * @param {string} cwd
5383
- * @param {{ existsSync?: (p: string) => boolean, resolveWorktreeRoot?: (cwd: string) => { root: string, reason: string }, writeWarning?: (msg: string) => void }} [deps]
5384
- * @returns {string} resolved cwd
5081
+ * @param {{ worktreeRoot: string, provenance: { reason: string } }} ctx
5082
+ * @param {(msg: string) => void} [writeWarning]
5385
5083
  */
5386
- function resolveMainWorktreeCwd(cwd, deps = {}) {
5387
- const existsSync = deps.existsSync || fs.existsSync;
5388
- const resolveWorktreeRoot = deps.resolveWorktreeRoot || require('./lib/worktree-safety.cjs').resolveWorktreeRoot;
5389
- const writeWarning = deps.writeWarning || ((msg) => process.stderr.write(msg));
5390
-
5391
- if (existsSync(path.join(cwd, '.planning'))) {
5392
- return cwd;
5393
- }
5394
- const { root: worktreeRoot, reason: worktreeRootReason } = resolveWorktreeRoot(cwd);
5395
- if (worktreeRootReason === 'git_timed_out') {
5396
- writeWarning(
5397
- 'WARNING: could not determine the git worktree root (git timed out). ' +
5398
- 'Planning artifacts (STATE.md, ROADMAP.md, etc.) may be written to the ' +
5399
- `wrong tree — proceeding with "${worktreeRoot}" as a best-effort fallback. ` +
5400
- 'Retry the command; if this persists, check for a stalled filesystem mount ' +
5401
- 'or a stale git index lock (.git/index.lock) in this worktree.\n'
5402
- );
5403
- }
5404
- return worktreeRoot;
5084
+ function warnIfGitTimedOut(ctx, writeWarning = (msg) => process.stderr.write(msg)) {
5085
+ if (ctx.provenance.reason !== 'git_timed_out') return;
5086
+ writeWarning(
5087
+ 'WARNING: could not determine the git worktree root (git timed out). ' +
5088
+ 'Planning artifacts (STATE.md, ROADMAP.md, etc.) may be written to the ' +
5089
+ `wrong tree — proceeding with "${ctx.worktreeRoot}" as a best-effort fallback. ` +
5090
+ 'Retry the command; if this persists, check for a stalled filesystem mount ' +
5091
+ 'or a stale git index lock (.git/index.lock) in this worktree.\n'
5092
+ );
5405
5093
  }
5406
5094
 
5407
5095
  // ─── #5118: an out-of-set verification status thrown past a command ─────────
@@ -5453,6 +5141,7 @@ async function main() {
5453
5141
  // The argv splice must happen here too, otherwise the dispatcher below sees
5454
5142
  // "--json-errors" as an unknown command. Default off — human operators keep
5455
5143
  // their plain-text diagnostic.
5144
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5456
5145
  const jsonErrorsIdx = args.indexOf('--json-errors');
5457
5146
  if (jsonErrorsIdx !== -1) {
5458
5147
  setJsonErrorMode(true);
@@ -5476,6 +5165,7 @@ async function main() {
5476
5165
  // survive into the dispatcher and reproduce the same "Unknown command".
5477
5166
  resolveContractVersion({ argv: process.argv, env: process.env });
5478
5167
  for (let i = args.length - 1; i >= 0; i--) {
5168
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5479
5169
  if (typeof args[i] === 'string' && args[i].startsWith('--exit-contract=')) {
5480
5170
  args.splice(i, 1);
5481
5171
  }
@@ -5498,7 +5188,9 @@ async function main() {
5498
5188
 
5499
5189
  // Optional cwd override for sandboxed subagents running outside project root.
5500
5190
  let cwd = process.cwd();
5191
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5501
5192
  const cwdEqArg = args.find(arg => arg.startsWith('--cwd='));
5193
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5502
5194
  const cwdIdx = args.indexOf('--cwd');
5503
5195
  if (cwdEqArg) {
5504
5196
  const value = cwdEqArg.slice('--cwd='.length).trim();
@@ -5524,7 +5216,9 @@ async function main() {
5524
5216
  // runs, and its result must skip that call entirely below. A relative
5525
5217
  // value resolves against process.cwd(), matching --cwd's own resolution.
5526
5218
  let projectDirExplicit = false;
5219
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5527
5220
  const projectDirEqArg = args.find(arg => arg.startsWith('--project-dir='));
5221
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5528
5222
  const projectDirIdx = args.indexOf('--project-dir');
5529
5223
  let projectDirValue;
5530
5224
  if (projectDirEqArg) {
@@ -5548,26 +5242,25 @@ async function main() {
5548
5242
  cwd = resolvedProjectDir;
5549
5243
  projectDirExplicit = true;
5550
5244
  }
5245
+ // #6043 (ADR-4908): the project context is resolved ONCE, here, and every
5246
+ // root a command needs comes from it. `--project-dir` replaces the start
5247
+ // directory for every rule (explicit validated above).
5248
+ //
5249
+ // #4906/#4465: `invokingCwd` stays the raw pre-resolution cwd — a command
5250
+ // whose own git operation must run against the INVOKING worktree's HEAD
5251
+ // (routeSelectRevertCommits) needs a path inside that worktree, never a
5252
+ // remapped root (tests/undo-commit-selection-4465.test.cjs).
5253
+ const preWorktreeRemapCwd = cwd;
5254
+ const ctx = resolveProjectContext(cwd, { explicit: projectDirExplicit ? cwd : null });
5255
+ warnIfGitTimedOut(ctx);
5551
5256
  // #4894: verification derives its root from a phase directory, not `cwd`, so
5552
5257
  // hand it the validated explicit root. Always (re)set — `null` when the flag is
5553
5258
  // absent — so a prior in-process main() call can never leak an override.
5554
- projectRoot.setExplicitProjectRoot(projectDirExplicit ? cwd : null);
5555
-
5556
- // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
5557
- // However, in monorepo worktrees where the subdirectory itself owns .planning/,
5558
- // skip worktree resolution — the CWD is already the correct project root.
5559
- //
5560
- // #4906/#4465: this remap is a BLANKET rewrite applied to every command, before
5561
- // `command` is even known — most commands need it (they read `.planning/`, which
5562
- // in a linked worktree lives only in the main one). A command whose own git
5563
- // operation must run against the INVOKING worktree's OWN HEAD/branch (never the
5564
- // main worktree's) needs the PRE-remap value instead — preserved here so it can be
5565
- // threaded through dispatchHostCommand as `invokingCwd` (see routeSelectRevertCommits,
5566
- // the first consumer: its `git log` must see the caller's own reachable history, not
5567
- // main's, or a linked-worktree revert range silently resolves against the wrong repo
5568
- // state — reproduced in tests/undo-commit-selection-4465.test.cjs's linked-worktree case).
5569
- const preWorktreeRemapCwd = cwd;
5570
- cwd = resolveMainWorktreeCwd(cwd);
5259
+ // Deleted in Phase 2 (#6044) when verification takes the context itself.
5260
+ projectRoot.setExplicitProjectRoot(projectDirExplicit ? ctx.projectRoot : null);
5261
+ // SKIP_ROOT_RESOLUTION commands and workstream resolution keep the value the
5262
+ // retired resolveMainWorktreeCwd produced (Phase 2 migrates them to named fields).
5263
+ cwd = legacyDispatchCwd(ctx, cwd);
5571
5264
 
5572
5265
  // Optional workstream override for parallel milestone work.
5573
5266
  // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
@@ -5596,6 +5289,7 @@ async function main() {
5596
5289
  error(err.message || String(err));
5597
5290
  }
5598
5291
 
5292
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5599
5293
  const rawIndex = args.indexOf('--raw');
5600
5294
  const raw = rawIndex !== -1;
5601
5295
  if (rawIndex !== -1) args.splice(rawIndex, 1);
@@ -5603,6 +5297,7 @@ async function main() {
5603
5297
  // --pick <name>: extract a single field from JSON output (replaces jq dependency).
5604
5298
  // Supports dot-notation (e.g., --pick workflow.research) and bracket notation
5605
5299
  // for arrays (e.g., --pick directories[-1]).
5300
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5606
5301
  const pickIdx = args.indexOf('--pick');
5607
5302
  let pickField = null;
5608
5303
  if (pickIdx !== -1) {
@@ -5614,6 +5309,7 @@ async function main() {
5614
5309
  // --default <value>: for config-get, return this value instead of erroring
5615
5310
  // when the key is absent. Allows workflows to express optional config reads
5616
5311
  // without defensive `2>/dev/null || true` boilerplate (#1893).
5312
+ // eslint-disable-next-line local/named-args-spec-from-registry -- global flags are parsed once in main() and declared in GLOBAL_FLAGS (src/cli-surface.cts)
5617
5313
  const defaultIdx = args.indexOf('--default');
5618
5314
  let defaultValue = undefined;
5619
5315
  if (defaultIdx !== -1) {
@@ -5660,7 +5356,7 @@ async function main() {
5660
5356
  // discovering the command surface via subcommand help requests routed
5661
5357
  // through this dispatcher. Rendering top-level usage on --help is strictly
5662
5358
  // better UX than the old short-circuit that printed unrelated usage text.
5663
- const HELP_FLAGS = new Set(['-h', '--help', '-?', '--h', '--usage']);
5359
+ const HELP_FLAGS = new Set(GLOBAL_FLAGS.helpTokens);
5664
5360
  if (args.some((a) => HELP_FLAGS.has(a))) {
5665
5361
  process.stdout.write(TOP_LEVEL_USAGE + '\n');
5666
5362
  return;
@@ -5669,7 +5365,7 @@ async function main() {
5669
5365
  // Reject version flags. AI agents sometimes hallucinate --version on tool
5670
5366
  // invocations; silently ignoring it can cause destructive operations to
5671
5367
  // proceed unchecked. (Help flags are handled above.)
5672
- const NEVER_VALID_FLAGS = new Set(['--version', '-v']);
5368
+ const NEVER_VALID_FLAGS = new Set(GLOBAL_FLAGS.rejectedTokens);
5673
5369
  for (const arg of args) {
5674
5370
  if (NEVER_VALID_FLAGS.has(arg)) {
5675
5371
  error(`Unknown flag: ${arg}\ngsd-tools does not accept version flags. Run "gsd-tools" with no arguments for usage.`, ERROR_REASON.USAGE);
@@ -5677,10 +5373,9 @@ async function main() {
5677
5373
  }
5678
5374
 
5679
5375
  // #3881: an explicit --project-dir already IS the resolved project root
5680
- // (validated above) — findProjectRoot's ancestor walk-up must not run
5681
- // over it, per docs/CONFIGURATION.md's documented idempotence.
5682
- if (!projectDirExplicit && !SKIP_ROOT_RESOLUTION.has(command)) {
5683
- cwd = findProjectRoot(cwd);
5376
+ // (validated above); the resolver reports it as `explicit` without a walk.
5377
+ if (!SKIP_ROOT_RESOLUTION.has(command)) {
5378
+ cwd = ctx.projectRoot;
5684
5379
  }
5685
5380
 
5686
5381
  // When --pick is active, capture stdout and extract the requested field.
@@ -5692,7 +5387,7 @@ async function main() {
5692
5387
  // false "output was not JSON" (negative space N8).
5693
5388
  if (pickField) {
5694
5389
  const captured = await captureTranslatingVerificationStatus(async () => {
5695
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd);
5390
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd, ctx);
5696
5391
  });
5697
5392
  const resolved = resolveAtFileOutput(captured);
5698
5393
  let obj;
@@ -5724,7 +5419,7 @@ async function main() {
5724
5419
  // every workflow to have a bash-specific `if [[ "$INIT" == @file:* ]]` check
5725
5420
  // that breaks on PowerShell and other non-bash shells.
5726
5421
  const captured = await captureTranslatingVerificationStatus(async () => {
5727
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd);
5422
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd, ctx);
5728
5423
  });
5729
5424
  fs.writeSync(1, resolveAtFileOutput(captured));
5730
5425
  }
@@ -5797,7 +5492,7 @@ function extractField(obj, fieldPath) {
5797
5492
  return { found: true, value: current };
5798
5493
  }
5799
5494
 
5800
- async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null, invokingCwd) {
5495
+ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null, invokingCwd, ctx) {
5801
5496
  switch (command) {
5802
5497
 
5803
5498
  default: {
@@ -5818,7 +5513,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
5818
5513
  // commands (state, …) routed via their `route*Command` router instead of
5819
5514
  // a hardcoded `case` arm. Tried after capability/overlay dispatch and
5820
5515
  // before the unknown-command error.
5821
- if (await dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd })) break;
5516
+ if (await dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd, ctx })) break;
5822
5517
 
5823
5518
  // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
5824
5519
  // above split it so `command` here is the head ("foo"). Use
@@ -5851,7 +5546,13 @@ if (require.main === module) {
5851
5546
  // synthetic registry + requireModule injections.
5852
5547
  // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
5853
5548
  // the third-party overlay dispatch + install-root confinement tests.
5549
+ /** Top-level host command names (fresh array each call). */
5550
+ function hostCommandNames() {
5551
+ return Object.keys(HOST_COMMAND_ROUTERS);
5552
+ }
5553
+
5854
5554
  module.exports = {
5555
+ hostCommandNames,
5855
5556
  dispatchCapabilityCommand,
5856
5557
  dispatchOverlayCapabilityCommand,
5857
5558
  defaultRequireFromInstallRoot,
@@ -5859,14 +5560,10 @@ module.exports = {
5859
5560
  HOST_COMMAND_ROUTERS,
5860
5561
  TOP_LEVEL_USAGE,
5861
5562
  skipsRootResolution,
5862
- resolveMainWorktreeCwd,
5563
+ warnIfGitTimedOut,
5863
5564
  // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
5864
5565
  // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
5865
5566
  resolveSpawnBinary,
5866
- // #4208 review: exported for tests — the two-list commit parser is otherwise
5867
- // reachable only by spawning the CLI, which a property test cannot afford.
5868
- collectListFlagValues,
5869
- COMMIT_LIST_FLAGS,
5870
5567
  // #3714 follow-up: exported for tests — the dispatch model-pin VALUE
5871
5568
  // policy (charset accept/render parity, max-length boundary, leading-char
5872
5569
  // anchor) is otherwise unreachable from outside the dispatchOverlayCapabilityCommand closure.