@opengsd/gsd-core 1.8.0 → 1.9.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 (174) 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 +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. package/scripts/update-size-baseline.cjs +0 -68
@@ -173,7 +173,10 @@ function validateConfigSliceEntry(capId, key, slice) {
173
173
  // ─── Per-capability validation ────────────────────────────────────────────────
174
174
 
175
175
  const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
176
- const VALID_ROLES = new Set(['feature', 'runtime']);
176
+ // ADR-2782 D3: a third role for reviewer lanes that are NOT install targets
177
+ // (gemini, coderabbit, ollama, lm_studio, llama_cpp). A `role: "reviewer"`
178
+ // capability carries a reviewer body, no runtime body, and no install surface.
179
+ const VALID_ROLES = new Set(['feature', 'runtime', 'reviewer']);
177
180
  const VALID_TIERS = new Set(['core', 'standard', 'full']);
178
181
  const VALID_ON_ERROR = new Set(['skip', 'halt']);
179
182
  const RUNTIME_COMPAT_WILDCARD = '*';
@@ -321,7 +324,7 @@ function validateCapability(cap, folderId) {
321
324
  }
322
325
 
323
326
  if (!VALID_ROLES.has(cap.role)) {
324
- errors.push('role must be one of: feature, runtime (got: ' + cap.role + ')');
327
+ errors.push('role must be one of: ' + [...VALID_ROLES].join(', ') + ' (got: ' + cap.role + ')');
325
328
  }
326
329
 
327
330
  if (typeof cap.title !== 'string' || cap.title.length === 0) {
@@ -354,8 +357,33 @@ function validateCapability(cap, folderId) {
354
357
 
355
358
  if (cap.role === 'feature') {
356
359
  errors.push(...validateFeatureBody(cap));
360
+ // ADR-2782 D1 admits the reviewer body on `runtime` and `reviewer` ONLY. A
361
+ // feature capability owns loop artefacts, not an external review CLI. This is
362
+ // an ERROR rather than an ignored field because declaring a body is an
363
+ // ASSERTION of lane-ness (D4's Postel boundary), and a manifest built for a
364
+ // GSD that admits it declares `engines.gsd` and is gated before reaching here.
365
+ if (cap.reviewer !== undefined) {
366
+ errors.push('role:feature capability must not have a "reviewer" body (admissible on role runtime or reviewer only)');
367
+ }
357
368
  } else if (cap.role === 'runtime') {
358
369
  errors.push(...validateRuntimeBody(cap));
370
+ // A host that is ALSO a reviewer keeps exactly one manifest (ADR-2782 D1).
371
+ errors.push(...validateReviewerBody(cap));
372
+ } else if (cap.role === 'reviewer') {
373
+ // ADR-2782 D3 — a lane that is not an install target. No runtime body, no
374
+ // install surface, no runtimeCompat (it surfaces through no host runtime).
375
+ if (cap.reviewer === undefined) {
376
+ errors.push('role:reviewer capability must have a "reviewer" body — the role asserts a lane');
377
+ }
378
+ if (cap.runtime !== undefined) {
379
+ errors.push('role:reviewer capability must not have a "runtime" body (it is not an install target)');
380
+ }
381
+ for (const field of FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER) {
382
+ if (cap[field] !== undefined) {
383
+ errors.push('role:reviewer capability must not have "' + field + '" (feature-only field)');
384
+ }
385
+ }
386
+ errors.push(...validateReviewerBody(cap));
359
387
  }
360
388
 
361
389
  return errors;
@@ -683,6 +711,7 @@ const VALID_CONVERTER_NAMES = new Set([
683
711
  'convertClaudeCommandToCursorSkill',
684
712
  'convertClaudeCommandToKiloSkill',
685
713
  'convertClaudeCommandToKimiSkill',
714
+ 'convertClaudeCommandToKimiCodeSkill',
686
715
  'convertClaudeCommandToOpencodeSkill',
687
716
  'convertClaudeCommandToTraeSkill',
688
717
  'convertClaudeCommandToWindsurfSkill',
@@ -735,7 +764,104 @@ const VALID_HOOK_BUSES = new Set(['host', 'engine', 'none']);
735
764
  const VALID_STATE_IO = new Set(['filesystem', 'sandboxed-storage', 'session-log-append']);
736
765
  const VALID_TRANSPORTS = new Set(['mcp', 'native-extension']);
737
766
  const VALID_HOST_RUNTIMES = new Set(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']);
738
- const VALID_SUBAGENT_TOOLKITS = new Set(['full', 'read-only']);
767
+ const VALID_SUBAGENT_TOOLKITS = new Set(['full', 'read-only', 'built-in-only']);
768
+ // ADR-1239 amendment (#2481): how reasoning effort reaches the host.
769
+ const VALID_EFFORT_SURFACES = new Set(['argv', 'none']);
770
+ // ADR-1239 Codex-binding amendment (#2584): how a host isolates concurrent
771
+ // same-wave executors — a dispatch sub-field, not a top-level axis.
772
+ const VALID_DISPATCH_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
773
+
774
+ // ─── Reviewer lane body (ADR-2782 D1/D2/D3/D7/D8) ────────────────────────────
775
+ //
776
+ // A reviewer lane is one external CLI or model endpoint that /gsd:review hands a
777
+ // plan to. The body is admissible on `role: "runtime"` (a host that is ALSO a
778
+ // reviewer) and on `role: "reviewer"` (a lane that is not an install target).
779
+ //
780
+ // The vocabulary below tracks src/review-lane-descriptor.cts (Phase 1, #2794)
781
+ // field-for-field INCLUDING nesting, so no translation layer exists between the
782
+ // core descriptor and the manifest. Four members were added by Phase 1's ADR
783
+ // amendment, each forced by a lane that ships today: `promptChannel: 'none'`
784
+ // (CodeRabbit is fed no prompt), `outputChannel: 'file-arg'` + `outputArg`
785
+ // (Codex writes via -o and discards stdout, #1698), and `flags[]` (Antigravity
786
+ // answers to both --antigravity and --agy).
787
+ //
788
+ // EVERY error message below enumerates its valid members. That is deliberate:
789
+ // the prose reference lands in Phase 6 (#2800), so until then the validator's
790
+ // own errors ARE the documentation — the same gap that left `hostBehaviors`
791
+ // discoverable only by grepping a source line is not repeated here.
792
+
793
+ // A slug is NOT a capability id. Ids are kebab (KEBAB_RE, which rejects "_");
794
+ // slugs carry the shipped roster's snake forms (`lm_studio`, `llama_cpp`) and
795
+ // name the config keys (`review.lm_studio_host`) that ADR-2782 D9 leaves
796
+ // unchanged. Reusing KEBAB_RE here would reject two shipped lanes.
797
+ //
798
+ // ⚠ DEFECT.GENERATIVE-FIX — this grammar is DUPLICATED, by necessity, from
799
+ // `LANE_SLUG_RE` in src/review-lane-descriptor.cts (Phase 1, #2794). It cannot be
800
+ // imported: that module compiles to gsd-core/bin/lib/review-lane-descriptor.cjs,
801
+ // which is gitignored build output, and THIS file is a committed plain .cjs that
802
+ // must load on a fresh worktree before `npm run build:lib` has ever run (see the
803
+ // header). Two surfaces sharing one parser therefore require a parity assertion
804
+ // that fails when they diverge — `laneSlugGrammarMatchesPhase1Descriptor` in
805
+ // tests/reviewer-manifest-body.test.cjs.
806
+ //
807
+ // A LEADING DIGIT IS PERMITTED. Phase 1 allows it and a manifest validator that
808
+ // did not would reject a slug the core descriptor accepts — a model-named lane
809
+ // such as `4o-mini` — which is exactly the translation layer ADR-2782 exists to
810
+ // delete. Keep the two grammars byte-identical.
811
+ const LANE_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
812
+ // Flags are kebab even when the slug is snake: `lm_studio` → `--lm-studio`.
813
+ // Phase 1 declares flags but does not constrain their grammar, so this is the
814
+ // first and only definition — no parity partner to track.
815
+ const LANE_FLAG_RE = /^--[a-z0-9][a-z0-9-]*$/;
816
+
817
+ const VALID_LANE_TRANSPORTS = new Set(['spawn', 'openai-http']);
818
+ const VALID_LANE_PROBE_KINDS = new Set(['command-exists', 'command-capability', 'http-reachable']);
819
+ const VALID_PROMPT_CHANNELS = new Set(['stdin', 'argv', 'argv-file-ref', 'none']);
820
+ const VALID_OUTPUT_CHANNELS = new Set(['stdout', 'file-arg']);
821
+ const VALID_LANE_EFFORT_CHANNELS = new Set(['none', 'argv', 'env']);
822
+ const VALID_MODEL_DISCOVERY = new Set(['none', 'first-from-models-endpoint']);
823
+ const VALID_EMPTY_OUTPUT = new Set(['stub-with-stderr', 'handler-owned']);
824
+ const VALID_EVIDENCE_CLASSES = new Set(['source-grounded', 'diff-only']);
825
+
826
+ // ADR-2782 D6 — a CLOSED enum of FIRST-PARTY module names, never a path and
827
+ // never third-party code. `null` is the default and covers 7 of the 11 shipped
828
+ // lanes; it is checked separately because a Set cannot hold the "declared
829
+ // absent" case distinctly from an unknown string.
830
+ //
831
+ // ADMISSION RULE for a new member — this enum is the pressure valve that keeps
832
+ // the descriptor from becoming an ad-hoc interpreter, and it only works while
833
+ // membership stays scarce. A new member requires EITHER >=2 lanes that share the
834
+ // behaviour, OR a documented upstream defect that data provably cannot express.
835
+ // Today: `openai-compatible` serves 3 lanes (healthy); `antigravity` serves 1,
836
+ // justified solely by a documented upstream stdout bug. An enum that grows one
837
+ // member per lane has stopped being a vocabulary and become a dispatch table for
838
+ // bespoke code — at which point the descriptor is a plugin system wearing a
839
+ // manifest, and that is a decision for an ADR, not for a downstream phase.
840
+ // `opencode` admitted by Phase 5b (#2799) under the SECOND arm of the rule above:
841
+ // it serves 1 lane, justified by a documented upstream defect data cannot express.
842
+ // OpenCode's default `build` agent is an agentic coder, not a prompt→completion
843
+ // API; on a large review prompt it can end its turn with ZERO output tokens, and
844
+ // `--format default` then drops the assistant text entirely, silently losing the
845
+ // reviewer (#1936). The review must therefore be RECONSTRUCTED from the assistant
846
+ // `text` parts of a `--format json` stream — a parse, not a copy. Expressing that
847
+ // as data would need an `outputChannel: 'json-parts'` plus a selector expression,
848
+ // i.e. exactly the ad-hoc interpreter the admission rule exists to prevent.
849
+ const VALID_LANE_HANDLERS = new Set(['antigravity', 'openai-compatible', 'opencode']);
850
+
851
+ // D2 — `transport` selects the invoke sub-shape. A manifest carrying fields from
852
+ // BOTH sub-shapes, or from NEITHER, has undefined meaning and fails validation.
853
+ // The discriminator is explicit rather than inferred from field presence, which
854
+ // is precisely the ambiguity these two sets exist to detect.
855
+ const SPAWN_ONLY_INVOKE_FIELDS = ['binary', 'args', 'promptChannel', 'outputChannel', 'outputArg', 'modelArg'];
856
+ // `defaultHost` / `fallbackModel` added by Phase 5b (#2799). Phase 4 federated every
857
+ // `review.*_host` key with a default of `""`, so the REAL fallback destination and model
858
+ // (`http://localhost:11434` / `llama3` and friends) existed only inside the bash leg. Once the
859
+ // lane is invoked from data, an unset host with no declared default would POST to a garbage URL.
860
+ const HTTP_ONLY_INVOKE_FIELDS = ['hostConfigKey', 'defaultHost', 'path', 'modelDiscovery', 'fallbackModel'];
861
+
862
+ // Feature-only fields are as forbidden on a lane-only capability as on a runtime
863
+ // one; a `role: "reviewer"` capability owns no artefacts and wires no loop point.
864
+ const FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
739
865
 
740
866
  // GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant)
741
867
  // Derived from the actual pairings in the 16 real runtime descriptors.
@@ -1204,6 +1330,24 @@ function validateRuntimeBody(cap) {
1204
1330
  );
1205
1331
  }
1206
1332
 
1333
+ // effortSurface (axis) — ADR-1239 amendment (#2481).
1334
+ // OPTIONAL, unlike the Phase-A axes. It was added after descriptors already
1335
+ // existed, so requiring it would invalidate every descriptor written before it —
1336
+ // including third-party ones, breaking the "purely additive" property ADR-1239
1337
+ // promises for external descriptors. An omitted axis is legitimate: negotiation
1338
+ // degrades it to the safe floor ('none') and warns, exactly as for any
1339
+ // undeclared axis. Only a PRESENT value is checked against the vocabulary.
1340
+ if (hi.effortSurface === undefined) {
1341
+ // absent — nothing to validate; negotiateHostCapabilities fails it closed.
1342
+ } else if (hi.effortSurface === '__proto__' || hi.effortSurface === 'constructor' || hi.effortSurface === 'prototype') {
1343
+ errors.push('runtime.hostIntegration.effortSurface "' + hi.effortSurface + '" is a reserved name');
1344
+ } else if (hi.effortSurface !== 'undocumented' && !VALID_EFFORT_SURFACES.has(hi.effortSurface)) {
1345
+ errors.push(
1346
+ 'runtime.hostIntegration.effortSurface must be one of: ' + [...VALID_EFFORT_SURFACES].join(', ') +
1347
+ ' (or "undocumented") (got: ' + JSON.stringify(hi.effortSurface) + ')',
1348
+ );
1349
+ }
1350
+
1207
1351
  // dispatch — required object
1208
1352
  if (typeof hi.dispatch !== 'object' || hi.dispatch === null || Array.isArray(hi.dispatch)) {
1209
1353
  errors.push('runtime.hostIntegration.dispatch must be an object');
@@ -1270,9 +1414,103 @@ function validateRuntimeBody(cap) {
1270
1414
  'runtime.hostIntegration.dispatch.backgroundDispatch must be a boolean or "undocumented" (got: ' + JSON.stringify(d.backgroundDispatch) + ')',
1271
1415
  );
1272
1416
  }
1417
+
1418
+ // isolation — ADR-1239 Codex-binding amendment (#2584).
1419
+ // OPTIONAL, like effortSurface: added after descriptors already existed,
1420
+ // so requiring it would invalidate every descriptor authored before it —
1421
+ // including third-party ones, breaking the "purely additive" property
1422
+ // ADR-1239 promises for external descriptors. An omitted isolation is
1423
+ // legitimate: negotiation degrades it to 'none' (the safe floor) and
1424
+ // warns, exactly as for any other undeclared dispatch sub-field. Only a
1425
+ // PRESENT value is checked against the closed vocabulary.
1426
+ if (d.isolation === undefined) {
1427
+ // absent — nothing to validate; negotiateHostCapabilities fails it closed.
1428
+ } else if (d.isolation === '__proto__' || d.isolation === 'constructor' || d.isolation === 'prototype') {
1429
+ errors.push('runtime.hostIntegration.dispatch.isolation "' + d.isolation + '" is a reserved name');
1430
+ } else if (d.isolation !== 'undocumented' && !VALID_DISPATCH_ISOLATION.has(d.isolation)) {
1431
+ errors.push(
1432
+ 'runtime.hostIntegration.dispatch.isolation must be one of: ' + [...VALID_DISPATCH_ISOLATION].join(', ') +
1433
+ ' (or "undocumented") (got: ' + JSON.stringify(d.isolation) + ')',
1434
+ );
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ // orchestratorExec — ADR-1239 Codex-binding amendment (#2584), Phase 2.
1440
+ // OPTIONAL top-level field (sibling of hostBehaviors), like hookEvents:
1441
+ // only hosts whose hostIntegration.dispatch.isolation is
1442
+ // 'orchestrator-worktree' need it, so it is not required on every runtime
1443
+ // descriptor. When present it must be a well-formed exec descriptor for
1444
+ // src/host-integration.cts's resolveOrchestratorExec.
1445
+ if (r.orchestratorExec !== undefined) {
1446
+ if (typeof r.orchestratorExec !== 'object' || r.orchestratorExec === null || Array.isArray(r.orchestratorExec)) {
1447
+ errors.push(
1448
+ 'runtime.orchestratorExec must be an object (got: ' +
1449
+ (r.orchestratorExec === null ? 'null' : (Array.isArray(r.orchestratorExec) ? 'array' : typeof r.orchestratorExec)) + ')',
1450
+ );
1451
+ } else {
1452
+ const oe = r.orchestratorExec;
1453
+
1454
+ // S2b: reserved-OWN-KEY guard (CodeQL barrier — inline literal comparisons)
1455
+ if (Object.prototype.hasOwnProperty.call(oe, '__proto__')) {
1456
+ errors.push('runtime.orchestratorExec must not contain reserved key "__proto__"');
1457
+ }
1458
+ if (Object.prototype.hasOwnProperty.call(oe, 'constructor')) {
1459
+ errors.push('runtime.orchestratorExec must not contain reserved key "constructor"');
1460
+ }
1461
+ if (Object.prototype.hasOwnProperty.call(oe, 'prototype')) {
1462
+ errors.push('runtime.orchestratorExec must not contain reserved key "prototype"');
1463
+ }
1464
+
1465
+ // command — required non-empty string; reserved-name guard (CodeQL barrier)
1466
+ if (oe.command === '__proto__' || oe.command === 'constructor' || oe.command === 'prototype') {
1467
+ errors.push('runtime.orchestratorExec.command "' + oe.command + '" is a reserved name');
1468
+ } else if (typeof oe.command !== 'string' || oe.command.length === 0) {
1469
+ errors.push(
1470
+ 'runtime.orchestratorExec.command must be a non-empty string (got: ' + JSON.stringify(oe.command) + ')',
1471
+ );
1472
+ }
1473
+
1474
+ // args — optional array of strings
1475
+ if (oe.args !== undefined) {
1476
+ if (!Array.isArray(oe.args) || !oe.args.every((a) => typeof a === 'string')) {
1477
+ errors.push(
1478
+ 'runtime.orchestratorExec.args must be an array of strings (got: ' + JSON.stringify(oe.args) + ')',
1479
+ );
1480
+ }
1481
+ }
1482
+
1483
+ // cwdFlag — optional; string or null
1484
+ if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') {
1485
+ errors.push(
1486
+ 'runtime.orchestratorExec.cwdFlag must be a string or null (got: ' + JSON.stringify(oe.cwdFlag) + ')',
1487
+ );
1488
+ }
1489
+
1490
+ // promptFlag — optional; string or null (#2627, Phase 3). `null`/absent
1491
+ // means the host takes the executor prompt positionally (codex, opencode);
1492
+ // a string names the flag that carries it (kimi/kimi-code: --prompt).
1493
+ if (oe.promptFlag !== undefined && oe.promptFlag !== null && typeof oe.promptFlag !== 'string') {
1494
+ errors.push(
1495
+ 'runtime.orchestratorExec.promptFlag must be a string or null (got: ' + JSON.stringify(oe.promptFlag) + ')',
1496
+ );
1497
+ }
1273
1498
  }
1274
1499
  }
1275
1500
 
1501
+ // harnessIsolationFlag — ADR-1239 Codex-binding amendment (#2584), Phase 3
1502
+ // (#2627). OPTIONAL top-level field: the counterpart of orchestratorExec for
1503
+ // `dispatch.isolation: 'harness-worktree'` hosts, naming the host's OWN
1504
+ // isolation flag that GSD passes on dispatch (claude: isolation="worktree";
1505
+ // cursor: --worktree). Descriptor data so the scheduler passes a declared
1506
+ // token instead of branching on a runtime id (ADR-1239: "the per-host
1507
+ // dispatch invocation ... is descriptor data, not a scheduler branch").
1508
+ if (r.harnessIsolationFlag !== undefined && (typeof r.harnessIsolationFlag !== 'string' || r.harnessIsolationFlag.length === 0)) {
1509
+ errors.push(
1510
+ 'runtime.harnessIsolationFlag must be a non-empty string (got: ' + JSON.stringify(r.harnessIsolationFlag) + ')',
1511
+ );
1512
+ }
1513
+
1276
1514
  // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX)
1277
1515
  // Only check if both fields are valid strings (individual field validators above report type errors).
1278
1516
  if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') {
@@ -1312,6 +1550,523 @@ function validateRuntimeBody(cap) {
1312
1550
  return errors;
1313
1551
  }
1314
1552
 
1553
+ /**
1554
+ * ADR-2782 D2/D7 — every declared reviewer field is a known key. Anything else
1555
+ * inside the body is IGNORED WITH A WARNING (D4.3), never a validation error:
1556
+ * a capability authored for a newer GSD must degrade to discovered-but-inactive
1557
+ * rather than failing the build of a repo that merely reads it.
1558
+ */
1559
+ const KNOWN_REVIEWER_FIELDS = new Set([
1560
+ 'slug', 'flags', 'transport', 'probe', 'invoke', 'timeoutFloorMs', 'emptyOutput',
1561
+ 'reviewsSection', 'evidenceClass', 'requiresBinaries', 'promptBudgetKey',
1562
+ // `modelConfigKey` added by Phase 5b (#2799). The model key was IMPLICIT
1563
+ // (`review.models.<slug>`) until a shipped lane broke the convention: antigravity's
1564
+ // slug is `antigravity` but its key is `review.models.agy`, so resolving by slug
1565
+ // missed a configured model and silently disabled the pinned-model escape hatch
1566
+ // #2073 added. A convention one shipped lane already violates is not a contract.
1567
+ 'modelConfigKey',
1568
+ 'handler',
1569
+ ]);
1570
+
1571
+ const KNOWN_PROBE_FIELDS = new Set(['kind', 'binary', 'needle', 'timeoutMs', 'hostConfigKey', 'path']);
1572
+
1573
+ /** A bounded probe timeout must be a finite, positive INTEGER of milliseconds. */
1574
+ function isPositiveIntegerMs(v) {
1575
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
1576
+ }
1577
+
1578
+ /**
1579
+ * Render any value for an error message WITHOUT ever throwing.
1580
+ *
1581
+ * `JSON.stringify` throws on a BigInt and on a circular structure, and a value
1582
+ * carrying a throwing `toJSON` propagates that throw. Interpolating a rejected
1583
+ * value into its own rejection message must never itself become the failure —
1584
+ * these validators are contracted to RETURN errors, and #1461 OVL-1 records a
1585
+ * validator that threw and would have crashed every consumer of loadRegistry.
1586
+ *
1587
+ * @param {*} v
1588
+ * @returns {string}
1589
+ */
1590
+ function describeValue(v) {
1591
+ if (typeof v === 'bigint') return String(v) + 'n';
1592
+ if (typeof v === 'symbol') return String(v);
1593
+ if (typeof v === 'function') return '[function]';
1594
+ try {
1595
+ const json = JSON.stringify(v);
1596
+ // stringify returns undefined for undefined and for non-serializable roots.
1597
+ return json === undefined ? String(v) : json;
1598
+ } catch {
1599
+ // Circular structure, a nested BigInt, or a throwing toJSON.
1600
+ try {
1601
+ return Object.prototype.toString.call(v);
1602
+ } catch {
1603
+ return '[unserializable]';
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ /** Extract a message from an unknown thrown value without throwing again. */
1609
+ function safeErrorMessage(err) {
1610
+ try {
1611
+ if (err instanceof Error && typeof err.message === 'string') return err.message;
1612
+ return describeValue(err);
1613
+ } catch {
1614
+ return '[unprintable error]';
1615
+ }
1616
+ }
1617
+
1618
+ /** House CodeQL barrier — inline literal guard at every key-derived read/write site. */
1619
+ function isReservedName(v) {
1620
+ return v === '__proto__' || v === 'constructor' || v === 'prototype';
1621
+ }
1622
+
1623
+ /**
1624
+ * One closed-enum membership check, with the members always enumerated in the
1625
+ * error.
1626
+ *
1627
+ * The enumeration is the point, not the deduplication. The prose reference for
1628
+ * the reviewer body lands in Phase 6 (#2800), so until then these errors are the
1629
+ * only documentation of the vocabulary — exactly the gap that left
1630
+ * `hostBehaviors` discoverable solely by grepping a source line. Routing every
1631
+ * enum through one helper makes "the error names the valid members" structural
1632
+ * rather than a convention repeated at nine call sites, where it would drift.
1633
+ *
1634
+ * No reserved-name pre-check: a `VALID_*` set never contains `__proto__`,
1635
+ * `constructor` or `prototype`, so membership alone already rejects them, and
1636
+ * "must be one of: …" tells an author more than "is a reserved name". The
1637
+ * literal guards stay where they do real work — the key-derived write sites.
1638
+ *
1639
+ * @param {string} ctx Error-message prefix.
1640
+ * @param {string} label Dotted field path, e.g. "reviewer.transport".
1641
+ * @param {*} value The declared value.
1642
+ * @param {Set} validSet The closed vocabulary.
1643
+ * @returns {string[]}
1644
+ */
1645
+ function validateEnumField(ctx, label, value, validSet) {
1646
+ if (validSet.has(value)) return [];
1647
+ return [
1648
+ ctx + ' ' + label + ' must be one of: ' + [...validSet].join(', ') +
1649
+ ' (got: ' + describeValue(value) + ')',
1650
+ ];
1651
+ }
1652
+
1653
+ /**
1654
+ * Collect NON-FATAL diagnostics for a reviewer body (ADR-2782 D4.3).
1655
+ *
1656
+ * Kept separate from validateReviewerBody so validateCapability's contract
1657
+ * (`=> string[]` of ERRORS) is unchanged for its two existing callers. The
1658
+ * build-time generator writes these to stderr; the overlay loader surfaces them
1659
+ * through OverlayMeta.warnings. A warning written only to a build log nobody
1660
+ * reads is not a warning (ADR-2782 D4, "Where warnings surface").
1661
+ *
1662
+ * @param {object} cap A capability manifest.
1663
+ * @returns {string[]} Warning strings; empty when there is nothing to say.
1664
+ */
1665
+ function collectReviewerWarnings(cap) {
1666
+ // Same totality contract as validateReviewerBody, for the same reason: this is
1667
+ // called from loadRegistry's accept path, and a diagnostic that throws must
1668
+ // never cost the user a working lane.
1669
+ try {
1670
+ return collectReviewerWarningFields(cap);
1671
+ } catch {
1672
+ return [];
1673
+ }
1674
+ }
1675
+
1676
+ function collectReviewerWarningFields(cap) {
1677
+ const warnings = [];
1678
+ if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) return warnings;
1679
+ const r = cap.reviewer;
1680
+ if (typeof r !== 'object' || r === null || Array.isArray(r)) return warnings;
1681
+
1682
+ const capId = typeof cap.id === 'string' ? cap.id : '(unknown)';
1683
+ for (const key of Object.keys(r)) {
1684
+ if (isReservedName(key) || KNOWN_REVIEWER_FIELDS.has(key)) continue;
1685
+ warnings.push(
1686
+ '⚠ capability "' + capId + '" reviewer.' + key + ' is not a known reviewer field ' +
1687
+ 'in this GSD version — ignored. Known fields: ' + [...KNOWN_REVIEWER_FIELDS].join(', '),
1688
+ );
1689
+ }
1690
+ return warnings;
1691
+ }
1692
+
1693
+ /**
1694
+ * Validate a `reviewer` lane body (ADR-2782 D1/D2/D3/D6/D7).
1695
+ *
1696
+ * TOTAL: returns an array of error strings for ANY input and never throws. The
1697
+ * overlay loader contracts every validator to RETURN errors — #1461 OVL-1
1698
+ * records a validator that THREW and would have crashed every consumer of
1699
+ * loadRegistry. That contract is load-bearing, not stylistic.
1700
+ *
1701
+ * ABSENT-SAFE (D4.1): a capability with no `reviewer` key is simply not a lane.
1702
+ * That is NEVER an error — 39 of 39 shipped capabilities are in this state, and
1703
+ * a validator that errors here breaks the entire registry. `undefined` is the
1704
+ * ONLY permissive case: `null`, `{}`, `[]`, `false` and `0` are all assertions
1705
+ * of a body, and a malformed assertion is an error (Postel's Law with a
1706
+ * boundary — liberal in what a manifest may OMIT, strict in what it ASSERTS).
1707
+ *
1708
+ * Totality is enforced STRUCTURALLY by the wrapper below, not by auditing every
1709
+ * field read. Serialization is made safe via describeValue(), but that alone is
1710
+ * not enough: a value carrying a throwing getter, or a Proxy with a throwing
1711
+ * `get`/`ownKeys` trap, throws on the READ itself, before any message is built.
1712
+ * A caller cannot be asked to re-derive today's reachability analysis — the
1713
+ * contract says "any input", so the guarantee is absolute rather than argued.
1714
+ *
1715
+ * @param {object} cap The parsed capability manifest.
1716
+ * @returns {string[]} Array of error strings; empty = valid.
1717
+ */
1718
+ function validateReviewerBody(cap) {
1719
+ try {
1720
+ return validateReviewerBodyFields(cap);
1721
+ } catch (err) {
1722
+ // A malformed body must degrade to a validation ERROR, never to a crash of
1723
+ // every consumer of loadRegistry (#1461 OVL-1).
1724
+ return ['capability reviewer body could not be validated: ' + safeErrorMessage(err)];
1725
+ }
1726
+ }
1727
+
1728
+ function validateReviewerBodyFields(cap) {
1729
+ const errors = [];
1730
+ if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) return errors;
1731
+
1732
+ const r = cap.reviewer;
1733
+ if (r === undefined) return errors; // D4.1 — not a lane. Never an error.
1734
+
1735
+ const ctx = 'capability "' + (typeof cap.id === 'string' ? cap.id : '(unknown)') + '"';
1736
+
1737
+ if (typeof r !== 'object' || r === null || Array.isArray(r)) {
1738
+ const got = r === null ? 'null' : Array.isArray(r) ? 'array' : typeof r;
1739
+ errors.push(
1740
+ ctx + ' reviewer must be an object (got: ' + got + '). ' +
1741
+ 'Omit the key entirely to declare no lane — an explicit null is not an omission.',
1742
+ );
1743
+ return errors; // cannot validate fields of a non-object
1744
+ }
1745
+
1746
+ // ── slug ───────────────────────────────────────────────────────────────────
1747
+ // NOT a capability id: ids are kebab, slugs carry the roster's snake forms.
1748
+ if (typeof r.slug !== 'string' || r.slug.length === 0) {
1749
+ errors.push(ctx + ' reviewer.slug must be a non-empty string');
1750
+ } else if (isReservedName(r.slug)) {
1751
+ errors.push(ctx + ' reviewer.slug "' + r.slug + '" is a reserved name');
1752
+ } else if (!LANE_SLUG_RE.test(r.slug)) {
1753
+ errors.push(
1754
+ ctx + ' reviewer.slug "' + r.slug + '" must match ' + String(LANE_SLUG_RE) +
1755
+ ' (lower-case; "_" and "-" permitted — a slug is not a capability id and not a flag)',
1756
+ );
1757
+ }
1758
+
1759
+ // ── flags ──────────────────────────────────────────────────────────────────
1760
+ if (!Array.isArray(r.flags)) {
1761
+ errors.push(ctx + ' reviewer.flags must be an array of CLI flags');
1762
+ } else if (r.flags.length === 0) {
1763
+ errors.push(
1764
+ ctx + ' reviewer.flags must declare at least one flag — a lane nobody can name ' +
1765
+ 'cannot be explicitly selected, so ADR-2782 D4\'s explicit-selection rule is unreachable for it',
1766
+ );
1767
+ } else {
1768
+ const seen = new Set();
1769
+ for (const flag of r.flags) {
1770
+ if (typeof flag !== 'string' || !LANE_FLAG_RE.test(flag)) {
1771
+ errors.push(
1772
+ ctx + ' reviewer.flags entry ' + describeValue(flag) +
1773
+ ' must match ' + String(LANE_FLAG_RE) + ' (e.g. "--lm-studio")',
1774
+ );
1775
+ continue;
1776
+ }
1777
+ if (seen.has(flag)) {
1778
+ errors.push(ctx + ' reviewer.flags lists "' + flag + '" more than once');
1779
+ }
1780
+ seen.add(flag);
1781
+ }
1782
+ }
1783
+
1784
+ // ── transport (D2) — explicit discriminator, never inferred ────────────────
1785
+ errors.push(...validateEnumField(ctx, 'reviewer.transport', r.transport, VALID_LANE_TRANSPORTS));
1786
+
1787
+ errors.push(...validateLaneProbe(ctx, r.probe));
1788
+ errors.push(...validateLaneInvoke(ctx, r.transport, r.invoke));
1789
+
1790
+ // ── lane scalars ───────────────────────────────────────────────────────────
1791
+ if (!isPositiveIntegerMs(r.timeoutFloorMs)) {
1792
+ errors.push(
1793
+ ctx + ' reviewer.timeoutFloorMs must be a positive integer of milliseconds ' +
1794
+ '(got: ' + describeValue(r.timeoutFloorMs) + ')',
1795
+ );
1796
+ }
1797
+
1798
+ errors.push(...validateEnumField(ctx, 'reviewer.emptyOutput', r.emptyOutput, VALID_EMPTY_OUTPUT));
1799
+
1800
+ if (typeof r.reviewsSection !== 'string' || r.reviewsSection.length === 0) {
1801
+ errors.push(ctx + ' reviewer.reviewsSection must be a non-empty string');
1802
+ }
1803
+
1804
+ errors.push(...validateEnumField(ctx, 'reviewer.evidenceClass', r.evidenceClass, VALID_EVIDENCE_CLASSES));
1805
+
1806
+ if (!Array.isArray(r.requiresBinaries)) {
1807
+ errors.push(
1808
+ ctx + ' reviewer.requiresBinaries must be an array (use [] when the lane needs no ' +
1809
+ 'external tool on PATH). Note the name: the envelope\'s "requires" is capability ids',
1810
+ );
1811
+ } else {
1812
+ for (const bin of r.requiresBinaries) {
1813
+ if (typeof bin !== 'string' || bin.length === 0) {
1814
+ errors.push(ctx + ' reviewer.requiresBinaries entry ' + describeValue(bin) + ' must be a non-empty string');
1815
+ }
1816
+ }
1817
+ }
1818
+
1819
+ // OPTIONAL, and that is required by D4 rather than a convenience: `modelConfigKey` did not exist
1820
+ // before Phase 5b, so demanding it would fail validation on every reviewer manifest authored
1821
+ // against an earlier GSD — exactly the forward/backward-compatibility break D4 rule 2 forbids.
1822
+ // Absent is read as `null` (this lane accepts no model override). `null` is explicit; an empty
1823
+ // string is neither, and is rejected.
1824
+ if (r.modelConfigKey !== undefined && r.modelConfigKey !== null &&
1825
+ (typeof r.modelConfigKey !== 'string' || r.modelConfigKey.length === 0)) {
1826
+ errors.push(
1827
+ ctx + ' reviewer.modelConfigKey must be a dotted config key or null ' +
1828
+ '(got: ' + describeValue(r.modelConfigKey) + ')',
1829
+ );
1830
+ }
1831
+
1832
+ // `null` is the declared "no per-lane budget"; an empty string is not.
1833
+ if (r.promptBudgetKey !== null && (typeof r.promptBudgetKey !== 'string' || r.promptBudgetKey.length === 0)) {
1834
+ errors.push(
1835
+ ctx + ' reviewer.promptBudgetKey must be a dotted config key or null ' +
1836
+ '(got: ' + describeValue(r.promptBudgetKey) + ')',
1837
+ );
1838
+ }
1839
+
1840
+ // ── handler (D6) — closed first-party enum; null is the default ────────────
1841
+ if (r.handler !== null && !VALID_LANE_HANDLERS.has(r.handler)) {
1842
+ errors.push(
1843
+ ctx + ' reviewer.handler must be null or one of: ' + [...VALID_LANE_HANDLERS].join(', ') +
1844
+ ' (got: ' + describeValue(r.handler) + '). Handlers are first-party module NAMES, ' +
1845
+ 'never paths and never third-party code — a lane needing a shape the vocabulary lacks ' +
1846
+ 'files an issue naming the missing primitive (ADR-2782 D6)',
1847
+ );
1848
+ }
1849
+
1850
+ return errors;
1851
+ }
1852
+
1853
+ /**
1854
+ * ADR-2782 D7 — probe.kind is a closed enum WIDER than existence, and every
1855
+ * probe that starts a process or a connection MUST be bounded.
1856
+ *
1857
+ * `command-exists` alone is structurally insufficient: `kimi` is claimed by both
1858
+ * Kimi Code CLI and the legacy Python kimi-cli (a separate first-party runtime
1859
+ * capability in this repo), so an existence-only probe registers the wrong tool.
1860
+ * The unbounded form of that probe was a live instance of this repo's named
1861
+ * Unbounded Subprocesses defect — it ran on EVERY /gsd:review invocation
1862
+ * regardless of which flags were passed, so a binary waiting on a first-run auth
1863
+ * prompt hung every future review, including reviews that never asked for it.
1864
+ *
1865
+ * @param {string} ctx Error-message prefix.
1866
+ * @param {*} probe The probe value.
1867
+ * @returns {string[]}
1868
+ */
1869
+ function validateLaneProbe(ctx, probe) {
1870
+ const errors = [];
1871
+
1872
+ if (typeof probe !== 'object' || probe === null || Array.isArray(probe)) {
1873
+ errors.push(ctx + ' reviewer.probe must be an object with a "kind" from: ' + [...VALID_LANE_PROBE_KINDS].join(', '));
1874
+ return errors;
1875
+ }
1876
+
1877
+ const kindErrors = validateEnumField(ctx, 'reviewer.probe.kind', probe.kind, VALID_LANE_PROBE_KINDS);
1878
+ if (kindErrors.length > 0) {
1879
+ errors.push(...kindErrors);
1880
+ return errors; // sub-shape is meaningless without a known kind
1881
+ }
1882
+
1883
+ for (const key of Object.keys(probe)) {
1884
+ if (!isReservedName(key) && !KNOWN_PROBE_FIELDS.has(key)) {
1885
+ errors.push(ctx + ' reviewer.probe.' + key + ' is not a known probe field');
1886
+ }
1887
+ }
1888
+
1889
+ const needsBound = probe.kind === 'command-capability' || probe.kind === 'http-reachable';
1890
+
1891
+ if (probe.kind === 'command-exists' || probe.kind === 'command-capability') {
1892
+ if (typeof probe.binary !== 'string' || probe.binary.length === 0) {
1893
+ errors.push(ctx + ' reviewer.probe.binary must be a non-empty string for kind "' + probe.kind + '"');
1894
+ }
1895
+ } else if (probe.binary !== undefined) {
1896
+ errors.push(ctx + ' reviewer.probe.binary is not permitted for kind "' + probe.kind + '"');
1897
+ }
1898
+
1899
+ if (probe.kind === 'command-capability') {
1900
+ if (typeof probe.needle !== 'string' || probe.needle.length === 0) {
1901
+ errors.push(ctx + ' reviewer.probe.needle must be a non-empty string for kind "command-capability"');
1902
+ }
1903
+ } else if (probe.needle !== undefined) {
1904
+ errors.push(ctx + ' reviewer.probe.needle is not permitted for kind "' + probe.kind + '"');
1905
+ }
1906
+
1907
+ if (probe.kind === 'http-reachable') {
1908
+ if (typeof probe.hostConfigKey !== 'string' || probe.hostConfigKey.length === 0) {
1909
+ errors.push(ctx + ' reviewer.probe.hostConfigKey must be a non-empty string for kind "http-reachable"');
1910
+ }
1911
+ if (typeof probe.path !== 'string' || probe.path.length === 0) {
1912
+ errors.push(ctx + ' reviewer.probe.path must be a non-empty string for kind "http-reachable"');
1913
+ }
1914
+ } else {
1915
+ if (probe.hostConfigKey !== undefined) {
1916
+ errors.push(ctx + ' reviewer.probe.hostConfigKey is not permitted for kind "' + probe.kind + '"');
1917
+ }
1918
+ if (probe.path !== undefined) {
1919
+ errors.push(ctx + ' reviewer.probe.path is not permitted for kind "' + probe.kind + '"');
1920
+ }
1921
+ }
1922
+
1923
+ if (needsBound) {
1924
+ if (!isPositiveIntegerMs(probe.timeoutMs)) {
1925
+ errors.push(
1926
+ ctx + ' reviewer.probe.timeoutMs must be a positive integer of milliseconds for kind "' +
1927
+ probe.kind + '" — an unbounded probe hangs every /gsd:review invocation ' +
1928
+ '(got: ' + describeValue(probe.timeoutMs) + ')',
1929
+ );
1930
+ }
1931
+ } else if (probe.timeoutMs !== undefined) {
1932
+ errors.push(
1933
+ ctx + ' reviewer.probe.timeoutMs is not permitted for kind "command-exists" — ' +
1934
+ 'no process is started, so there is nothing to bound',
1935
+ );
1936
+ }
1937
+
1938
+ return errors;
1939
+ }
1940
+
1941
+ /**
1942
+ * ADR-2782 D2 — the invoke sub-shape is selected by `transport`. A manifest
1943
+ * declaring fields from BOTH sub-shapes, or from NEITHER, fails validation:
1944
+ * inference from field presence leaves those two cases carrying undefined
1945
+ * meaning, which is exactly what a closed vocabulary exists to prevent.
1946
+ *
1947
+ * @param {string} ctx Error-message prefix.
1948
+ * @param {*} transport The (already enum-checked) transport value.
1949
+ * @param {*} invoke The invoke value.
1950
+ * @returns {string[]}
1951
+ */
1952
+ function validateLaneInvoke(ctx, transport, invoke) {
1953
+ const errors = [];
1954
+
1955
+ if (typeof invoke !== 'object' || invoke === null || Array.isArray(invoke)) {
1956
+ errors.push(ctx + ' reviewer.invoke must be an object');
1957
+ return errors;
1958
+ }
1959
+
1960
+ const hasSpawnField = SPAWN_ONLY_INVOKE_FIELDS.some((f) => invoke[f] !== undefined);
1961
+ const hasHttpField = HTTP_ONLY_INVOKE_FIELDS.some((f) => invoke[f] !== undefined);
1962
+
1963
+ if (hasSpawnField && hasHttpField) {
1964
+ errors.push(
1965
+ ctx + ' reviewer.invoke mixes spawn-only fields (' + SPAWN_ONLY_INVOKE_FIELDS.join(', ') +
1966
+ ') with openai-http-only fields (' + HTTP_ONLY_INVOKE_FIELDS.join(', ') +
1967
+ ') — a lane is one transport or the other',
1968
+ );
1969
+ }
1970
+
1971
+ if (transport === 'spawn') {
1972
+ for (const f of HTTP_ONLY_INVOKE_FIELDS) {
1973
+ if (invoke[f] !== undefined) {
1974
+ errors.push(ctx + ' reviewer.invoke.' + f + ' is not permitted for transport "spawn"');
1975
+ }
1976
+ }
1977
+ errors.push(...validateSpawnInvoke(ctx, invoke));
1978
+ } else if (transport === 'openai-http') {
1979
+ for (const f of SPAWN_ONLY_INVOKE_FIELDS) {
1980
+ if (invoke[f] !== undefined) {
1981
+ errors.push(ctx + ' reviewer.invoke.' + f + ' is not permitted for transport "openai-http"');
1982
+ }
1983
+ }
1984
+ errors.push(...validateHttpInvoke(ctx, invoke));
1985
+ }
1986
+ // transport already reported as invalid upstream — do not double-report here.
1987
+
1988
+ return errors;
1989
+ }
1990
+
1991
+ function validateSpawnInvoke(ctx, invoke) {
1992
+ const errors = [];
1993
+
1994
+ if (typeof invoke.binary !== 'string' || invoke.binary.length === 0) {
1995
+ errors.push(ctx + ' reviewer.invoke.binary must be a non-empty string for transport "spawn"');
1996
+ }
1997
+
1998
+ if (!Array.isArray(invoke.args)) {
1999
+ errors.push(ctx + ' reviewer.invoke.args must be an array (use [] when the lane takes no arguments)');
2000
+ } else {
2001
+ for (const a of invoke.args) {
2002
+ if (typeof a !== 'string') {
2003
+ errors.push(ctx + ' reviewer.invoke.args entry ' + describeValue(a) + ' must be a string');
2004
+ }
2005
+ }
2006
+ }
2007
+
2008
+ errors.push(...validateEnumField(ctx, 'reviewer.invoke.promptChannel', invoke.promptChannel, VALID_PROMPT_CHANNELS));
2009
+
2010
+ const outputChannelErrors = validateEnumField(ctx, 'reviewer.invoke.outputChannel', invoke.outputChannel, VALID_OUTPUT_CHANNELS);
2011
+ if (outputChannelErrors.length > 0) {
2012
+ errors.push(...outputChannelErrors);
2013
+ } else if (invoke.outputChannel === 'file-arg') {
2014
+ // Knowing the review lands in a file is useless without the argument naming it.
2015
+ if (typeof invoke.outputArg !== 'string' || invoke.outputArg.length === 0) {
2016
+ errors.push(
2017
+ ctx + ' reviewer.invoke.outputArg is required (non-empty string) when outputChannel is "file-arg"',
2018
+ );
2019
+ }
2020
+ } else if (invoke.outputArg !== undefined) {
2021
+ // Forbidden rather than ignored: a manifest carrying an outputArg it does not
2022
+ // use is data a later reader may honour.
2023
+ errors.push(
2024
+ ctx + ' reviewer.invoke.outputArg is only permitted when outputChannel is "file-arg" ' +
2025
+ '(got outputChannel: ' + describeValue(invoke.outputChannel) + ')',
2026
+ );
2027
+ }
2028
+
2029
+ // `null` declares "this lane accepts no model override". An empty string does not.
2030
+ if (invoke.modelArg !== null && (typeof invoke.modelArg !== 'string' || invoke.modelArg.length === 0)) {
2031
+ errors.push(
2032
+ ctx + ' reviewer.invoke.modelArg must be a non-empty string or null ' +
2033
+ '(got: ' + describeValue(invoke.modelArg) + ')',
2034
+ );
2035
+ }
2036
+
2037
+ errors.push(...validateEnumField(ctx, 'reviewer.invoke.effortChannel', invoke.effortChannel, VALID_LANE_EFFORT_CHANNELS));
2038
+
2039
+ return errors;
2040
+ }
2041
+
2042
+ function validateHttpInvoke(ctx, invoke) {
2043
+ const errors = [];
2044
+
2045
+ if (typeof invoke.hostConfigKey !== 'string' || invoke.hostConfigKey.length === 0) {
2046
+ errors.push(
2047
+ ctx + ' reviewer.invoke.hostConfigKey must be a non-empty dotted config key ' +
2048
+ 'for transport "openai-http" (it names the config key holding the base URL)',
2049
+ );
2050
+ }
2051
+
2052
+ if (typeof invoke.path !== 'string' || invoke.path.length === 0) {
2053
+ errors.push(ctx + ' reviewer.invoke.path must be a non-empty string for transport "openai-http" (e.g. "/v1/chat/completions")');
2054
+ }
2055
+
2056
+ errors.push(...validateEnumField(ctx, 'reviewer.invoke.modelDiscovery', invoke.modelDiscovery, VALID_MODEL_DISCOVERY));
2057
+
2058
+ // D2 fixes effortChannel to 'none' for this transport — an HTTP lane has no
2059
+ // argv to carry an effort flag and no env of its own.
2060
+ if (invoke.effortChannel !== 'none') {
2061
+ errors.push(
2062
+ ctx + ' reviewer.invoke.effortChannel must be "none" for transport "openai-http" ' +
2063
+ '(got: ' + describeValue(invoke.effortChannel) + ')',
2064
+ );
2065
+ }
2066
+
2067
+ return errors;
2068
+ }
2069
+
1315
2070
  // #1459 CONVERGENCE finding 1(b) — GENEROUS DoS backstop on a (possibly project-plantable) hook
1316
2071
  // fragment file. A real fragment is a few KiB of markdown; 8 MiB is wildly more than any legitimate
1317
2072
  // fragment. The bounded reader refuses a non-regular (FIFO/device/symlink-to-nonregular) or oversized
@@ -1830,7 +2585,7 @@ const TIER_RANK = { core: 0, standard: 1, full: 2 };
1830
2585
  * @param {Set<string>} centralKeys Set of keys in the central config-schema
1831
2586
  * @returns {string[]} Array of error strings; empty = all pass.
1832
2587
  */
1833
- function validateCrossCapability(capMap, centralKeys) {
2588
+ function validateCrossCapability(capMap, centralKeys, centralPatterns = []) {
1834
2589
  const errors = [];
1835
2590
 
1836
2591
  // Ownership: one owner per skill stem + agent name
@@ -1873,10 +2628,19 @@ function validateCrossCapability(capMap, centralKeys) {
1873
2628
  }
1874
2629
  }
1875
2630
 
1876
- // Config key ownership: exclusive AND absent from central schema
2631
+ // Config key ownership: exclusive AND absent from central schema.
2632
+ //
2633
+ // ADR-2782 D1/D9: the role filter was `role !== 'feature'`, which silently
2634
+ // exempted every non-feature capability from ownership AND from the
2635
+ // central-schema collision check — the reason reviewer config keys were
2636
+ // stranded centrally. Ownership is a property of DECLARING a config slice, not
2637
+ // of being a feature, so the filter is now purely on the slice's presence.
2638
+ // Verified inert at introduction: no shipped capability declares `config` on a
2639
+ // non-feature role, so this widening changes no existing key — it stops a
2640
+ // latent silent drop and unblocks Phase 4 (#2797).
1877
2641
  const configKeyOwner = new Map(); // key → capId
1878
2642
  for (const [capId, cap] of capMap) {
1879
- if (cap.role !== 'feature' || typeof cap.config !== 'object' || cap.config === null) continue;
2643
+ if (typeof cap.config !== 'object' || cap.config === null) continue;
1880
2644
  for (const key of Object.keys(cap.config)) {
1881
2645
  if (configKeyOwner.has(key)) {
1882
2646
  errors.push(
@@ -1892,8 +2656,101 @@ function validateCrossCapability(capMap, centralKeys) {
1892
2656
  'remove from central config-schema before adding to the capability',
1893
2657
  );
1894
2658
  }
2659
+ // #2797: exact-key membership is not the whole central schema. A key may
2660
+ // also be claimed by a central DYNAMIC PATTERN, and until now that
2661
+ // collision was invisible here — `centralKeys` is built from
2662
+ // `manifest.validKeys` alone.
2663
+ //
2664
+ // Why that mattered enough to fix rather than note: `isCentralConfigKey`
2665
+ // DOES consult the patterns, and `mergeFederatedConfig` skips every key
2666
+ // for which it returns true. So a federated slice overlapping a central
2667
+ // pattern is INERT — it carries no traffic — while the build stays green.
2668
+ // Two of the four key families Phase 4 migrates (`review.models.<slug>`
2669
+ // and `review.max_prompt_tokens_per_reviewer.<slug>`) were pattern-backed,
2670
+ // so the invariant was blind to exactly the migration it exists to police.
2671
+ const collidingPattern = centralPatterns.find((p) => p.test(key));
2672
+ if (collidingPattern) {
2673
+ errors.push(
2674
+ 'config key "' + key + '" is declared in capability "' + capId +
2675
+ '" AND is matched by central config-schema pattern /' + collidingPattern.source +
2676
+ '/ — the federated slice would be inert (mergeFederatedConfig skips central keys): ' +
2677
+ 'remove the pattern from the central config-schema in the SAME commit',
2678
+ );
2679
+ }
2680
+ }
2681
+ }
2682
+
2683
+ // ── Reviewer lane uniqueness (ADR-2782 D8) ─────────────────────────────────
2684
+ //
2685
+ // slug, every flag, and reviewsSection are each unique across the MERGED
2686
+ // first-party ∪ overlay set. reviewsSection uniqueness is not cosmetic: two
2687
+ // lanes sharing a heading silently merge their output in REVIEWS.md, producing
2688
+ // a review that appears to have consensus it does not have.
2689
+ //
2690
+ // This runs in validateCrossCapability rather than in the generator because
2691
+ // BOTH callers reach it: the build-time generator over first-party, and
2692
+ // capability-loader's loadRegistry over `acceptedMap` (first-party ∪ accepted
2693
+ // overlays) per candidate. First-party is already in the map when an overlay
2694
+ // candidate is added, so the OVERLAY is the collider that gets dropped —
2695
+ // which is exactly D8's "first-party wins", with no provenance check here.
2696
+ //
2697
+ // Reviewer INSTANCES (review.reviewer_instances.<name>, ADR-1517) resolve
2698
+ // THROUGH a lane and are not lanes; they never enter these sets.
2699
+ const laneSlugClaims = new Map(); // slug → capId[]
2700
+ const laneFlagClaims = new Map(); // flag → capId[]
2701
+ const laneSectionClaims = new Map(); // reviewsSection → capId[]
2702
+
2703
+ // Claims are ACCUMULATED and reported after the sweep, never reported on the
2704
+ // second claimant. Reporting pairwise-on-collision looks equivalent and is not:
2705
+ // with three lanes on one key it names whichever pair happened to arrive first,
2706
+ // so the message text depends on Map insertion order — which is readdir order
2707
+ // at build time and candidate order at load time. A cross-platform CI lane
2708
+ // would then disagree with a local run about the text of the same failure.
2709
+ // Accumulating makes the output a pure function of the input set for ANY N.
2710
+ const claim = (claims, key, capId) => {
2711
+ if (typeof key !== 'string' || key.length === 0) return;
2712
+ if (isReservedName(key)) return;
2713
+ let claimants = claims.get(key);
2714
+ if (claimants === undefined) {
2715
+ claimants = [];
2716
+ claims.set(key, claimants);
2717
+ }
2718
+ if (!claimants.includes(capId)) claimants.push(capId);
2719
+ };
2720
+
2721
+ for (const [capId, cap] of capMap) {
2722
+ const r = cap.reviewer;
2723
+ // A capability with no lane contributes to no uniqueness set. A MALFORMED
2724
+ // body was already reported by validateCapability — do not double-report.
2725
+ if (typeof r !== 'object' || r === null || Array.isArray(r)) continue;
2726
+ claim(laneSlugClaims, r.slug, capId);
2727
+ claim(laneSectionClaims, r.reviewsSection, capId);
2728
+ if (Array.isArray(r.flags)) {
2729
+ // Flattened across arrays: Antigravity answers to --antigravity AND --agy,
2730
+ // so uniqueness is per-flag, not per-lane.
2731
+ for (const flag of r.flags) claim(laneFlagClaims, flag, capId);
2732
+ }
2733
+ }
2734
+
2735
+ // One error per colliding key naming EVERY claimant, ids sorted; the whole
2736
+ // block is sorted before it is appended, so both the messages and their order
2737
+ // are independent of how the capabilities were enumerated.
2738
+ const laneCollisions = [];
2739
+ for (const [claims, label] of [
2740
+ [laneSlugClaims, 'slug'],
2741
+ [laneFlagClaims, 'flag'],
2742
+ [laneSectionClaims, 'reviewsSection'],
2743
+ ]) {
2744
+ for (const [key, claimants] of claims) {
2745
+ if (claimants.length < 2) continue;
2746
+ laneCollisions.push(
2747
+ 'reviewer ' + label + ' "' + key + '" is declared by ' +
2748
+ [...claimants].sort().map((i) => '"' + i + '"').join(' and '),
2749
+ );
1895
2750
  }
1896
2751
  }
2752
+ laneCollisions.sort();
2753
+ errors.push(...laneCollisions);
1897
2754
 
1898
2755
  // requires: all ids exist
1899
2756
  for (const [capId, cap] of capMap) {
@@ -2289,6 +3146,22 @@ module.exports = {
2289
3146
  VALID_ARTIFACT_KIND_NAMES,
2290
3147
  VALID_ARTIFACT_NESTINGS,
2291
3148
  FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME,
3149
+ // ADR-2782 D1/D2/D3/D6/D7/D8 — reviewer lane body
3150
+ FEATURE_FIELDS_FORBIDDEN_ON_REVIEWER,
3151
+ LANE_SLUG_RE,
3152
+ LANE_FLAG_RE,
3153
+ VALID_LANE_TRANSPORTS,
3154
+ VALID_LANE_PROBE_KINDS,
3155
+ VALID_PROMPT_CHANNELS,
3156
+ VALID_OUTPUT_CHANNELS,
3157
+ VALID_LANE_EFFORT_CHANNELS,
3158
+ VALID_MODEL_DISCOVERY,
3159
+ VALID_EMPTY_OUTPUT,
3160
+ VALID_EVIDENCE_CLASSES,
3161
+ VALID_LANE_HANDLERS,
3162
+ KNOWN_REVIEWER_FIELDS,
3163
+ validateReviewerBody,
3164
+ collectReviewerWarnings,
2292
3165
  VALID_INSTALL_SURFACES,
2293
3166
  VALID_PERMISSION_WRITERS,
2294
3167
  VALID_EXTENDED_HOOK_EVENTS,
@@ -2300,6 +3173,7 @@ module.exports = {
2300
3173
  VALID_TRANSPORTS,
2301
3174
  VALID_HOST_RUNTIMES,
2302
3175
  VALID_SUBAGENT_TOOLKITS,
3176
+ VALID_DISPATCH_ISOLATION,
2303
3177
  _HOST_INTEGRATION_VOCAB: {
2304
3178
  embeddingMode: [...VALID_EMBEDDING_MODES],
2305
3179
  commandSurface: [...VALID_COMMAND_SURFACES],
@@ -2309,6 +3183,8 @@ module.exports = {
2309
3183
  transport: [...VALID_TRANSPORTS],
2310
3184
  runtime: [...VALID_HOST_RUNTIMES],
2311
3185
  subagentToolkit: [...VALID_SUBAGENT_TOOLKITS],
3186
+ effortSurface: [...VALID_EFFORT_SURFACES],
3187
+ isolation: [...VALID_DISPATCH_ISOLATION],
2312
3188
  },
2313
3189
  INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
2314
3190
  GEMINI_AGENT_EVENTS,