@opengsd/gsd-core 1.8.0 → 1.9.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +107 -34
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +236 -107
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +882 -4
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +36 -9
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +61 -6
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +146 -22
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -21
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +372 -94
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/validate-registry.cjs +10 -6
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- 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
|
-
|
|
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:
|
|
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 (
|
|
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,
|