@opengsd/gsd-core 1.20.0 → 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 (154) 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 +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. 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,22 +2847,62 @@ 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);
2908
2855
  }
2909
2856
  }
2910
2857
 
2858
+ /**
2859
+ * #5682: ALSO record a plan-scoped decision at `.gsd/dispatch-isolation/<phase>--<plan>.json`, so a
2860
+ * second orchestrator's write to the single legacy slot cannot erase it. Same payload, same atomic
2861
+ * tmp+rename, same symlink/containment checks as the legacy directory. Best-effort: any failure is
2862
+ * swallowed so the legacy result is unchanged. The key rule lives in hooks/lib/isolation-sentinel.js
2863
+ * (the reader); if that module is unreachable from this install layout, only the legacy file is written.
2864
+ */
2865
+ function writeKeyedDispatchIsolationRecord(cwd, sentinelDir, payload, clock) {
2866
+ const nodePath = require('path');
2867
+ const nodeFs = require('fs');
2868
+ try {
2869
+ const {
2870
+ keyedSentinelPath, phaseSentinelPath, KEYED_SENTINEL_DIR, AGED_NONE_MAX_MS,
2871
+ } = require('../../hooks/lib/isolation-sentinel.js');
2872
+ // A plan-scoped decision lands at <phase>--<plan>.json; a plan-less one (the phase-level
2873
+ // degrade) at <phase>.json, so concurrent phase-level orchestrators keep separate records.
2874
+ const keyedFile = payload.plan
2875
+ ? keyedSentinelPath(cwd, payload.phase, payload.plan)
2876
+ : phaseSentinelPath(cwd, payload.phase);
2877
+ if (!keyedFile) return;
2878
+ const keyedDir = nodePath.join(cwd, KEYED_SENTINEL_DIR);
2879
+ nodeFs.mkdirSync(keyedDir, { recursive: true });
2880
+ if (nodeFs.lstatSync(keyedDir).isSymbolicLink() ||
2881
+ nodePath.relative(nodeFs.realpathSync(sentinelDir), nodeFs.realpathSync(keyedDir)) !== 'dispatch-isolation') {
2882
+ return;
2883
+ }
2884
+ const tmpPath = `${keyedFile}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
2885
+ try {
2886
+ nodeFs.writeFileSync(tmpPath, JSON.stringify(payload), { flag: 'wx' });
2887
+ nodeFs.renameSync(tmpPath, keyedFile);
2888
+ } catch (writeError) {
2889
+ try { nodeFs.unlinkSync(tmpPath); } catch (_) { /* best-effort cleanup */ }
2890
+ throw writeError;
2891
+ }
2892
+ // Prune records and orphaned temp files older than the longest any record can still govern
2893
+ // (AGED_NONE_MAX_MS, #5479), measured on the injected clock.
2894
+ const cutoff = clock.now() - AGED_NONE_MAX_MS;
2895
+ const written = nodePath.basename(keyedFile);
2896
+ for (const name of nodeFs.readdirSync(keyedDir)) {
2897
+ if (name === written || !(name.endsWith('.json') || name.includes('.json.tmp-'))) continue;
2898
+ try {
2899
+ const file = nodePath.join(keyedDir, name);
2900
+ if (nodeFs.lstatSync(file).mtimeMs < cutoff) nodeFs.unlinkSync(file);
2901
+ } catch (_) { /* best-effort prune */ }
2902
+ }
2903
+ } catch (_) { /* keyed record is best-effort; the legacy record already landed */ }
2904
+ }
2905
+
2911
2906
  /**
2912
2907
  * Atomically persist the resolved dispatch-isolation decision to the
2913
2908
  * run-scoped sentinel both isolation guard hooks read
@@ -2918,7 +2913,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2918
2913
  * per-plan degrade call site and back-compat/tests) share exactly one
2919
2914
  * write implementation. Never throws — returns `{ recorded, path, error? }`.
2920
2915
  */
2921
- function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }) {
2916
+ function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }, { clock = require('./lib/clock.cjs').realClock } = {}) {
2922
2917
  const nodePath = require('path');
2923
2918
  const nodeFs = require('fs');
2924
2919
  const sentinelDir = nodePath.join(cwd, '.gsd');
@@ -2928,77 +2923,61 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2928
2923
  harness_flag: harnessFlag || null,
2929
2924
  phase: phase || null,
2930
2925
  plan: plan || null,
2931
- written_at: Date.now(),
2926
+ written_at: clock.now(),
2932
2927
  };
2928
+ let tmpPath = null;
2933
2929
  try {
2934
- nodeFs.mkdirSync(sentinelDir, { recursive: true });
2935
- // Never write through a project-supplied .gsd symlink. Resolve the
2936
- // directory as well so an ancestor redirect cannot escape this project.
2937
- if (nodeFs.lstatSync(sentinelDir).isSymbolicLink() ||
2938
- nodePath.relative(nodeFs.realpathSync(cwd), nodeFs.realpathSync(sentinelDir)) !== '.gsd') {
2939
- throw new Error('sentinel directory is a symlink or resolves outside the project');
2940
- }
2941
- // User projects do not inherit gsd-core's root .gitignore. Ignore this
2942
- // tool-owned directory locally before writing the first sentinel. An
2943
- // ordinary ignore-file IO failure must not prevent recording isolation.
2944
- const ignorePath = nodePath.join(sentinelDir, '.gitignore');
2930
+ // #5757: the shared tool-owned .gsd owner refuses a project-supplied
2931
+ // symlink (or ancestor redirect) and seeds/extends .gsd/.gitignore, so
2932
+ // this writer and the fork-probe cache apply one policy. An ordinary
2933
+ // ignore-file IO failure must not prevent recording isolation.
2934
+ const toolDir = require('./lib/gsd-tool-dir.cjs');
2945
2935
  try {
2946
- nodeFs.writeFileSync(ignorePath, '*\n', { flag: 'wx' });
2947
- } catch (ignoreError) {
2948
- if (ignoreError.code === 'EEXIST') {
2949
- // Keep a pre-existing ignore file. A bare '*' already ignores the
2950
- // whole directory, including this file and the sentinel temp file.
2951
- // Read through an fd opened without following links where supported;
2952
- // replace by rename rather than append through the path, so a link
2953
- // swapped in after the check cannot redirect a write elsewhere.
2954
- let fd;
2955
- let ignoreTmpPath;
2956
- try {
2957
- if (nodeFs.lstatSync(ignorePath).isSymbolicLink()) {
2958
- throw new Error('sentinel ignore file is a symlink');
2959
- }
2960
- fd = nodeFs.openSync(ignorePath, nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NOFOLLOW || 0));
2961
- if (!nodeFs.fstatSync(fd).isFile()) throw new Error('sentinel ignore file is not a regular file');
2962
- const current = nodeFs.readFileSync(fd, 'utf8');
2963
- nodeFs.closeSync(fd);
2964
- fd = undefined;
2965
- if (!current.includes('# gsd-core dispatch sentinel') && !/^\*\r?$/m.test(current)) {
2966
- const additionalRules = `${current.endsWith('\n') ? '' : '\n'}# gsd-core dispatch sentinel\n` +
2967
- '/dispatch-isolation-sentinel.json\n' +
2968
- '/dispatch-isolation-sentinel.json.tmp-*\n' +
2969
- '/.gitignore\n';
2970
- ignoreTmpPath = `${ignorePath}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
2971
- nodeFs.writeFileSync(ignoreTmpPath, current + additionalRules, { flag: 'wx' });
2972
- if (nodeFs.lstatSync(ignorePath).isSymbolicLink()) {
2973
- throw new Error('sentinel ignore file is a symlink');
2974
- }
2975
- nodeFs.renameSync(ignoreTmpPath, ignorePath);
2976
- ignoreTmpPath = null;
2977
- }
2978
- } catch (existingIgnoreError) {
2979
- if (existingIgnoreError.code === 'ELOOP' || existingIgnoreError.message === 'sentinel ignore file is a symlink') {
2980
- throw new Error('sentinel ignore file is a symlink');
2981
- }
2982
- // Ignore seeding is best-effort; the sentinel is still useful if
2983
- // this path cannot be read or updated.
2984
- } finally {
2985
- if (fd !== undefined) nodeFs.closeSync(fd);
2986
- if (ignoreTmpPath) {
2987
- try { nodeFs.unlinkSync(ignoreTmpPath); } catch (_) { /* best-effort cleanup */ }
2988
- }
2989
- }
2936
+ toolDir.ensureToolOwnedGsdDir(cwd, {
2937
+ marker: '# gsd-core dispatch sentinel',
2938
+ rules: [
2939
+ '/dispatch-isolation-sentinel.json',
2940
+ '/dispatch-isolation-sentinel.json.tmp-*',
2941
+ '/dispatch-isolation/',
2942
+ '/.gitignore',
2943
+ ],
2944
+ });
2945
+ } catch (ownerError) {
2946
+ // The recorded `error` text is observable JSON (hyrums-law): keep the
2947
+ // sentinel's historical wording, translating by the owner's stable code.
2948
+ if (ownerError && ownerError.code === toolDir.GSD_DIR_UNSAFE) {
2949
+ throw new Error('sentinel directory is a symlink or resolves outside the project');
2950
+ }
2951
+ if (ownerError && ownerError.code === toolDir.GSD_IGNORE_SYMLINK) {
2952
+ throw new Error('sentinel ignore file is a symlink');
2990
2953
  }
2954
+ throw ownerError;
2991
2955
  }
2992
- // Atomic write: unique temp file + rename, so a concurrent reader (a
2993
- // guard hook firing mid-write) never observes a partially-written
2994
- // sentinel. Unique per-process+time so concurrent orchestrator-worktree
2995
- // invocations sharing the same sentinelDir never collide on the temp name.
2996
- const tmpPath = `${sentinelPath}.tmp-${process.pid}-${Date.now()}`;
2997
- nodeFs.writeFileSync(tmpPath, JSON.stringify(payload));
2998
- nodeFs.renameSync(tmpPath, sentinelPath);
2956
+ // Atomic write: exclusive random temp file + rename, so a concurrent
2957
+ // reader (a guard hook firing mid-write) never observes a
2958
+ // partially-written sentinel, concurrent invocations sharing the same
2959
+ // sentinelDir never collide on the temp name, and a planted link at a
2960
+ // guessed temp name is never written through.
2961
+ // `tmpPath` is assigned only once the exclusive create succeeded, so an
2962
+ // EEXIST never unlinks an entry this process did not create.
2963
+ const candidateTmp = `${sentinelPath}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
2964
+ const tmpFd = nodeFs.openSync(candidateTmp, 'wx');
2965
+ tmpPath = candidateTmp;
2966
+ try {
2967
+ nodeFs.writeFileSync(tmpFd, JSON.stringify(payload));
2968
+ } finally {
2969
+ nodeFs.closeSync(tmpFd);
2970
+ }
2971
+ require('./lib/shell-command-projection.cjs').retryRenameSync(tmpPath, sentinelPath);
2972
+ tmpPath = null;
2973
+ writeKeyedDispatchIsolationRecord(cwd, sentinelDir, payload, clock);
2999
2974
  return { recorded: true, path: '.gsd/dispatch-isolation-sentinel.json' };
3000
2975
  } catch (err) {
3001
2976
  return { recorded: false, path: '.gsd/dispatch-isolation-sentinel.json', error: err && err.message };
2977
+ } finally {
2978
+ if (tmpPath) {
2979
+ try { nodeFs.unlinkSync(tmpPath); } catch (_) { /* best-effort cleanup */ }
2980
+ }
3002
2981
  }
3003
2982
  }
3004
2983
 
@@ -3020,9 +2999,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3020
2999
  //
3021
3000
  // Output: { recorded: true|false, path, error? }
3022
3001
  const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
3023
- const isoIdx = args.indexOf('--isolation');
3024
- const isolation = isoIdx !== -1 ? args[isoIdx + 1] : undefined;
3025
- 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)) {
3026
3005
  error(
3027
3006
  'Usage: record-dispatch-isolation --isolation <harness-worktree|orchestrator-worktree|none> ' +
3028
3007
  '[--harness-flag <flag>|--harness-flag=<flag>] [--phase <n>] [--plan <id>]',
@@ -3045,31 +3024,17 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3045
3024
  // unambiguously and is never subject to that guard — any future runtime
3046
3025
  // whose registered flag happens to be bare-CLI-shaped benefits the same
3047
3026
  // way Cursor's does.
3048
- let harnessFlag = null;
3049
- const flagEqArg = args.find((a) => a.startsWith('--harness-flag='));
3050
- if (flagEqArg) {
3051
- const value = flagEqArg.slice('--harness-flag='.length);
3052
- harnessFlag = value.length > 0 ? value : null;
3053
- } else {
3054
- const flagIdx = args.indexOf('--harness-flag');
3055
- harnessFlag = flagIdx !== -1 && args[flagIdx + 1] && !args[flagIdx + 1].startsWith('--')
3056
- ? args[flagIdx + 1]
3057
- : null;
3058
- }
3059
- const phaseIdx = args.indexOf('--phase');
3060
- const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
3061
- ? args[phaseIdx + 1]
3062
- : null;
3063
- const planIdx = args.indexOf('--plan');
3064
- const plan = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
3065
- ? args[planIdx + 1]
3066
- : null;
3027
+ const harnessFlag = parsed['harness-flag'] || null;
3028
+ const phase = parsed.phase || null;
3029
+ const plan = parsed.plan || null;
3067
3030
 
3068
3031
  const result = writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase, plan });
3069
3032
  output(result, raw);
3070
3033
  }
3071
3034
 
3072
3035
  function routeResolveDispatchType({ args, cwd, raw, error }) {
3036
+ const parsed = parseNamedArgsOrExit(args, specFor('resolve-dispatch-type'), error);
3037
+ const requested = parsed.requested ?? '';
3073
3038
  // #2508 Phase 4 Option A: resolve a requested GSD subagent name to the
3074
3039
  // type an Agent() call should use on the current runtime. On
3075
3040
  // named-dispatch runtimes (Claude, OpenCode, …) the name is returned
@@ -3081,8 +3046,6 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3081
3046
  // --raw (default) → prints the resolved type (e.g. "gsd-planner" or "plan")
3082
3047
  // --json → prints { runtime, requested, resolved, dispatch }
3083
3048
  try {
3084
- const requestedIdx = args.indexOf('--requested');
3085
- const requested = requestedIdx !== -1 ? args[requestedIdx + 1] : '';
3086
3049
  const { resolveRuntime } = require('./lib/runtime-slash.cjs');
3087
3050
  const runtimeId = resolveRuntime(cwd);
3088
3051
  const registry = require('./lib/capability-registry.cjs');
@@ -3092,8 +3055,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3092
3055
  const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null;
3093
3056
  const hostIntegration = require('./lib/host-integration.cjs');
3094
3057
  const resolved = hostIntegration.resolveDispatchType(requested, dispatch);
3095
- const jsonIdx = args.indexOf('--json');
3096
- if (jsonIdx !== -1) {
3058
+ if (parsed.json) {
3097
3059
  output({ runtime: runtimeId, requested, resolved, dispatch }, raw);
3098
3060
  } else {
3099
3061
  process.stdout.write(String(resolved));
@@ -3102,13 +3064,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3102
3064
  // Fail-closed: on any error, echo the requested name unchanged
3103
3065
  // (named-dispatch is the GSD default; degrading to it preserves
3104
3066
  // behavior for every runtime already in the field).
3105
- const requestedIdx = args.indexOf('--requested');
3106
- const requested = requestedIdx !== -1 ? args[requestedIdx + 1] : '';
3107
3067
  process.stdout.write(String(requested));
3108
3068
  }
3109
3069
  }
3110
3070
 
3111
3071
  function routeResolveAgent({ args, cwd, raw, error }) {
3072
+ const parsed = parseNamedArgsOrExit(args, specFor('resolve-agent'), error);
3112
3073
  // #1689: resolve a per-plan agent_hint specialist name to the subagent_type
3113
3074
  // an Agent() call should use. Returns the name unchanged when a
3114
3075
  // matching agent file exists in the active runtime's agent dir(s);
@@ -3121,8 +3082,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3121
3082
  // --json -> prints { runtime, requested, resolved, fallback }
3122
3083
  const FALLBACK = 'gsd-executor';
3123
3084
  try {
3124
- const nameIdx = args.indexOf('--name');
3125
- const requested = nameIdx !== -1 ? args[nameIdx + 1] : '';
3085
+ const requested = parsed.name ?? '';
3126
3086
  const { resolveRuntime } = require('./lib/runtime-slash.cjs');
3127
3087
  const runtimeId = resolveRuntime(cwd);
3128
3088
  const { resolveAgentHint } = require('./lib/agent-install-check.cjs');
@@ -3139,8 +3099,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3139
3099
  // the named agent did not resolve, or resolution errored). Requesting
3140
3100
  // gsd-executor explicitly and resolving to it is NOT a fallback.
3141
3101
  const fellBack = !resolvedOk;
3142
- const jsonIdx = args.indexOf('--json');
3143
- if (jsonIdx !== -1) {
3102
+ if (parsed.json) {
3144
3103
  output({ runtime: runtimeId, requested: requested || null, resolved, fallback: fellBack }, raw);
3145
3104
  } else {
3146
3105
  process.stdout.write(String(resolved));
@@ -3155,15 +3114,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3155
3114
  function routeAgentSkills({ args, cwd, raw, error }) {
3156
3115
  // --json emits typed IR { agent_type, block, skills_count } for test assertions
3157
3116
  // (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
3158
- const jsonIdx = args.indexOf('--json');
3159
- const agentSkillsJsonMode = jsonIdx !== -1;
3160
- if (agentSkillsJsonMode) args.splice(jsonIdx, 1);
3161
- // #5122: the agent's own self-load asks for configured skills only,
3162
- // never the persona fallback served to orchestrator-side injection.
3163
- const skillsOnlyIdx = args.indexOf('--skills-only');
3164
- const agentSkillsOnly = skillsOnlyIdx !== -1;
3165
- if (agentSkillsOnly) args.splice(skillsOnlyIdx, 1);
3166
- 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']);
3167
3123
  }
3168
3124
 
3169
3125
  function routeSkillManifest({ args, cwd, raw, error }) {
@@ -3171,6 +3127,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3171
3127
  }
3172
3128
 
3173
3129
  function routeHistoryDigest({ args, cwd, raw, error }) {
3130
+ parseNamedArgsOrExit(args, specFor('history-digest'), error);
3174
3131
  commands.cmdHistoryDigest(cwd, raw);
3175
3132
  }
3176
3133
 
@@ -3191,6 +3148,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3191
3148
  // deterministic detectAssumptionDelta, emitting the typed IR as JSON.
3192
3149
  const sub = args[1];
3193
3150
  if (sub === 'scan') {
3151
+ const parsed = parseNamedArgsOrExit(args, specFor('assumption-delta.scan'), error);
3194
3152
  const phaseNum = args[2];
3195
3153
  // Reject missing or flag-shaped phase values (QA matrix: values that
3196
3154
  // look like flags). `scan --json` must not treat "--json" as a phase.
@@ -3204,8 +3162,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3204
3162
  // Terms are normalized (deduped, alphanumeric-only, capped) by
3205
3163
  // detectAssumptionDelta's resolveTerms.
3206
3164
  let termsOverride;
3207
- const termsIdx = args.indexOf('--terms');
3208
- const termsVal = termsIdx !== -1 ? args[termsIdx + 1] : undefined;
3165
+ const termsVal = parsed.terms;
3209
3166
  if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
3210
3167
  const list = termsVal
3211
3168
  .split(',')
@@ -3261,31 +3218,28 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3261
3218
  function routeMilestone({ args, cwd, raw, error }) {
3262
3219
  const subcommand = args[1];
3263
3220
  if (subcommand === 'complete') {
3264
- const milestoneName = parseMultiwordArg(args, 'name');
3221
+ const parsed = parseNamedArgsOrExit(args, specFor('milestone.complete'), error);
3222
+ const milestoneName = parsed.name;
3265
3223
  // #1871: archive phase dirs by default on milestone complete so the next
3266
3224
  // new-milestone never inherits un-archived dirs. --no-archive-phases opts out.
3267
- const archivePhases = !args.includes('--no-archive-phases');
3268
- const force = args.includes('--force');
3225
+ const archivePhases = !parsed['no-archive-phases'];
3226
+ const force = parsed.force;
3269
3227
  // #2118: --dry-run prints a preview plan without mutating.
3270
- const dryRun = args.includes('--dry-run');
3228
+ const dryRun = parsed['dry-run'];
3271
3229
  // #2142: quick-task archival is opt-in (default OFF) — unlike
3272
3230
  // --no-archive-phases' inverted shape, absence of this flag means
3273
3231
  // "do nothing" rather than "skip a default-on behavior".
3274
- const archiveQuick = args.includes('--archive-quick');
3232
+ const archiveQuick = parsed['archive-quick'];
3275
3233
  // #3726: explicit mutation opt-in — without --confirm (and without
3276
3234
  // --dry-run) the command refuses before touching anything. Distinct
3277
3235
  // from --force, which bypasses the narrow scope guards only.
3278
- const confirm = args.includes('--confirm');
3236
+ const confirm = parsed.confirm;
3279
3237
  // #5706 (epic #3628): optional caller-supplied release map file.
3280
- // A flag with no usable value fails loud rather than being dropped.
3281
- let releaseMap = null;
3282
- const releaseMapIdx = args.indexOf('--release-map');
3283
- if (releaseMapIdx !== -1) {
3284
- const releaseMapValue = args[releaseMapIdx + 1];
3285
- if (typeof releaseMapValue !== 'string' || releaseMapValue.startsWith('--') || releaseMapValue.trim() === '') {
3286
- error('--release-map requires a file path (a JSON array of release-map entries)');
3287
- }
3288
- 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)');
3289
3243
  }
3290
3244
  milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases, force, dryRun, archiveQuick, confirm, releaseMap }, raw);
3291
3245
  } else if (subcommand === 'archive-quick') {
@@ -3293,8 +3247,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3293
3247
  // touch ROADMAP/REQUIREMENTS/MILESTONES.md, runs no completion
3294
3248
  // guards) — safe to call against an already-completed milestone,
3295
3249
  // unlike `milestone complete --archive-quick`.
3296
- const dryRun = args.includes('--dry-run');
3297
- 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);
3298
3252
  } else if (subcommand === 'release-tags') {
3299
3253
  // #5705 (epic #3628 Phase 1): repository-scoped annotated release tags.
3300
3254
  milestoneReleaseTags.cmdMilestoneReleaseTags(cwd, args, raw);
@@ -3304,6 +3258,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3304
3258
  }
3305
3259
 
3306
3260
  function routeProgress({ args, cwd, raw, error }) {
3261
+ parseNamedArgsOrExit(args, specFor('progress.bar'), error);
3307
3262
  const subcommand = args[1] || 'json';
3308
3263
  commands.cmdProgressRender(cwd, subcommand, raw);
3309
3264
  }
@@ -3312,16 +3267,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3312
3267
  const subcommand = args[1];
3313
3268
  if (subcommand === 'render-checkpoint') {
3314
3269
  const uat = require('./lib/uat.cjs');
3315
- const options = parseNamedArgsOrExit(args, { valueFlags: ['file'], positionals: 2 }, error);
3270
+ const options = parseNamedArgsOrExit(args, specFor('uat.render-checkpoint'), error);
3316
3271
  uat.cmdRenderCheckpoint(cwd, options, raw);
3317
3272
  } else if (subcommand === 'classify-coverage') {
3318
3273
  const coverage = require('./lib/coverage.cjs');
3319
- const options = parseNamedArgsOrExit(args, { valueFlags: ['summary', 'file'], positionals: 2 }, error);
3274
+ const options = parseNamedArgsOrExit(args, specFor('uat.classify-coverage'), error);
3320
3275
  coverage.cmdClassify(cwd, options, raw);
3321
3276
  } else if (subcommand === 'complete-session') {
3322
3277
  const uat = require('./lib/uat.cjs');
3323
3278
  const uatPath = args[2];
3324
- const options = parseNamedArgsOrExit(args, { valueFlags: ['message'], positionals: 3 }, error);
3279
+ const options = parseNamedArgsOrExit(args, specFor('uat.complete-session'), error);
3325
3280
  return uat.cmdUatCompleteSession(cwd, uatPath, { message: options.message }, raw);
3326
3281
  } else {
3327
3282
  error('Unknown uat subcommand. Available: render-checkpoint, classify-coverage, complete-session', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -3329,6 +3284,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3329
3284
  }
3330
3285
 
3331
3286
  function routeStats({ args, cwd, raw, error }) {
3287
+ parseNamedArgsOrExit(args, specFor('stats.json'), error);
3332
3288
  const subcommand = args[1] || 'json';
3333
3289
  commands.cmdStats(cwd, subcommand, raw);
3334
3290
  }
@@ -3340,17 +3296,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3340
3296
  // and plumbed (mirroring routeMilestone / #2118), and any OTHER
3341
3297
  // flag fails loudly instead of being silently ignored — an
3342
3298
  // accepted-but-ignored safety flag converts a deliberate preview
3343
- // into the mutation it was meant to avoid. (`--raw` is spliced by
3344
- // the dispatcher before routing; it stays in the set as a guard
3345
- // against that splice ever moving.)
3346
- const TODO_COMPLETE_KNOWN_FLAGS = new Set(['--dry-run', '--raw']);
3347
- for (const a of args.slice(3)) {
3348
- if (typeof a === 'string' && a.startsWith('-') && !TODO_COMPLETE_KNOWN_FLAGS.has(a)) {
3349
- error(`Unknown flag for todo complete: ${a}`, ERROR_REASON.USAGE);
3350
- }
3351
- }
3352
- 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);
3353
3303
  } else if (subcommand === 'match-phase') {
3304
+ parseNamedArgsOrExit(args, specFor('todo.match-phase'), error);
3354
3305
  commands.cmdTodoMatchPhase(cwd, args[2], raw);
3355
3306
  } else {
3356
3307
  error('Unknown todo subcommand. Available: complete, match-phase', ERROR_REASON.SDK_UNKNOWN_COMMAND);
@@ -3359,14 +3310,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3359
3310
 
3360
3311
  function routeScaffold({ args, cwd, raw, error }) {
3361
3312
  const scaffoldType = args[1];
3362
- // `--name` is multi-word (consumed separately by parseMultiwordArg,
3363
- // below) — a token count the single-token-per-flag boundary walk
3364
- // cannot represent. `positionals: 'rest'` disables that walk for
3365
- // this call, matching the existing (unchanged) permissive behavior
3366
- // 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);
3367
3316
  const scaffoldOptions = {
3368
- phase: parseNamedArgsOrExit(args, { valueFlags: ['phase'], positionals: 'rest' }, error).phase,
3369
- name: parseMultiwordArg(args, 'name'),
3317
+ phase: scaffoldParsed.phase,
3318
+ name: scaffoldParsed.name,
3370
3319
  };
3371
3320
  commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
3372
3321
  }
@@ -3375,74 +3324,20 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3375
3324
  // loop render-hooks <point>
3376
3325
  const loopSubcommand = args[1];
3377
3326
  if (loopSubcommand === 'render-hooks') {
3378
- let loopConfigDir = null;
3379
- const configDirEqArg = args.find(arg => arg.startsWith('--config-dir='));
3380
- const configDirIdx = args.indexOf('--config-dir');
3381
- if (configDirEqArg) {
3382
- const value = configDirEqArg.slice('--config-dir='.length).trim();
3383
- if (!value) error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3384
- loopConfigDir = value;
3385
- } else if (configDirIdx !== -1) {
3386
- const value = args[configDirIdx + 1];
3387
- if (!value || value.startsWith('--')) {
3388
- error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3389
- }
3390
- loopConfigDir = value;
3391
- }
3392
- // --active-cap <capId>: parse and validate before delegating
3393
- let loopActiveCap = undefined;
3394
- const activeCapEqArg = args.find(arg => arg.startsWith('--active-cap='));
3395
- const activeCapIdx = args.indexOf('--active-cap');
3396
- if (activeCapEqArg) {
3397
- const value = activeCapEqArg.slice('--active-cap='.length).trim();
3398
- if (!value) error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3399
- loopActiveCap = value;
3400
- } else if (activeCapIdx !== -1) {
3401
- const value = args[activeCapIdx + 1];
3402
- if (!value || value.startsWith('--')) {
3403
- error('Missing value for --active-cap (e.g. --active-cap tdd)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3404
- }
3405
- loopActiveCap = value;
3406
- }
3407
- // --runtime <r> (#2003): explicit runtime override so the config-dir
3408
- // resolution bypasses the persisted-runtime fallback (GSD_RUNTIME →
3409
- // config.runtime). Mirrors the --config-dir dual-form (--runtime X /
3410
- // --runtime=X) and the capability-set --runtime precedent.
3411
- let loopRuntime = undefined;
3412
- const runtimeEqArg = args.find(arg => arg.startsWith('--runtime='));
3413
- const runtimeIdx = args.indexOf('--runtime');
3414
- if (runtimeEqArg) {
3415
- const value = runtimeEqArg.slice('--runtime='.length).trim();
3416
- if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3417
- loopRuntime = value;
3418
- } else if (runtimeIdx !== -1) {
3419
- const value = args[runtimeIdx + 1];
3420
- if (!value || value.startsWith('--')) {
3421
- error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3422
- }
3423
- loopRuntime = value;
3424
- }
3425
- // --after-fingerprint <phaseDir> (#5105 R2): gate out verify:post
3426
- // steps whose declared artifact already exists in phaseDir.
3427
- let loopAfterFingerprint = undefined;
3428
- const afterFpEqArg = args.find(arg => arg.startsWith('--after-fingerprint='));
3429
- const afterFpIdx = args.indexOf('--after-fingerprint');
3430
- if (afterFpEqArg) {
3431
- const value = afterFpEqArg.slice('--after-fingerprint='.length).trim();
3432
- if (!value) error('Missing value for --after-fingerprint', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3433
- loopAfterFingerprint = value;
3434
- } else if (afterFpIdx !== -1) {
3435
- const value = args[afterFpIdx + 1];
3436
- if (!value || value.startsWith('--')) {
3437
- error('Missing value for --after-fingerprint', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
3438
- }
3439
- loopAfterFingerprint = value;
3440
- }
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');
3441
3336
  loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {
3442
3337
  configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined,
3443
- activeCap: loopActiveCap,
3444
- runtime: loopRuntime,
3445
- afterFingerprint: loopAfterFingerprint,
3338
+ activeCap: loopValue('active-cap'),
3339
+ runtime: loopValue('runtime'),
3340
+ afterFingerprint: loopValue('after-fingerprint'),
3446
3341
  });
3447
3342
  } else {
3448
3343
  error(
@@ -3453,38 +3348,41 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3453
3348
  }
3454
3349
 
3455
3350
  function routePhasePlanIndex({ args, cwd, raw, error }) {
3351
+ parseNamedArgsOrExit(args, specFor('phase-plan-index'), error);
3456
3352
  phase.cmdPhasePlanIndex(cwd, args[1], raw);
3457
3353
  }
3458
3354
 
3459
3355
  function routeStateSnapshot({ args, cwd, raw, error }) {
3356
+ parseNamedArgsOrExit(args, specFor('state-snapshot'), error);
3460
3357
  state.cmdStateSnapshot(cwd, raw);
3461
3358
  }
3462
3359
 
3463
3360
  function routeSummaryExtract({ args, cwd, raw, error }) {
3361
+ const parsed = parseNamedArgsOrExit(args, specFor('summary-extract'), error);
3464
3362
  const summaryPath = args[1];
3465
- const fieldsIndex = args.indexOf('--fields');
3466
- const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
3363
+ const fields = parsed.fields !== null ? parsed.fields.split(',') : null;
3467
3364
  commands.cmdSummaryExtract(cwd, summaryPath, fields, raw);
3468
3365
  }
3469
3366
 
3470
3367
  async function routeWebsearch({ args, cwd, raw, error }) {
3368
+ const parsed = parseNamedArgsOrExit(args, specFor('websearch'), error);
3471
3369
  const query = args[1];
3472
- const limitIdx = args.indexOf('--limit');
3473
- const freshnessIdx = args.indexOf('--freshness');
3474
3370
  await commands.cmdWebsearch(query, {
3475
- limit: limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10,
3476
- freshness: freshnessIdx !== -1 ? args[freshnessIdx + 1] : null,
3371
+ limit: parsed.limit !== null ? parseInt(parsed.limit, 10) : 10,
3372
+ freshness: parsed.freshness,
3477
3373
  }, raw);
3478
3374
  }
3479
3375
 
3480
3376
  function routeWorkstream({ args, cwd, raw, error }) {
3481
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;
3482
3382
  if (subcommand === 'create') {
3483
- const migrateNameIdx = args.indexOf('--migrate-name');
3484
- const noMigrate = args.includes('--no-migrate');
3485
3383
  workstream.cmdWorkstreamCreate(cwd, args[2], {
3486
- migrate: !noMigrate,
3487
- migrateName: migrateNameIdx !== -1 ? args[migrateNameIdx + 1] : null,
3384
+ migrate: !parsed['no-migrate'],
3385
+ migrateName: parsed['migrate-name'],
3488
3386
  }, raw);
3489
3387
  } else if (subcommand === 'list') {
3490
3388
  workstream.cmdWorkstreamList(cwd, raw);
@@ -3547,20 +3445,18 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3547
3445
 
3548
3446
  function routeLearnings({ args, cwd, raw, error }) {
3549
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;
3550
3452
  if (subcommand === 'list') {
3551
3453
  learnings.cmdLearningsList(raw);
3552
3454
  } else if (subcommand === 'query') {
3553
- const tagIdx = args.indexOf('--tag');
3554
- const tag = tagIdx !== -1 ? args[tagIdx + 1] : null;
3555
- if (!tag) error('Usage: gsd-tools learnings query --tag <tag>', ERROR_REASON.USAGE);
3556
- learnings.cmdLearningsQuery(tag, raw);
3455
+ learnings.cmdLearningsQuery(parsed.tag, raw);
3557
3456
  } else if (subcommand === 'copy') {
3558
3457
  learnings.cmdLearningsCopy(cwd, raw);
3559
3458
  } else if (subcommand === 'prune') {
3560
- const olderIdx = args.indexOf('--older-than');
3561
- const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
3562
- if (!olderThan) error('Usage: gsd-tools learnings prune --older-than <duration>', ERROR_REASON.USAGE);
3563
- learnings.cmdLearningsPrune(olderThan, raw);
3459
+ learnings.cmdLearningsPrune(parsed['older-than'], raw);
3564
3460
  } else if (subcommand === 'delete') {
3565
3461
  const id = args[2];
3566
3462
  if (!id) error('Usage: gsd-tools learnings delete <id>', ERROR_REASON.USAGE);
@@ -3612,8 +3508,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3612
3508
  }
3613
3509
 
3614
3510
  function routeTeamsStatus({ args, cwd, raw, error }) {
3511
+ const parsed = parseNamedArgsOrExit(args, specFor('teams-status'), error);
3615
3512
  const teamsStatus = require('./lib/teams-status.cjs');
3616
- teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
3513
+ teamsStatus.cmdTeamsStatus(cwd, { active: parsed.active });
3617
3514
  }
3618
3515
 
3619
3516
  // #3023 follow-up (adversarial review finding): the shared hook bundle's
@@ -3697,11 +3594,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
3697
3594
  }
3698
3595
 
3699
3596
  async function routeDetectCustomFiles({ args, cwd, raw, error }) {
3700
- const configDirIdx = args.indexOf('--config-dir');
3701
- const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
3702
- if (!configDir) {
3703
- error('Usage: gsd-tools detect-custom-files --config-dir <path>', ERROR_REASON.USAGE);
3704
- }
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'];
3705
3599
  const resolvedConfigDir = path.resolve(configDir);
3706
3600
  if (!fs.existsSync(resolvedConfigDir)) {
3707
3601
  error(`Config directory not found: ${resolvedConfigDir}`, ERROR_REASON.USAGE);
@@ -4007,14 +3901,12 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4007
3901
  function routeRestoreCustomFiles({ args, error }) {
4008
3902
  // Last-wins so a duplicated flag resolves rather than erroring, matching
4009
3903
  // the rest of the gsd-tools flag surface.
4010
- let configDir = null;
4011
- for (let i = 0; i < args.length; i++) {
4012
- if (args[i] !== '--config-dir') continue;
4013
- configDir = args[i + 1] === undefined ? null : args[i + 1];
4014
- }
4015
- 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;
4016
3908
 
4017
- if (configDir === null || configDir.trim() === '' || configDir.startsWith('--')) {
3909
+ if (configDir.trim() === '') {
4018
3910
  error('Usage: gsd-tools restore-custom-files --config-dir <path> [--apply]', ERROR_REASON.USAGE);
4019
3911
  }
4020
3912
  const resolvedConfigDir = path.resolve(configDir);
@@ -4162,65 +4054,28 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4162
4054
  async function routePromptBudget({ args, cwd, raw, error }) {
4163
4055
  const promptBudget = require('./lib/prompt-budget.cjs');
4164
4056
 
4165
- // ── Collect multi-value --plan-file flags ──────────────────────────
4166
- const planFiles = [];
4167
- for (let i = 1; i < args.length; i++) {
4168
- if (args[i] === '--plan-file' && args[i + 1] && !args[i + 1].startsWith('--')) {
4169
- planFiles.push(args[i + 1]);
4170
- i++;
4171
- }
4172
- }
4173
-
4174
- // ── Parse single-value flags ───────────────────────────────────────
4175
- const flagMap = new Map();
4176
- for (let i = 1; i < args.length; i++) {
4177
- const current = args[i];
4178
- const next = args[i + 1];
4179
- if (!current.startsWith('--')) continue;
4180
- if (!next || next.startsWith('--')) {
4181
- if (!flagMap.has(current)) flagMap.set(current, null);
4182
- continue;
4183
- }
4184
- if (!flagMap.has(current)) flagMap.set(current, next);
4185
- i++;
4186
- }
4187
- const getFlag = (flag) => flagMap.get(flag) ?? null;
4188
-
4189
- const budgetStr = getFlag('--budget');
4190
- const instructionsFile = getFlag('--instructions-file');
4191
- const roadmapFile = getFlag('--roadmap-file');
4192
- const outputPromptFile = getFlag('--output-prompt');
4193
- const outputMetadataFile = getFlag('--output-metadata');
4194
- const safetyMarginStr = getFlag('--safety-margin-pct');
4195
- const projectMdHeadLinesStr = getFlag('--project-md-head-lines');
4196
- const projectFile = getFlag('--project-file');
4197
- const contextFile = getFlag('--context-file');
4198
- const researchFile = getFlag('--research-file');
4199
- 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'];
4200
4072
 
4201
4073
  // ── Validate required args ─────────────────────────────────────────
4202
- if (!budgetStr) {
4203
- throw new ExitError(1, 'Error: --budget <N> is required');
4204
- }
4074
+ // Presence of the required flags is enforced by the spec's requiredFlags.
4205
4075
  const budget = parseInt(budgetStr, 10);
4206
4076
  if (!Number.isFinite(budget) || budget <= 0) {
4207
4077
  throw new ExitError(1, 'Error: --budget must be a positive integer');
4208
4078
  }
4209
- if (!instructionsFile) {
4210
- throw new ExitError(1, 'Error: --instructions-file <path> is required');
4211
- }
4212
- if (!roadmapFile) {
4213
- throw new ExitError(1, 'Error: --roadmap-file <path> is required');
4214
- }
4215
- if (planFiles.length === 0) {
4216
- throw new ExitError(1, 'Error: at least one --plan-file <path> is required');
4217
- }
4218
- if (!outputPromptFile) {
4219
- throw new ExitError(1, 'Error: --output-prompt <path> is required');
4220
- }
4221
- if (!outputMetadataFile) {
4222
- throw new ExitError(1, 'Error: --output-metadata <path> is required');
4223
- }
4224
4079
 
4225
4080
  // ── Validate and read required files ──────────────────────────────
4226
4081
  async function readRequired(filePath, flagName) {
@@ -4304,7 +4159,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4304
4159
  // by one or more optional criteria (ANDed together)"); no selector is
4305
4160
  // silently dropped or overridden by another.
4306
4161
  //
4307
- // Flag parsing mirrors routePromptBudget's Map-based flagMap: the three
4162
+ // Flags are read through the CLI surface spec (`context-predicates`): the three
4308
4163
  // known flags are recognized in both the space-separated `--flag value`
4309
4164
  // form and the inline-assignment `--flag=value` form (the latter is the
4310
4165
  // escape hatch for a flag-shaped selector value, e.g. `--contains=--dry-run`
@@ -4312,61 +4167,26 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4312
4167
  // design, since a following `--...` token always reads as a missing value).
4313
4168
  // `--class=` (empty value) and `--class==A` (double-equals typo shape)
4314
4169
  // are rejected the same way under either form. On a duplicate flag the
4315
- // FIRST occurrence wins (`Map.set` only fires when the key is absent),
4316
- // which is deterministic across repeated invocations with identical argv.
4170
+ // FIRST occurrence wins, which is deterministic across repeated invocations
4171
+ // with identical argv.
4317
4172
  //
4318
4173
  // Prototype-pollution safety: selector values are only ever compared via
4319
4174
  // `===`/`.startsWith()`/`.includes()` against ordinary string fields — this
4320
4175
  // route never uses a user-supplied string as an object property key
4321
4176
  // (`obj[userValue] = ...`), so `--class __proto__` / `constructor` /
4322
4177
  // `prototype` are just non-matching ordinary strings, not property-access
4323
- // vectors. `flagMap` itself is a `Map`, immune to prototype pollution by
4324
- // construction.
4178
+ // vectors. The parsed flags are an object keyed only by the spec's own fixed
4179
+ // flag names, never by user input.
4325
4180
  function routeContextPredicates({ args, cwd, raw, error }) {
4326
4181
  const { parsePredicates, selectPredicates } = require('./lib/context-predicates.cjs');
4327
4182
 
4328
- const KNOWN_FLAGS = new Set(['--class', '--prefix', '--contains']);
4329
- const flagMap = new Map();
4330
- for (let i = 1; i < args.length; i++) {
4331
- const current = args[i];
4332
- if (typeof current !== 'string' || !current.startsWith('--')) continue;
4333
-
4334
- // Inline-assignment escape hatch (`--flag=value`, mirrors the `--config-dir=`/
4335
- // `--runtime=` convention in routeUpdateContext elsewhere in this file). This is
4336
- // the ONLY way to pass a flag-shaped selector value (e.g. searching CONTEXT.md
4337
- // for the literal substring "--dry-run"): the space-separated form below always
4338
- // treats a following `--...` token as a missing value, by design, so it has no
4339
- // escape hatch on its own (#2928 review finding C).
4340
- const eqFlag = [...KNOWN_FLAGS].find((f) => current.startsWith(`${f}=`));
4341
- if (eqFlag) {
4342
- const value = current.slice(eqFlag.length + 1);
4343
- // Reject an empty value (`--class=`) and the `--class==A` double-equals typo
4344
- // shape (a value starting with `=`) the same way the pre-existing malformed-
4345
- // assignment behavior did — never silently accept "=A" as a literal value.
4346
- if (value === '' || value.startsWith('=')) {
4347
- error(`context-predicates: ${eqFlag} requires a non-empty value`, ERROR_REASON.USAGE);
4348
- return;
4349
- }
4350
- if (!flagMap.has(eqFlag)) flagMap.set(eqFlag, value);
4351
- continue;
4352
- }
4353
-
4354
- if (!KNOWN_FLAGS.has(current)) {
4355
- error(`Unknown flag for context-predicates: ${current}`, ERROR_REASON.USAGE);
4356
- return;
4357
- }
4358
- const next = args[i + 1];
4359
- if (next === undefined || next.startsWith('--')) {
4360
- if (!flagMap.has(current)) flagMap.set(current, null);
4361
- continue;
4362
- }
4363
- if (!flagMap.has(current)) flagMap.set(current, next);
4364
- i++;
4365
- }
4366
-
4367
- const hasClass = flagMap.has('--class');
4368
- const hasPrefix = flagMap.has('--prefix');
4369
- 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;
4370
4190
 
4371
4191
  if (!hasClass && !hasPrefix && !hasContains) {
4372
4192
  error(
@@ -4377,30 +4197,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4377
4197
  return;
4378
4198
  }
4379
4199
 
4380
- const requireNonEmpty = (flagName, rawValue) => {
4381
- if (rawValue === null || rawValue === undefined || rawValue.trim() === '') {
4382
- error(`context-predicates: ${flagName} requires a non-empty value`, ERROR_REASON.USAGE);
4383
- return null;
4384
- }
4385
- return rawValue;
4386
- };
4387
-
4388
4200
  const opts = {};
4389
- if (hasClass) {
4390
- const v = requireNonEmpty('--class', flagMap.get('--class'));
4391
- if (v === null) return;
4392
- opts.klass = v;
4393
- }
4394
- if (hasPrefix) {
4395
- const v = requireNonEmpty('--prefix', flagMap.get('--prefix'));
4396
- if (v === null) return;
4397
- opts.prefix = v;
4398
- }
4399
- if (hasContains) {
4400
- const v = requireNonEmpty('--contains', flagMap.get('--contains'));
4401
- if (v === null) return;
4402
- opts.contains = v;
4403
- }
4201
+ if (hasClass) opts.klass = parsed.class;
4202
+ if (hasPrefix) opts.prefix = parsed.prefix;
4203
+ if (hasContains) opts.contains = parsed.contains;
4404
4204
 
4405
4205
  const contextMdPath = path.join(__dirname, '..', '..', 'CONTEXT.md');
4406
4206
  let markdown;
@@ -4424,46 +4224,27 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4424
4224
  // scope, runtime, gsdDir }. Optional --config-dir / --runtime carry the
4425
4225
  // workflow's execution_context hints (the one thing only it can know).
4426
4226
  const { loadUpdateContext } = require('./lib/update-context.cjs');
4427
- const ucArgs = args.slice(1);
4428
- let preferredConfigDir = '';
4429
- let preferredRuntime = '';
4430
- for (let i = 0; i < ucArgs.length; i++) {
4431
- const a = ucArgs[i];
4432
- if (a.startsWith('--config-dir=')) { preferredConfigDir = a.slice('--config-dir='.length); continue; }
4433
- if (a.startsWith('--runtime=')) { preferredRuntime = a.slice('--runtime='.length); continue; }
4434
- if (a === '--config-dir') {
4435
- const v = ucArgs[i + 1];
4436
- if (v === undefined || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
4437
- preferredConfigDir = v; i++; continue;
4438
- }
4439
- if (a === '--runtime') {
4440
- const v = ucArgs[i + 1];
4441
- if (v === undefined || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
4442
- preferredRuntime = v; i++; continue;
4443
- }
4444
- if (a === '--json') continue; // JSON is the only output; accepted for symmetry
4445
- if (a.startsWith('-')) error(`Unknown flag for update-context: ${a}`, ERROR_REASON.USAGE);
4446
- }
4447
- 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
+ });
4448
4233
  process.stdout.write(JSON.stringify(ctx) + '\n');
4449
4234
  }
4450
4235
 
4451
4236
  async function routeClassifyConfidence({ args, cwd, raw, error }) {
4452
4237
  const researchProvider = require('./lib/research-provider.cjs');
4453
- const providerIdx = args.indexOf('--provider');
4454
- const provider = providerIdx !== -1 ? args[providerIdx + 1] : null;
4455
- if (!provider || provider.startsWith('--')) {
4456
- error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
4457
- }
4458
- const verified = args.includes('--verified');
4459
- const pkgIdx = args.indexOf('--package');
4460
- const pkg = pkgIdx !== -1 ? args[pkgIdx + 1] : null;
4461
- const ecoIdx = args.indexOf('--ecosystem');
4462
- 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;
4463
4244
  let legitimacyVerdict = null;
4464
- if (pkg && (!pkg.startsWith('--'))) {
4245
+ if (pkg) {
4465
4246
  const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
4466
- if (!ecosystem || ecosystem.startsWith('--') || !VALID_ECOSYSTEMS.has(ecosystem)) {
4247
+ if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) {
4467
4248
  error('Usage: gsd-tools query classify-confidence --provider <id> [--package <name> --ecosystem <npm|pypi|crates>] [--verified]', ERROR_REASON.USAGE);
4468
4249
  }
4469
4250
  const pkgLegitimacy = require('./lib/package-legitimacy.cjs');
@@ -4480,24 +4261,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4480
4261
  if (subcommand !== 'check') {
4481
4262
  error('Unknown package-legitimacy subcommand. Available: check', ERROR_REASON.SDK_UNKNOWN_COMMAND);
4482
4263
  }
4483
- const ecoIdx = args.indexOf('--ecosystem');
4484
- 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;
4485
4268
  const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']);
4486
- if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) {
4269
+ if (!VALID_ECOSYSTEMS.has(ecosystem)) {
4487
4270
  error('Usage: gsd-tools package-legitimacy check --ecosystem <npm|pypi|crates> <pkg1> ...', ERROR_REASON.USAGE);
4488
4271
  }
4489
- // Collect positional package names.
4490
- // Only --ecosystem takes a value. Every non-flag arg is a package name.
4491
- // Any unknown --flag is a usage error (do not silently skip+consume the next arg).
4492
- const packages = [];
4493
- for (let i = 2; i < args.length; i++) {
4494
- const a = args[i];
4495
- if (a === '--ecosystem') { i++; continue; }
4496
- if (a.startsWith('--')) {
4497
- error(`package-legitimacy: unknown flag ${a}`, ERROR_REASON.USAGE);
4498
- }
4499
- packages.push(a);
4500
- }
4272
+ // Every non-flag word past `check` that is not the --ecosystem value is a package name.
4273
+ const packages = positionalWords(args, plSpec, 2);
4501
4274
  if (packages.length === 0) {
4502
4275
  error('Usage: gsd-tools package-legitimacy check --ecosystem <eco> <pkg1> <pkg2> ...', ERROR_REASON.USAGE);
4503
4276
  }
@@ -4513,31 +4286,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4513
4286
  function routeEffort({ args, cwd, raw, error }) {
4514
4287
  const subcommand = args[1];
4515
4288
  if (subcommand === 'sync') {
4516
- const effortSyncArgs = args.slice(2);
4517
- let dryRun = true;
4518
- let effortSyncConfigDir;
4519
- let effortSyncRuntime;
4520
- for (let i = 0; i < effortSyncArgs.length; i++) {
4521
- const a = effortSyncArgs[i];
4522
- if (a === '--apply') { dryRun = false; continue; }
4523
- if (a === '--dry-run') { dryRun = true; continue; }
4524
- if (a.startsWith('--config-dir=')) { effortSyncConfigDir = a.slice('--config-dir='.length); continue; }
4525
- if (a === '--config-dir') {
4526
- const v = effortSyncArgs[i + 1];
4527
- if (!v || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
4528
- effortSyncConfigDir = v; i++; continue;
4529
- }
4530
- if (a.startsWith('--runtime=')) { effortSyncRuntime = a.slice('--runtime='.length); continue; }
4531
- if (a === '--runtime') {
4532
- const v = effortSyncArgs[i + 1];
4533
- if (!v || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
4534
- effortSyncRuntime = v; i++; continue;
4535
- }
4536
- if (a === '--raw') continue;
4537
- if (a.startsWith('-')) error(`Unknown flag for effort sync: ${a}`, ERROR_REASON.USAGE);
4538
- error(`effort sync takes no positional arguments; got: ${a}`, ERROR_REASON.USAGE);
4539
- }
4540
- 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
+ });
4541
4298
  } else {
4542
4299
  error('Unknown effort subcommand. Available: sync', ERROR_REASON.SDK_UNKNOWN_COMMAND);
4543
4300
  }
@@ -4550,10 +4307,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4550
4307
  return;
4551
4308
  }
4552
4309
 
4553
- const storyIdx = args.indexOf('--story');
4554
- const story = (storyIdx !== -1 && args[storyIdx + 1] && !args[storyIdx + 1].startsWith('--'))
4555
- ? args[storyIdx + 1]
4556
- : '';
4310
+ const parsed = parseNamedArgsOrExit(args, specFor('user-story.validate'), error);
4311
+
4312
+ const story = parsed.story || '';
4557
4313
 
4558
4314
  // Canonical extraction regex — requires non-whitespace content in each slot
4559
4315
  // (\S.*? ensures the slot isn't whitespace-only).
@@ -4602,6 +4358,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4602
4358
  // drift-guard authority → effective authority string
4603
4359
  // drift-guard severity --status <S> [--authority <A>] → {severity, hardBlock}
4604
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;
4605
4365
 
4606
4366
  // Read config.json directly for both plan_review.source_grounding_authority
4607
4367
  // and intel.enabled. Neither key is in the config-loader.cjs whitelist that
@@ -4612,8 +4372,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4612
4372
  try {
4613
4373
  const { planningDir } = require('./lib/planning-workspace.cjs');
4614
4374
  const cfgPath = require('path').join(planningDir(cwd), 'config.json');
4615
- if (require('fs').existsSync(cfgPath)) {
4616
- 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);
4617
4380
  if (rawCfg && rawCfg.plan_review && rawCfg.plan_review.source_grounding_authority) {
4618
4381
  configuredAuthority = String(rawCfg.plan_review.source_grounding_authority);
4619
4382
  }
@@ -4634,17 +4397,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4634
4397
  }
4635
4398
 
4636
4399
  if (subcommand === 'severity') {
4637
- const statusIdx = args.indexOf('--status');
4638
- const statusVal = statusIdx !== -1 ? args[statusIdx + 1] : undefined;
4639
- if (!statusVal || statusVal.startsWith('--')) {
4640
- error('drift-guard severity requires --status <VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE>', ERROR_REASON.SDK_UNKNOWN_COMMAND);
4641
- return;
4642
- }
4643
- const authIdx = args.indexOf('--authority');
4644
- const authVal = authIdx !== -1 ? args[authIdx + 1] : undefined;
4645
- const authorityForClassify = (authVal && !authVal.startsWith('--'))
4646
- ? authVal
4647
- : effectiveAuthority;
4400
+ const statusVal = parsed.status;
4401
+ const authorityForClassify = parsed.authority || effectiveAuthority;
4648
4402
  const result = classifyDriftSeverity({ status: statusVal, authority: authorityForClassify });
4649
4403
  output(result, raw);
4650
4404
  return;
@@ -4652,7 +4406,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4652
4406
 
4653
4407
  if (subcommand === 'phase-status') {
4654
4408
  // #1956: deterministic STATE.md-vs-ROADMAP.md phase-status drift.
4655
- const { planningDir } = require('./lib/planning-workspace.cjs');
4409
+ const { planningDir, resolvePhaseIdConvention } = require('./lib/planning-workspace.cjs');
4656
4410
  const { stateExtractField, stateCurrentPositionSlice } = require('./lib/state-document.cjs');
4657
4411
  const { locateRoadmapProgress } = require('./lib/roadmap-progress.cjs');
4658
4412
  const { phaseKeyFromProse } = require('./lib/phase-id.cjs');
@@ -4664,12 +4418,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4664
4418
  // is scoped to the body.
4665
4419
  const { stripFrontmatter } = require('./lib/frontmatter.cjs');
4666
4420
 
4667
- const phaseIdx = args.indexOf('--phase');
4668
- const phaseArg = (phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--'))
4669
- ? args[phaseIdx + 1]
4670
- : undefined;
4421
+ const phaseArg = parsed.phase || undefined;
4671
4422
 
4672
4423
  const dir = planningDir(cwd);
4424
+ const phaseConvention = resolvePhaseIdConvention(cwd);
4425
+ const proseKey = (value) => phaseKeyFromProse(value, undefined, phaseConvention);
4673
4426
  const statePath = path.join(dir, 'STATE.md');
4674
4427
  const roadmapPath = path.join(dir, 'ROADMAP.md');
4675
4428
 
@@ -4680,7 +4433,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4680
4433
  // missing_state below
4681
4434
  }
4682
4435
  if (stateContent === null) {
4683
- const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
4436
+ const phase = phaseArg !== undefined ? proseKey(phaseArg) : null;
4684
4437
  output({
4685
4438
  verdict: 'uncheckable',
4686
4439
  reason: 'missing_state',
@@ -4720,7 +4473,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4720
4473
  // document.
4721
4474
  const currentPositionBody = stateCurrentPositionSlice(stateBody);
4722
4475
  if (currentPositionBody === null) {
4723
- const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
4476
+ const phase = phaseArg !== undefined ? proseKey(phaseArg) : null;
4724
4477
  output({
4725
4478
  verdict: 'uncheckable',
4726
4479
  reason: 'no_current_position',
@@ -4735,8 +4488,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4735
4488
  // Resolve the target phase: --phase if given, else whatever
4736
4489
  // STATE.md's Current Position reports as current.
4737
4490
  const phase = phaseArg !== undefined
4738
- ? phaseKeyFromProse(phaseArg)
4739
- : phaseKeyFromProse(stateExtractField(currentPositionBody, 'Phase'));
4491
+ ? proseKey(phaseArg)
4492
+ : proseKey(stateExtractField(currentPositionBody, 'Phase'));
4740
4493
 
4741
4494
  if (roadmapContent === null) {
4742
4495
  output({
@@ -4756,7 +4509,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4756
4509
  // Progress owner's rule (roadmap-progress.cts), shared with every reader and writer (#5762).
4757
4510
  const { table } = locateRoadmapProgress(roadmapContent);
4758
4511
  const matchedRow = table
4759
- ? table.rows.find((row) => phaseKeyFromProse(row.Phase) === phase && phase !== null)
4512
+ ? table.rows.find((row) => proseKey(row.Phase) === phase && phase !== null)
4760
4513
  : undefined;
4761
4514
 
4762
4515
  if (!matchedRow) {
@@ -4840,31 +4593,6 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4840
4593
  * declares that file "All OS-facing I/O; single platform seam", and a private
4841
4594
  * duplicate here is what made it untrue.
4842
4595
  */
4843
- const COMMIT_LIST_FLAGS = new Set(['--files', '--files-removed']);
4844
-
4845
- // #4208 review: hoisted out of routeCommit's closure so the parser is reachable
4846
- // from a test. It is the whole of the two-list argument contract, and its edge
4847
- // cases (a boolean flag inside a run, a repeated list flag, either order) were
4848
- // already the subject of a review round -- a parser that only the CLI can reach
4849
- // can only be tested by example, one spawn at a time.
4850
- //
4851
- // Every occurrence of `flag` contributes a run; a run ends at the next LIST
4852
- // flag and skips boolean flags on the way, so no token strictly between one
4853
- // list flag and the next is ever dropped. Repeated runs of the same flag merge,
4854
- // as the pre-#4208 slice-to-end parse merged them.
4855
- function collectListFlagValues(args, flag) {
4856
- const values = [];
4857
- args.forEach((a, i) => {
4858
- if (a !== flag) return;
4859
- for (const b of args.slice(i + 1)) {
4860
- if (COMMIT_LIST_FLAGS.has(b)) break;
4861
- if (b.startsWith('--')) continue;
4862
- values.push(b);
4863
- }
4864
- });
4865
- return values;
4866
- }
4867
-
4868
4596
  function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
4869
4597
  const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
4870
4598
  return resolveExecutableBinary(name, { platform, env });
@@ -4912,12 +4640,12 @@ const HOST_COMMAND_ROUTERS = {
4912
4640
  // contract and this one mutates; ADR-2346 promotes a leaf to a family only at
4913
4641
  // >=3 verbs, and this is one.
4914
4642
  'stamp-codebase-map': ({ args, cwd, raw, error }) => {
4915
- const { files } = parseNamedArgsOrExit(args, { valueFlags: ['files'], positionals: 1 }, error);
4916
- // A value flag with no value parses to `null`, same as an absent one, so
4917
- // presence is read off `args`: a bare `--files` (an unquoted empty shell
4918
- // variable) must hit the empty-filter refusal, not widen to all seven.
4919
- const only = args.includes('--files')
4920
- ? 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)
4921
4649
  : undefined;
4922
4650
  verify.cmdStampCodebaseMap(cwd, raw, only);
4923
4651
  },
@@ -4937,6 +4665,7 @@ const HOST_COMMAND_ROUTERS = {
4937
4665
  'commit': routeCommit,
4938
4666
  'check-commit': routeCheckCommit,
4939
4667
  'commit-docs-guard': routeCommitDocsGuard,
4668
+ 'project-context': routeProjectContext,
4940
4669
  'commit-to-subrepo': routeCommitToSubrepo,
4941
4670
  'pr-subrepo': routePrSubrepo,
4942
4671
  'verify-summary': routeVerifySummary,
@@ -5012,7 +4741,7 @@ const HOST_COMMAND_ROUTERS = {
5012
4741
  // through. Prototype-pollution-safe: own-property lookup rejects
5013
4742
  // `__proto__`/`constructor`/`prototype` command keys (same guard as
5014
4743
  // dispatchCapabilityCommand).
5015
- async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd }) {
4744
+ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValue, workstreamContext, invokingCwd, ctx }) {
5016
4745
  if (
5017
4746
  command === '__proto__' ||
5018
4747
  command === 'constructor' ||
@@ -5033,7 +4762,9 @@ async function dispatchHostCommand({ command, args, cwd, raw, error, defaultValu
5033
4762
  // every other router legitimately wants via `cwd`. Optional — callers/tests
5034
4763
  // that omit it (every existing one) leave routers that don't read it
5035
4764
  // byte-unaffected.
5036
- 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 });
5037
4768
  return true; // consumed — don't emit "Unknown command"
5038
4769
  }
5039
4770
 
@@ -5262,7 +4993,7 @@ function runWithTimeout(argv) {
5262
4993
  // independently hand-maintained sites and nothing previously caught them
5263
4994
  // drifting apart when a query command was added to only one or two.
5264
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' +
5265
- '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, ' +
5266
4997
  'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, resolve-package-manager, ' +
5267
4998
  'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
5268
4999
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
@@ -5311,6 +5042,9 @@ const SKIP_ROOT_RESOLUTION = new Set([
5311
5042
  // explicitly via --config-dir; it never reads .planning/.
5312
5043
  'restore-custom-files',
5313
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',
5314
5048
  // context-predicates is a pure repo-root CONTEXT.md read (like
5315
5049
  // prompt-budget); it never touches .planning/, so it needs no project
5316
5050
  // root resolution and must work from any cwd (including one with no
@@ -5340,34 +5074,22 @@ function skipsRootResolution(command) {
5340
5074
  }
5341
5075
 
5342
5076
  /**
5343
- * Resolve the worktree root for a given cwd, warning to stderr when git
5344
- * could not determine it (reason 'git_timed_out') rather than silently
5345
- * trusting a best-effort fallback (#3050). Extracted from main() so it can
5346
- * 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).
5347
5080
  *
5348
- * @param {string} cwd
5349
- * @param {{ existsSync?: (p: string) => boolean, resolveWorktreeRoot?: (cwd: string) => { root: string, reason: string }, writeWarning?: (msg: string) => void }} [deps]
5350
- * @returns {string} resolved cwd
5081
+ * @param {{ worktreeRoot: string, provenance: { reason: string } }} ctx
5082
+ * @param {(msg: string) => void} [writeWarning]
5351
5083
  */
5352
- function resolveMainWorktreeCwd(cwd, deps = {}) {
5353
- const existsSync = deps.existsSync || fs.existsSync;
5354
- const resolveWorktreeRoot = deps.resolveWorktreeRoot || require('./lib/worktree-safety.cjs').resolveWorktreeRoot;
5355
- const writeWarning = deps.writeWarning || ((msg) => process.stderr.write(msg));
5356
-
5357
- if (existsSync(path.join(cwd, '.planning'))) {
5358
- return cwd;
5359
- }
5360
- const { root: worktreeRoot, reason: worktreeRootReason } = resolveWorktreeRoot(cwd);
5361
- if (worktreeRootReason === 'git_timed_out') {
5362
- writeWarning(
5363
- 'WARNING: could not determine the git worktree root (git timed out). ' +
5364
- 'Planning artifacts (STATE.md, ROADMAP.md, etc.) may be written to the ' +
5365
- `wrong tree — proceeding with "${worktreeRoot}" as a best-effort fallback. ` +
5366
- 'Retry the command; if this persists, check for a stalled filesystem mount ' +
5367
- 'or a stale git index lock (.git/index.lock) in this worktree.\n'
5368
- );
5369
- }
5370
- 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
+ );
5371
5093
  }
5372
5094
 
5373
5095
  // ─── #5118: an out-of-set verification status thrown past a command ─────────
@@ -5419,6 +5141,7 @@ async function main() {
5419
5141
  // The argv splice must happen here too, otherwise the dispatcher below sees
5420
5142
  // "--json-errors" as an unknown command. Default off — human operators keep
5421
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)
5422
5145
  const jsonErrorsIdx = args.indexOf('--json-errors');
5423
5146
  if (jsonErrorsIdx !== -1) {
5424
5147
  setJsonErrorMode(true);
@@ -5442,6 +5165,7 @@ async function main() {
5442
5165
  // survive into the dispatcher and reproduce the same "Unknown command".
5443
5166
  resolveContractVersion({ argv: process.argv, env: process.env });
5444
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)
5445
5169
  if (typeof args[i] === 'string' && args[i].startsWith('--exit-contract=')) {
5446
5170
  args.splice(i, 1);
5447
5171
  }
@@ -5464,7 +5188,9 @@ async function main() {
5464
5188
 
5465
5189
  // Optional cwd override for sandboxed subagents running outside project root.
5466
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)
5467
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)
5468
5194
  const cwdIdx = args.indexOf('--cwd');
5469
5195
  if (cwdEqArg) {
5470
5196
  const value = cwdEqArg.slice('--cwd='.length).trim();
@@ -5490,7 +5216,9 @@ async function main() {
5490
5216
  // runs, and its result must skip that call entirely below. A relative
5491
5217
  // value resolves against process.cwd(), matching --cwd's own resolution.
5492
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)
5493
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)
5494
5222
  const projectDirIdx = args.indexOf('--project-dir');
5495
5223
  let projectDirValue;
5496
5224
  if (projectDirEqArg) {
@@ -5514,26 +5242,25 @@ async function main() {
5514
5242
  cwd = resolvedProjectDir;
5515
5243
  projectDirExplicit = true;
5516
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);
5517
5256
  // #4894: verification derives its root from a phase directory, not `cwd`, so
5518
5257
  // hand it the validated explicit root. Always (re)set — `null` when the flag is
5519
5258
  // absent — so a prior in-process main() call can never leak an override.
5520
- projectRoot.setExplicitProjectRoot(projectDirExplicit ? cwd : null);
5521
-
5522
- // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
5523
- // However, in monorepo worktrees where the subdirectory itself owns .planning/,
5524
- // skip worktree resolution — the CWD is already the correct project root.
5525
- //
5526
- // #4906/#4465: this remap is a BLANKET rewrite applied to every command, before
5527
- // `command` is even known — most commands need it (they read `.planning/`, which
5528
- // in a linked worktree lives only in the main one). A command whose own git
5529
- // operation must run against the INVOKING worktree's OWN HEAD/branch (never the
5530
- // main worktree's) needs the PRE-remap value instead — preserved here so it can be
5531
- // threaded through dispatchHostCommand as `invokingCwd` (see routeSelectRevertCommits,
5532
- // the first consumer: its `git log` must see the caller's own reachable history, not
5533
- // main's, or a linked-worktree revert range silently resolves against the wrong repo
5534
- // state — reproduced in tests/undo-commit-selection-4465.test.cjs's linked-worktree case).
5535
- const preWorktreeRemapCwd = cwd;
5536
- 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);
5537
5264
 
5538
5265
  // Optional workstream override for parallel milestone work.
5539
5266
  // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
@@ -5562,6 +5289,7 @@ async function main() {
5562
5289
  error(err.message || String(err));
5563
5290
  }
5564
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)
5565
5293
  const rawIndex = args.indexOf('--raw');
5566
5294
  const raw = rawIndex !== -1;
5567
5295
  if (rawIndex !== -1) args.splice(rawIndex, 1);
@@ -5569,6 +5297,7 @@ async function main() {
5569
5297
  // --pick <name>: extract a single field from JSON output (replaces jq dependency).
5570
5298
  // Supports dot-notation (e.g., --pick workflow.research) and bracket notation
5571
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)
5572
5301
  const pickIdx = args.indexOf('--pick');
5573
5302
  let pickField = null;
5574
5303
  if (pickIdx !== -1) {
@@ -5580,6 +5309,7 @@ async function main() {
5580
5309
  // --default <value>: for config-get, return this value instead of erroring
5581
5310
  // when the key is absent. Allows workflows to express optional config reads
5582
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)
5583
5313
  const defaultIdx = args.indexOf('--default');
5584
5314
  let defaultValue = undefined;
5585
5315
  if (defaultIdx !== -1) {
@@ -5626,7 +5356,7 @@ async function main() {
5626
5356
  // discovering the command surface via subcommand help requests routed
5627
5357
  // through this dispatcher. Rendering top-level usage on --help is strictly
5628
5358
  // better UX than the old short-circuit that printed unrelated usage text.
5629
- const HELP_FLAGS = new Set(['-h', '--help', '-?', '--h', '--usage']);
5359
+ const HELP_FLAGS = new Set(GLOBAL_FLAGS.helpTokens);
5630
5360
  if (args.some((a) => HELP_FLAGS.has(a))) {
5631
5361
  process.stdout.write(TOP_LEVEL_USAGE + '\n');
5632
5362
  return;
@@ -5635,7 +5365,7 @@ async function main() {
5635
5365
  // Reject version flags. AI agents sometimes hallucinate --version on tool
5636
5366
  // invocations; silently ignoring it can cause destructive operations to
5637
5367
  // proceed unchecked. (Help flags are handled above.)
5638
- const NEVER_VALID_FLAGS = new Set(['--version', '-v']);
5368
+ const NEVER_VALID_FLAGS = new Set(GLOBAL_FLAGS.rejectedTokens);
5639
5369
  for (const arg of args) {
5640
5370
  if (NEVER_VALID_FLAGS.has(arg)) {
5641
5371
  error(`Unknown flag: ${arg}\ngsd-tools does not accept version flags. Run "gsd-tools" with no arguments for usage.`, ERROR_REASON.USAGE);
@@ -5643,10 +5373,9 @@ async function main() {
5643
5373
  }
5644
5374
 
5645
5375
  // #3881: an explicit --project-dir already IS the resolved project root
5646
- // (validated above) — findProjectRoot's ancestor walk-up must not run
5647
- // over it, per docs/CONFIGURATION.md's documented idempotence.
5648
- if (!projectDirExplicit && !SKIP_ROOT_RESOLUTION.has(command)) {
5649
- 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;
5650
5379
  }
5651
5380
 
5652
5381
  // When --pick is active, capture stdout and extract the requested field.
@@ -5658,7 +5387,7 @@ async function main() {
5658
5387
  // false "output was not JSON" (negative space N8).
5659
5388
  if (pickField) {
5660
5389
  const captured = await captureTranslatingVerificationStatus(async () => {
5661
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd);
5390
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd, ctx);
5662
5391
  });
5663
5392
  const resolved = resolveAtFileOutput(captured);
5664
5393
  let obj;
@@ -5690,7 +5419,7 @@ async function main() {
5690
5419
  // every workflow to have a bash-specific `if [[ "$INIT" == @file:* ]]` check
5691
5420
  // that breaks on PowerShell and other non-bash shells.
5692
5421
  const captured = await captureTranslatingVerificationStatus(async () => {
5693
- await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd);
5422
+ await runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext, preWorktreeRemapCwd, ctx);
5694
5423
  });
5695
5424
  fs.writeSync(1, resolveAtFileOutput(captured));
5696
5425
  }
@@ -5763,7 +5492,7 @@ function extractField(obj, fieldPath) {
5763
5492
  return { found: true, value: current };
5764
5493
  }
5765
5494
 
5766
- 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) {
5767
5496
  switch (command) {
5768
5497
 
5769
5498
  default: {
@@ -5784,7 +5513,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
5784
5513
  // commands (state, …) routed via their `route*Command` router instead of
5785
5514
  // a hardcoded `case` arm. Tried after capability/overlay dispatch and
5786
5515
  // before the unknown-command error.
5787
- 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;
5788
5517
 
5789
5518
  // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
5790
5519
  // above split it so `command` here is the head ("foo"). Use
@@ -5817,7 +5546,13 @@ if (require.main === module) {
5817
5546
  // synthetic registry + requireModule injections.
5818
5547
  // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
5819
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
+
5820
5554
  module.exports = {
5555
+ hostCommandNames,
5821
5556
  dispatchCapabilityCommand,
5822
5557
  dispatchOverlayCapabilityCommand,
5823
5558
  defaultRequireFromInstallRoot,
@@ -5825,14 +5560,10 @@ module.exports = {
5825
5560
  HOST_COMMAND_ROUTERS,
5826
5561
  TOP_LEVEL_USAGE,
5827
5562
  skipsRootResolution,
5828
- resolveMainWorktreeCwd,
5563
+ warnIfGitTimedOut,
5829
5564
  // #3275: exported for tests — the shared PATH+PATHEXT resolver behind
5830
5565
  // review-lane invoke's `deps.spawn` / `deps.hasBinary` seams.
5831
5566
  resolveSpawnBinary,
5832
- // #4208 review: exported for tests — the two-list commit parser is otherwise
5833
- // reachable only by spawning the CLI, which a property test cannot afford.
5834
- collectListFlagValues,
5835
- COMMIT_LIST_FLAGS,
5836
5567
  // #3714 follow-up: exported for tests — the dispatch model-pin VALUE
5837
5568
  // policy (charset accept/render parity, max-length boundary, leading-char
5838
5569
  // anchor) is otherwise unreachable from outside the dispatchOverlayCapabilityCommand closure.