@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2

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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/agents/gsd-roadmapper.md +6 -0
  5. package/bin/install.js +199 -365
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +695 -5
  9. package/gsd-core/bin/lib/adr-parser.cjs +45 -23
  10. package/gsd-core/bin/lib/audit.cjs +2 -2
  11. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  12. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  14. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  15. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  16. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  17. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  18. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  19. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  20. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  22. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  23. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  24. package/gsd-core/bin/lib/commands.cjs +247 -0
  25. package/gsd-core/bin/lib/config-loader.cjs +98 -84
  26. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  27. package/gsd-core/bin/lib/config.cjs +7 -1
  28. package/gsd-core/bin/lib/decisions.cjs +149 -60
  29. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  30. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  31. package/gsd-core/bin/lib/init.cjs +91 -22
  32. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  35. package/gsd-core/bin/lib/milestone.cjs +41 -2
  36. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  37. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  38. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  39. package/gsd-core/bin/lib/phase.cjs +33 -4
  40. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  42. package/gsd-core/bin/lib/project-root.cjs +89 -2
  43. package/gsd-core/bin/lib/resolution.cjs +26 -0
  44. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  45. package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
  46. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  47. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  48. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  49. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  50. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  51. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  53. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  54. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  55. package/gsd-core/bin/lib/state-document.cjs +4 -2
  56. package/gsd-core/bin/lib/state.cjs +317 -161
  57. package/gsd-core/bin/lib/surface.cjs +12 -19
  58. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  59. package/gsd-core/bin/lib/uat.cjs +39 -26
  60. package/gsd-core/bin/lib/validate.cjs +5 -2
  61. package/gsd-core/bin/lib/verify.cjs +40 -15
  62. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  63. package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
  64. package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
  65. package/gsd-core/references/context-budget.md +8 -8
  66. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  67. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  68. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  69. package/gsd-core/references/planner-antipatterns.md +48 -0
  70. package/gsd-core/references/planning-config.md +4 -0
  71. package/gsd-core/references/prohibition-probe.md +15 -9
  72. package/gsd-core/references/scout-codebase.md +2 -2
  73. package/gsd-core/workflows/autonomous.md +33 -33
  74. package/gsd-core/workflows/diagnose-issues.md +6 -1
  75. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  76. package/gsd-core/workflows/discuss-phase.md +1 -2
  77. package/gsd-core/workflows/execute-phase.md +12 -12
  78. package/gsd-core/workflows/help/modes/full.md +10 -0
  79. package/gsd-core/workflows/list-seeds.md +63 -0
  80. package/gsd-core/workflows/manager.md +37 -37
  81. package/gsd-core/workflows/pr-branch.md +156 -0
  82. package/gsd-core/workflows/quick.md +6 -1
  83. package/gsd-core/workflows/review.md +10 -2
  84. package/gsd-core/workflows/spec-phase.md +8 -3
  85. package/gsd-core/workflows/verify-phase.md +2 -2
  86. package/package.json +6 -3
  87. package/scripts/gen-capability-matrix.cjs +284 -0
  88. package/scripts/gen-capability-registry.cjs +96 -1853
  89. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  90. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  91. package/scripts/lint-resolution-provenance.cjs +192 -0
  92. package/scripts/lint-test-file-count.allowlist.json +9 -0
  93. package/scripts/prompt-injection-scan.sh +1 -0
  94. package/scripts/run-tests.cjs +14 -0
  95. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -0,0 +1,764 @@
1
+ "use strict";
2
+ /**
3
+ * capability-loader.cts — runtime Capability Registry overlay (ADR-1244 D2).
4
+ *
5
+ * Promotes the registry from a frozen data file to a module with an interface:
6
+ *
7
+ * loadRegistry({ includeInstalled }) -> composed registry
8
+ *
9
+ * It composes the **first-party frozen registry** (the committed, generated
10
+ * `capability-registry.cjs`) with a **validated installed overlay** — third-party
11
+ * capability manifests read at runtime from per-scope install roots:
12
+ * - global: $GSD_HOME/.gsd/capabilities/<id>/capability.json (GSD_HOME defaults to ~)
13
+ * - project: <projectRoot>/.gsd/capabilities/<id>/capability.json
14
+ *
15
+ * Invariants enforced over the merged set (first-party ∪ overlay):
16
+ * - First-party always wins: an overlay whose `id`, owned skill/agent stem, or
17
+ * federated config key collides with first-party (or uses a reserved `gsd-` /
18
+ * `gsd-core-` / `anthropic-` id prefix) is rejected.
19
+ * - Load-time re-gate (default-resilient): an overlay that fails validation or
20
+ * whose `engines.gsd` does not satisfy the running GSD version is SKIPPED
21
+ * with a warning — it never crashes the loop. EXCEPTION (per-hook-kind
22
+ * policy): a skipped capability that declares a `gate` is recorded in
23
+ * `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for
24
+ * that gate rather than silently proceeding as if it had passed.
25
+ *
26
+ * The merged registry is materialized by the canonical `buildRegistry`
27
+ * (re-exported from the generator, which ships) over a cap-map reconstructed
28
+ * from the frozen registry's capability objects plus the accepted overlay
29
+ * capabilities — so every derived view (bySkill, byLoopPoint, configSchema,
30
+ * capabilityClusters, profileMembership, …) is computed by exactly one builder
31
+ * and cannot drift from the first-party path.
32
+ *
33
+ * Install never executes capability code here (staging/exec belongs to ADR-1244
34
+ * D3/D5); this module only READS and VALIDATES declarations.
35
+ */
36
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ var desc = Object.getOwnPropertyDescriptor(m, k);
39
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
40
+ desc = { enumerable: true, get: function() { return m[k]; } };
41
+ }
42
+ Object.defineProperty(o, k2, desc);
43
+ }) : (function(o, m, k, k2) {
44
+ if (k2 === undefined) k2 = k;
45
+ o[k2] = m[k];
46
+ }));
47
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
48
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
49
+ }) : function(o, v) {
50
+ o["default"] = v;
51
+ });
52
+ var __importStar = (this && this.__importStar) || (function () {
53
+ var ownKeys = function(o) {
54
+ ownKeys = Object.getOwnPropertyNames || function (o) {
55
+ var ar = [];
56
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
57
+ return ar;
58
+ };
59
+ return ownKeys(o);
60
+ };
61
+ return function (mod) {
62
+ if (mod && mod.__esModule) return mod;
63
+ var result = {};
64
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
65
+ __setModuleDefault(result, mod);
66
+ return result;
67
+ };
68
+ })();
69
+ Object.defineProperty(exports, "__esModule", { value: true });
70
+ exports.loadRegistry = loadRegistry;
71
+ const fs = __importStar(require("node:fs"));
72
+ const os = __importStar(require("node:os"));
73
+ const path = __importStar(require("node:path"));
74
+ const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/;
75
+ const GSD_HOME_DIRNAME = '.gsd';
76
+ /**
77
+ * GENEROUS DoS backstop for the bounded per-scope ledger read (mirrors capability-ledger's
78
+ * LEDGER_MAX_BYTES). The project-scope ledger is repo-plantable untrusted content; reading it via
79
+ * the shared fd reader (regular-file + size cap) means a FIFO/device/symlinked ledger can no longer
80
+ * BLOCK (the #1459 raw-readFileSync hang) or read unbounded.
81
+ */
82
+ const LEDGER_MAX_BYTES = 8 * 1024 * 1024;
83
+ /**
84
+ * #1459 finding 2 (HIGH): GENEROUS DoS backstop on a project-plantable `capability.json`. The loader
85
+ * MUST read the manifest via the shared bounded fd reader (regular-file + size cap, no FIFO hang),
86
+ * NOT a raw `fs.readFileSync` — a repo-planted FIFO/device manifest would otherwise BLOCK the loader
87
+ * forever and an oversized manifest would read unbounded into memory (OOM). A legitimate manifest is a
88
+ * few KiB of declarative JSON; 8 MiB is wildly more than any real capability.json. A null/oversized/
89
+ * non-regular read → SKIP the overlay (warning), fail-closed.
90
+ */
91
+ const MANIFEST_MAX_BYTES = 8 * 1024 * 1024;
92
+ function errMessage(e) {
93
+ return e instanceof Error ? e.message : String(e);
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Test seams (#1461). The validator and generator are normally `require()`d
97
+ // fresh inside loadRegistry. These optional overrides let a test inject a
98
+ // validator whose cross-capability check THROWS (OVL-1) or a generator whose
99
+ // buildRegistry THROWS (OVL-2), to prove the loader still NEVER crashes the
100
+ // loop — it skips the offending overlay with a warning / falls back to the
101
+ // frozen first-party registry. Pass null to restore the real module.
102
+ // ---------------------------------------------------------------------------
103
+ let _validatorOverride = null;
104
+ let _generatorOverride = null;
105
+ /** Test seam: override the capability validator module. Pass null to restore. */
106
+ function _setValidatorForTest(v) {
107
+ _validatorOverride = v;
108
+ }
109
+ /** Test seam: override the registry generator module. Pass null to restore. */
110
+ function _setGeneratorForTest(g) {
111
+ _generatorOverride = g;
112
+ }
113
+ /** Resolve the running GSD version; fail-closed to '0.0.0' if it cannot be read. */
114
+ function readHostVersion() {
115
+ try {
116
+ // gsd-core/bin/lib/ -> repo/package root is three levels up.
117
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
118
+ const pkg = require('../../../package.json');
119
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
120
+ }
121
+ catch {
122
+ return '0.0.0';
123
+ }
124
+ }
125
+ /**
126
+ * Canonicalize a directory path for dedup/scope-escalation comparison. #1459 finding 1 (HIGH): the dedup
127
+ * MUST collapse two DIFFERENT LEXICAL paths that name the SAME PHYSICAL directory (a symlink) to one key,
128
+ * else a symlinked GSD_HOME aliasing the project root is scanned once as trusted 'global' BEFORE the
129
+ * 'project' scan and the in-repo `.gsd/capabilities` bundle bypasses the CB-3 consent gate via aliasing.
130
+ * `fs.realpathSync` resolves symlinks to the physical path; on ENOENT/IO error it falls back to
131
+ * `path.resolve` (a not-yet-created overlay dir cannot be realpath'd).
132
+ *
133
+ * #1459 CONVERGENCE finding 3 (LOW/MED): the realpath FAILURE must be reported to the caller (the
134
+ * `realpathFailed` flag), NOT silently swallowed. The old behavior — fall back to `path.resolve` while
135
+ * preserving the candidate's ORIGINAL scope — was not strictly fail-safe: a symlinked GSD_HOME whose
136
+ * realpath THROWS (a race / odd-FS) would key on its SYMLINK-LEXICAL path, which differs from the
137
+ * project candidate's realpath'd key, so the two would NOT merge and the aliased global root would be
138
+ * scanned as trusted-'global' (no consent record required) — parking an aliased project tree in the
139
+ * trusted-global slot. The caller (`overlayRoots`) uses `realpathFailed` to classify a realpath-failed
140
+ * GLOBAL candidate CONSERVATIVELY (consent-required 'project'), so a race/odd-FS can never aliased-upgrade
141
+ * an in-repo bundle to trusted-global. The fallback key is still `path.resolve` (best-effort dedup); a
142
+ * normal ENOENT (the global capabilities dir simply does not exist yet) still resolves to no scan because
143
+ * the later readdir fails — the conservative reclassification is harmless when there is nothing to read.
144
+ */
145
+ function canonicalDir(dir) {
146
+ try {
147
+ return { path: fs.realpathSync(dir), realpathFailed: false, enoent: false };
148
+ }
149
+ catch (err) {
150
+ // #1459 finding 1 (round 6): distinguish a NON-EXISTENT overlay dir (ENOENT — there is simply nothing
151
+ // to scan at that scope, so the fail-safe demotion must NOT fire) from a realpath that fails for ANOTHER
152
+ // reason (race / odd-FS / EIO / EACCES — the dir may exist but is uncanonicalizable, so we cannot prove
153
+ // physical distinctness and MUST fail safe toward needs-consent).
154
+ const code = err.code;
155
+ const enoent = code === 'ENOENT' || code === 'ENOTDIR';
156
+ return { path: path.resolve(dir), realpathFailed: true, enoent };
157
+ }
158
+ }
159
+ /**
160
+ * The ordered overlay install roots (global first, then project), deduped by
161
+ * CANONICAL (realpath'd) absolute path so a single physical directory is never scanned twice (which
162
+ * would otherwise self-report a spurious id collision when the project lives
163
+ * under the GSD home, or in tests where both resolve to the same fixture).
164
+ *
165
+ * #1459 CB-3: when the consent-global home resolves EQUAL to (or an ancestor whose .gsd collides with)
166
+ * a GENUINE project root, the global overlay dir and the project overlay dir are the SAME directory.
167
+ * The dedup must NOT then keep it as 'global' (trusted, no consent record required) — that would let an
168
+ * in-repo bundle bypass consent simply because GSD_HOME pointed at the repo. On a collision the
169
+ * surviving scope escalates to the MORE RESTRICTIVE 'project' (consent-required), but ONLY when the
170
+ * colliding root is a GENUINE marker'd project (a `.planning/` dir or a `.git`). `findProjectRoot` is
171
+ * total — it returns `cwd` itself when no marker exists — so a bare GSD_HOME with no project marker
172
+ * (the user's own home; also the test-fixture `cwd === home` no-op) must stay 'global' and NOT spuriously
173
+ * demand consent.
174
+ *
175
+ * #1459 finding 1 (HIGH): BOTH the dedup key AND the CB-3 collision comparison are keyed on the
176
+ * realpath'd path (canonicalDir), so a symlinked GSD_HOME that physically IS the project root collides
177
+ * and escalates to consent-required 'project' — it can no longer be aliased into the trusted-global slot.
178
+ *
179
+ * #1459 finding 1 (HIGH, ROUND 6): the trusted-global slot is now gated on PROVABLE distinctness from the
180
+ * project tree — realpath(global) AND realpath(project) must BOTH succeed AND resolve to DIFFERENT physical
181
+ * paths. The earlier one-sided rule (demote only a realpath-FAILED *global* candidate) still allowed the
182
+ * symlinked-GSD_HOME bypass: when GSD_HOME aliases the project root, the GLOBAL candidate realpaths fine
183
+ * while the PROJECT candidate's realpath fails, so the keys never collide and the in-repo bundle stays in
184
+ * the no-consent global slot. If distinctness cannot be proven (either realpath throws, or both resolve
185
+ * EQUAL) AND there is a genuine project root, the global is demoted to consent-required 'project'.
186
+ */
187
+ function hasGenuineProjectMarker(dir) {
188
+ try {
189
+ const planning = path.join(dir, '.planning');
190
+ if (fs.existsSync(planning) && fs.statSync(planning).isDirectory())
191
+ return true;
192
+ }
193
+ catch { /* fall through */ }
194
+ try {
195
+ if (fs.existsSync(path.join(dir, '.git')))
196
+ return true;
197
+ }
198
+ catch { /* fall through */ }
199
+ return false;
200
+ }
201
+ function overlayRoots(cwd, gsdHome) {
202
+ const roots = [];
203
+ const byPath = new Map();
204
+ const add = (dir, scope, canonical, genuineProject = false) => {
205
+ const resolved = path.resolve(dir);
206
+ // #1459 finding 1: the DEDUP KEY (and thus the CB-3 scope-escalation comparison) is the CANONICAL
207
+ // (realpath'd) path, so a symlinked GSD_HOME that physically IS the project root collides here (and
208
+ // escalates below) instead of being scanned as a distinct trusted 'global' root. The SCANNED path
209
+ // (`entry.dir`) stays the lexical `path.resolve` value — the readdir/commandRoots path is unchanged
210
+ // for the common (non-symlinked) case; only the dedup/escalation decision is realpath-aware.
211
+ const key = canonical.path;
212
+ const existing = byPath.get(key);
213
+ if (existing) {
214
+ // CB-3: a dir already claimed escalates to the more restrictive scope ONLY for a GENUINE project
215
+ // root — so a real GSD_HOME == projectRoot (incl. via a symlink) still requires consent, while a
216
+ // marker-less home stays trusted-global (and the test-fixture cwd===home no-op is preserved).
217
+ if (existing.scope === 'global' && scope === 'project' && genuineProject)
218
+ existing.scope = 'project';
219
+ return;
220
+ }
221
+ const entry = { dir: resolved, scope };
222
+ byPath.set(key, entry);
223
+ roots.push(entry);
224
+ };
225
+ const home = gsdHome || process.env['GSD_HOME'] || os.homedir();
226
+ const globalDir = path.join(home, GSD_HOME_DIRNAME, 'capabilities');
227
+ const globalCanon = canonicalDir(globalDir);
228
+ let projectRoot = null;
229
+ try {
230
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
231
+ const projectRootMod = require('./project-root.cjs');
232
+ projectRoot = projectRootMod.findProjectRoot(cwd);
233
+ }
234
+ catch {
235
+ projectRoot = null;
236
+ }
237
+ const projectDir = projectRoot ? path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities') : null;
238
+ const projectCanon = projectDir ? canonicalDir(projectDir) : null;
239
+ // #1459 finding 1 (HIGH, round 6): the global overlay root is trusted (consent-FREE) ONLY when we can
240
+ // PROVE it is a distinct physical directory from the project overlay tree — i.e. realpath(global) AND
241
+ // realpath(project) BOTH succeed AND resolve to DIFFERENT physical paths. A one-sided rule (demote only a
242
+ // realpath-FAILED *global* candidate) left the symlinked-GSD_HOME bypass open: when GSD_HOME is a symlink
243
+ // alias of the project root, the GLOBAL candidate realpaths fine (stays trusted-global) while the PROJECT
244
+ // candidate's realpath fails → the two keys never collide → the in-repo bundle stays in the no-consent
245
+ // global slot. So the global is demoted to consent-required 'project' (only when there IS a GENUINE
246
+ // project root, so a marker-less home / cwd===home stays trusted-global) whenever distinctness cannot be
247
+ // proven: EITHER realpath throws, OR both succeed but resolve EQUAL (an alias). When the demoted-global
248
+ // and the project candidate physically coincide they then dedup onto one consent-required entry; when
249
+ // they are merely unprovable-distinct (e.g. global realpath failed) the global is independently demoted
250
+ // so an aliased in-repo tree it would scan still requires a record. A genuinely non-existent global dir
251
+ // (ENOENT) realpath-fails too, but its later readdir fails, so this demotion is a harmless no-op there.
252
+ let globalScope = 'global';
253
+ if (projectRoot && projectCanon && hasGenuineProjectMarker(projectRoot)) {
254
+ // The fail-safe only matters when there IS an in-repo overlay tree to protect. A NON-EXISTENT project
255
+ // overlay dir (ENOENT) has nothing to bypass into the trusted-global slot, so the global stays trusted
256
+ // (and a genuinely distinct real global cap is not spuriously demoted — the control case). Otherwise,
257
+ // demote the global to consent-required 'project' UNLESS we can PROVE physical distinctness:
258
+ // - the project overlay actually exists (or can't be proven absent), AND
259
+ // - either realpath can't canonicalize one side (race/odd-FS → can't prove distinct), OR
260
+ // - both canonicalize EQUAL (an alias — GSD_HOME physically IS the project root).
261
+ const projectAbsent = projectCanon.realpathFailed && projectCanon.enoent;
262
+ if (!projectAbsent) {
263
+ const provablyDistinct = !globalCanon.realpathFailed &&
264
+ !projectCanon.realpathFailed &&
265
+ globalCanon.path !== projectCanon.path;
266
+ if (!provablyDistinct)
267
+ globalScope = 'project';
268
+ }
269
+ }
270
+ add(globalDir, globalScope, globalCanon);
271
+ if (projectDir && projectCanon) {
272
+ add(projectDir, 'project', projectCanon, hasGenuineProjectMarker(projectRoot));
273
+ }
274
+ return roots;
275
+ }
276
+ /**
277
+ * Resolve the PROJECT ROOT for `cwd` used to LOOK UP a project-scope consent record (#1459). Delegates
278
+ * to the SINGLE canonical `consentProjectRoot` helper (IC-01/CB-4) so the loader's lookup key always
279
+ * matches the install RECORD key and the `trust revoke` key — installing from a subdir then resolves
280
+ * to the same realpath'd project root the loader checks (no install-then-inactive). Falls back to
281
+ * `cwd` if the project-root module cannot be loaded at all (the consent store realpaths it).
282
+ */
283
+ function projectRootFor(cwd) {
284
+ try {
285
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
286
+ const projectRootMod = require('./project-root.cjs');
287
+ return projectRootMod.consentProjectRoot(cwd);
288
+ }
289
+ catch { /* fall through */ }
290
+ return cwd;
291
+ }
292
+ /**
293
+ * Read the per-scope ledger co-located with an overlay root (the root is `<scope>/.gsd/capabilities`,
294
+ * so its ledger is `<scope>/.gsd-capabilities.json`) and classify its ids:
295
+ * - `pending`: ids carrying an in-flight `_pending` intent (crashed/uncommitted install/upgrade)
296
+ * — must not be activated until reconciliation completes.
297
+ * - `committed`: ids with a ledger entry and NO `_pending` — i.e. an install the user actually
298
+ * completed (and, for executable surfaces, CONSENTED to). This is the authoritative
299
+ * consent signal required before dispatching a capability's CLI COMMANDS (ADR-1244
300
+ * Phase 5 / D7): a bundle merely dropped on disk with no ledger entry is NOT
301
+ * consented and its command family must not be dispatchable.
302
+ * Never throws: a missing/invalid ledger yields empty sets.
303
+ */
304
+ /**
305
+ * Is `e` a structurally-valid COMMITTED ledger entry for `id`? Delegates the structural shape to
306
+ * capability-ledger's SHARED `isValidLedgerEntry` (loader/ledger validator PARITY — #1459 ROOT FIX:
307
+ * the loader previously hand-duplicated the shape and could drift), and ADDS the loader-specific
308
+ * "committed = valid AND carries NO `_pending` marker" semantic. A malformed/tampered/pending entry
309
+ * fails this check and is therefore NOT treated as committed — fail closed.
310
+ */
311
+ function isCommittedLedgerEntry(ledger, id, e) {
312
+ if (!e || typeof e !== 'object' || Array.isArray(e))
313
+ return false;
314
+ if (Object.prototype.hasOwnProperty.call(e, '_pending'))
315
+ return false; // intent ⇒ uncommitted.
316
+ return ledger.isValidLedgerEntry(id, e);
317
+ }
318
+ function ledgerOverlayIds(ledger, rootDir) {
319
+ const pending = new Set();
320
+ const committed = new Set();
321
+ try {
322
+ const ledgerPath = path.join(rootDir, '..', '..', '.gsd-capabilities.json');
323
+ // #1459 (HIGH): read the per-scope ledger via the SHARED fd-based bounded reader (open → fstat →
324
+ // require regular file → size cap → read exactly size). The previous raw `fs.readFileSync` BLOCKED
325
+ // forever on a repo-planted FIFO ledger (a project-scope DoS) and read an oversized file whole.
326
+ const content = ledger.readSmallRegularFile(ledgerPath, LEDGER_MAX_BYTES);
327
+ if (content === null)
328
+ return { pending, committed }; // genuinely missing.
329
+ const parsed = JSON.parse(content);
330
+ if (!parsed || typeof parsed !== 'object')
331
+ return { pending, committed };
332
+ const entries = parsed['entries'];
333
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
334
+ return { pending, committed };
335
+ for (const [id, entry] of Object.entries(entries)) {
336
+ if (!entry || typeof entry !== 'object')
337
+ continue;
338
+ if (entry['_pending']) {
339
+ pending.add(id); // a truthy in-flight intent — defer/skip until reconciliation
340
+ }
341
+ else if (isCommittedLedgerEntry(ledger, id, entry)) {
342
+ committed.add(id); // a genuine, structurally-valid commit
343
+ }
344
+ // else: malformed / tampered / falsy-_pending → neither (fail closed: declarative-only)
345
+ }
346
+ }
347
+ catch { /* missing/invalid/non-regular/oversized ledger — no pending, no committed (fail closed) */ }
348
+ return { pending, committed };
349
+ }
350
+ /** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */
351
+ function withOverlayMeta(reg, meta) {
352
+ return Object.assign({}, reg, { _overlay: meta });
353
+ }
354
+ /**
355
+ * Loop extension points a capability declares a gate at (the `point` strings off `cap.gates`).
356
+ * SINGLE source of truth shared by BOTH the per-candidate `skip()` closure AND the OVL-2
357
+ * buildRegistry-failure fallback (#1461) so a dropped gate-declaring overlay fails CLOSED via the
358
+ * SAME extraction the per-candidate path uses — never one path blocking and the other failing open.
359
+ *
360
+ * #1461 finding 1 (HIGH): this MUST be TOTAL over an UNTRUSTED, possibly-malformed manifest — it
361
+ * runs on a candidate BEFORE per-candidate validation has confirmed the shape. A null `cap`, a
362
+ * non-object `cap`, a non-array `cap.gates` (e.g. `gates: {}` / `gates: null`), or a malformed gate
363
+ * ENTRY (`gates: [null]` / `gates: ["x"]` / a gate with a non-string `point`) must NEVER throw: it
364
+ * returns only the extractable `point` strings, filtering null/non-object/malformed entries. A
365
+ * `null` gate has no extractable point, so it contributes nothing (no spurious fail-closed block).
366
+ */
367
+ function gatePointsOf(cap) {
368
+ if (!cap || typeof cap !== 'object')
369
+ return [];
370
+ const gates = cap.gates;
371
+ if (!Array.isArray(gates))
372
+ return [];
373
+ return gates
374
+ .map((g) => g && typeof g === 'object' && typeof g.point === 'string'
375
+ ? g.point
376
+ : null)
377
+ .filter((p) => typeof p === 'string');
378
+ }
379
+ /**
380
+ * Load the capability registry, optionally composing the installed overlay.
381
+ *
382
+ * @returns the registry object (same shape as `capability-registry.cjs`). When
383
+ * overlays are considered, an `_overlay` field carries skip warnings and the
384
+ * fail-closed gate list. With `includeInstalled` falsy, the frozen first-party
385
+ * registry is returned unchanged (identity-stable).
386
+ */
387
+ function loadRegistry(options = {}) {
388
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
389
+ const base = require('./capability-registry.cjs');
390
+ if (!options.includeInstalled)
391
+ return base;
392
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
393
+ const validator = _validatorOverride ?? require('./capability-validator.cjs');
394
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
395
+ const semver = require('./semver-compare.cjs');
396
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
397
+ const ledgerMod = require('./capability-ledger.cjs');
398
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
399
+ const consentMod = require('./capability-consent.cjs');
400
+ const cwd = options.cwd || process.cwd();
401
+ const hostVersion = options.hostVersion || readHostVersion();
402
+ // The user-owned consent home — SAME `gsdHome || GSD_HOME || homedir()` rule the CLI uses, so the
403
+ // consent the CLI records is the consent the loader checks. The consent store NEVER lives in a repo.
404
+ const gsdHome = options.gsdHome || process.env['GSD_HOME'] || os.homedir();
405
+ const warnings = [];
406
+ const incompatibleGateCapIds = [];
407
+ const blockedGates = [];
408
+ const commandRoots = {};
409
+ const overlayCaps = [];
410
+ // First-party reservations — first-party always wins.
411
+ const fpCaps = (base.capabilities ?? {});
412
+ const fpBySkill = (base.bySkill ?? {});
413
+ const fpByAgent = (base.byAgent ?? {});
414
+ const fpConfigKeys = (base.configKeys ?? {});
415
+ const fpConfigSchema = (base.configSchema ?? {});
416
+ const fpFamilies = (base.commandFamilies ?? {});
417
+ const fpIds = new Set(Object.keys(fpCaps));
418
+ const claimedSkills = new Set(Object.keys(fpBySkill));
419
+ const claimedAgents = new Set(Object.keys(fpByAgent));
420
+ const claimedConfig = new Set([...Object.keys(fpConfigKeys), ...Object.keys(fpConfigSchema)]);
421
+ const claimedFamilies = new Set(Object.keys(fpFamilies));
422
+ const acceptedIds = new Set();
423
+ // Running merged cap-map (first-party ∪ accepted overlays). A candidate is
424
+ // accepted only if the FULL cross-capability suite stays clean after adding it
425
+ // (first-party alone is clean, so any new error is the candidate's fault) — the
426
+ // overlay can never violate the same invariants the build-time generator enforces.
427
+ const acceptedMap = new Map(Object.entries(fpCaps));
428
+ // Generator (buildRegistry + central config keys) loaded lazily — only when at
429
+ // least one overlay candidate exists, so the no-overlay fast path stays cheap.
430
+ let generatorMod = null;
431
+ const getGenerator = () => {
432
+ if (generatorMod)
433
+ return generatorMod;
434
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
435
+ const mod = _generatorOverride ?? require('../../../scripts/gen-capability-registry.cjs');
436
+ generatorMod = mod;
437
+ return mod;
438
+ };
439
+ let centralKeys = null;
440
+ const getCentralKeys = () => {
441
+ if (!centralKeys) {
442
+ try {
443
+ centralKeys = getGenerator().loadCentralConfigKeys();
444
+ }
445
+ catch {
446
+ centralKeys = new Set();
447
+ }
448
+ }
449
+ return centralKeys;
450
+ };
451
+ for (const root of overlayRoots(cwd, options.gsdHome)) {
452
+ let entries;
453
+ try {
454
+ entries = fs.readdirSync(root.dir, { withFileTypes: true });
455
+ }
456
+ catch {
457
+ continue; // no overlay dir at this scope — normal
458
+ }
459
+ // Ids whose ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted
460
+ // install or upgrade). They are NOT yet committed, so they must not be activated — reconcile
461
+ // will roll them forward or back. Fail OPEN (skip without a gate block): an uncommitted gate
462
+ // is not a real installed gate. See capability-lifecycle.cts (ADR-1244 Phase 4).
463
+ const { pending: pendingIds, committed: committedIds } = ledgerOverlayIds(ledgerMod, root.dir);
464
+ for (const ent of entries) {
465
+ if (!ent.isDirectory())
466
+ continue;
467
+ const id = ent.name;
468
+ const capDir = path.join(root.dir, id);
469
+ const manifestPath = path.join(capDir, 'capability.json');
470
+ if (pendingIds.has(id)) {
471
+ warnings.push({ id, scope: root.scope, reason: 'install/upgrade in progress (uncommitted) — deferred until reconciliation' });
472
+ continue;
473
+ }
474
+ let cap;
475
+ try {
476
+ // #1459 finding 2 (HIGH): read the manifest via the SHARED fd-based bounded reader (open → fstat
477
+ // → require regular file → size cap → read exactly size). A project-planted FIFO/device manifest
478
+ // can no longer BLOCK the loader (the raw readFileSync hang) and an oversized manifest can no
479
+ // longer read unbounded. A null read (genuinely missing OR refused as non-regular/oversized) →
480
+ // skip the overlay, fail-closed.
481
+ const manifestRaw = ledgerMod.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES);
482
+ if (manifestRaw === null) {
483
+ warnings.push({ id, scope: root.scope, reason: 'capability.json missing, non-regular (FIFO/device), or exceeds the size cap — skipped' });
484
+ continue;
485
+ }
486
+ cap = JSON.parse(manifestRaw);
487
+ }
488
+ catch (e) {
489
+ warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) });
490
+ continue;
491
+ }
492
+ // Points at which this capability declares a gate — used to fail CLOSED if
493
+ // the capability is skipped (a skipped deploy gate must block, not pass).
494
+ const gatePoints = gatePointsOf(cap);
495
+ const declaresGate = gatePoints.length > 0;
496
+ const skip = (reason) => {
497
+ warnings.push({ id, scope: root.scope, reason });
498
+ if (declaresGate) {
499
+ incompatibleGateCapIds.push(id);
500
+ for (const point of gatePoints)
501
+ blockedGates.push({ point, capId: id, reason });
502
+ }
503
+ };
504
+ // #1461 finding 1 (HIGH): make the ENTIRE per-candidate processing body TOTAL. The committed
505
+ // validator is NOT total for malformed ARRAY entries — validateGate/validateStep/
506
+ // validateContribution dereference an entry (`.point`, `.into`, …) BEFORE any shape check, so a
507
+ // manifest with `gates: [null]` (or `steps: [null]` / `contributions: [null]`) makes
508
+ // validateCapability THROW `Cannot read properties of null (reading 'point')`. That throw was
509
+ // OUTSIDE any per-candidate guard → it escaped loadRegistry and crashed EVERY consumer
510
+ // (loop-resolver, config-loader, surface, capability-state, gsd-tools). ADR-1244 D2 mandates a
511
+ // malformed overlay is SKIPPED with a warning, never crashes the loop. Wrapping the whole body
512
+ // (manifest already parsed above) means ANY throw from ANY validator/step becomes a structured
513
+ // `skip()` + continue to the next candidate — which ALSO fail-closes a declared gate (the `skip`
514
+ // closure records incompatibleGateCapIds/blockedGates for the extractable gate points). The
515
+ // existing structured skip/continue paths inside are unchanged; this is a fail-safe BACKSTOP for
516
+ // a validator/step that THROWS rather than returning errors. `continue` inside this try simply
517
+ // advances the `for` loop (there is no finally to interfere).
518
+ try {
519
+ // 1. Reserved namespace — third-party may not impersonate first-party.
520
+ if (RESERVED_ID_PREFIX.test(id)) {
521
+ skip('id uses a reserved first-party prefix (gsd-/gsd-core-/anthropic-)');
522
+ continue;
523
+ }
524
+ // 2. Per-capability structural + version-envelope validation.
525
+ const errs = validator.validateCapability(cap, id);
526
+ if (errs.length) {
527
+ skip('failed validation: ' + errs.join('; '));
528
+ continue;
529
+ }
530
+ // 3. First-party wins + overlay/overlay de-dup on id, skill, agent, config key.
531
+ if (fpIds.has(id) || acceptedIds.has(id)) {
532
+ skip('id collides with an already-registered capability');
533
+ continue;
534
+ }
535
+ const skills = Array.isArray(cap.skills) ? cap.skills : [];
536
+ const agents = Array.isArray(cap.agents) ? cap.agents : [];
537
+ const cfgKeys = cap.config && typeof cap.config === 'object' && !Array.isArray(cap.config)
538
+ ? Object.keys(cap.config) : [];
539
+ const skillClash = skills.find((s) => claimedSkills.has(s));
540
+ if (skillClash) {
541
+ skip('owns skill "' + skillClash + '" already owned by another capability');
542
+ continue;
543
+ }
544
+ const agentClash = agents.find((a) => claimedAgents.has(a));
545
+ if (agentClash) {
546
+ skip('owns agent "' + agentClash + '" already owned by another capability');
547
+ continue;
548
+ }
549
+ const cfgClash = cfgKeys.find((k) => claimedConfig.has(k));
550
+ if (cfgClash) {
551
+ skip('owns config key "' + cfgClash + '" already owned by another capability');
552
+ continue;
553
+ }
554
+ const families = Array.isArray(cap.commands)
555
+ ? cap.commands
556
+ .map((c) => (c && typeof c === 'object' && typeof c.family === 'string' ? c.family : null))
557
+ .filter((f) => typeof f === 'string')
558
+ : [];
559
+ const familyClash = families.find((f) => claimedFamilies.has(f));
560
+ if (familyClash) {
561
+ skip('owns command family "' + familyClash + '" already owned by another capability');
562
+ continue;
563
+ }
564
+ // 4. Load-time engines.gsd re-gate.
565
+ const range = cap.engines?.gsd;
566
+ if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) {
567
+ skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")');
568
+ continue;
569
+ }
570
+ // 5. #1459 — USER-OWNED CONSENT GATE (TRUST-1 + TRUST-3). For a PROJECT-scope overlay the
571
+ // authoritative consent signal is NOT the in-repo ledger (repo-plantable: a clone/fork
572
+ // activated executable surfaces AND declarative loop surfaces with no user decision) but a
573
+ // record in the user-owned consent store on THIS machine, bound to (realpath(projectRoot),
574
+ // id, RECOMPUTED full-bundle content hash). If there is NO matching record we do NOT push
575
+ // the cap into acceptedMap/overlayCaps and do NOT set a commandRoot → the cap is
576
+ // DISCOVERED-BUT-INACTIVE (a warning records why). This single gate closes BOTH
577
+ // command-dispatch (TRUST-1) and declarative-surface (TRUST-3) activation. GLOBAL scope is
578
+ // under the user's own home and is trusted as before (no consent record required).
579
+ //
580
+ // CONVERGENCE finding 1 (HIGH): this gate now runs BEFORE the heavy/unbounded pre-activation
581
+ // work (materializeHookFragments — which reads each `fragment.path` off disk — and the full
582
+ // cross-capability validation). A forged in-repo PROJECT overlay can point a `fragment.path`
583
+ // at an in-bundle FIFO/oversized file; materializing it BEFORE the consent check would
584
+ // hang/OOM the loader before the unconsented → inactive fail-closed path is reached. Running
585
+ // the (already bounded + fail-closed) consent recompute FIRST means an unconsented project
586
+ // overlay skips with NO further disk work. The gate's DECISION is identical — only the
587
+ // work-ordering moved (consented project overlays + GLOBAL overlays still materialize below).
588
+ //
589
+ // CONTENT BINDING (#1459 round 2, CB-1/CB-2/TRUST2-5): the binding is the bundle CONTENT
590
+ // HASH recomputed HERE over the on-disk capDir (manifest AND artifacts AND identity) — NOT
591
+ // the ledger `integrity` (which is `''` for path/git/dir installs and taken verbatim from
592
+ // the repo-plantable project ledger → degenerate `'' === ''`) and NOT the executable-only
593
+ // disclosure signature (a declarative-only swap leaves it constant). Any tamper — a swapped
594
+ // declarative capability.json, an edited hook script, an empty-integrity local install —
595
+ // changes the recomputed hash and the cap stays inactive. `bundleContentHash` is itself
596
+ // bounded + fail-closed (it refuses non-regular bundle files and reads via the shared bounded
597
+ // reader), so it cannot hang on a forged FIFO bundle file. The whole lookup is wrapped so a
598
+ // consent-store read / hash-recompute failure fails CLOSED (inactive), never crashing the
599
+ // loop (the loader must stay non-throwing end to end).
600
+ //
601
+ // IRREDUCIBLE TOCTOU LIMIT (#1459 / mirrors the #1462 lock-release residual): the hash
602
+ // verified HERE binds the bundle's on-disk content at THIS instant. A local writer racing
603
+ // between this verification and the capability's LATER execution (a hook firing, a command
604
+ // dispatch) can still mutate the bundle files after the check passes — this is a filesystem
605
+ // primitive limit, not a loader bug: short of fd-pinned execution or an atomic content
606
+ // snapshot (which needs native support we do not have here), no userspace check can close the
607
+ // window between "verify content" and "execute content". This is documented, not dismissed:
608
+ // the gate is the strongest defense available at this layer (any persisted tamper is caught on
609
+ // the NEXT load), and the residual race requires an attacker already able to write the project
610
+ // tree at execution time.
611
+ if (root.scope === 'project') {
612
+ let consented = false;
613
+ try {
614
+ consented = consentMod.hasProjectConsent({
615
+ gsdHome,
616
+ projectRoot: projectRootFor(cwd),
617
+ id,
618
+ contentHash: consentMod.bundleContentHash(capDir),
619
+ });
620
+ }
621
+ catch {
622
+ consented = false; // fail closed — a consent-store/hash-recompute failure never activates a cap.
623
+ }
624
+ if (!consented) {
625
+ // DISCOVERED-BUT-INACTIVE: no user consent record on this machine. NOT a gate block (an
626
+ // unconsented project gate is not a real installed gate — same fail-open posture as
627
+ // `_pending`); it simply does not contribute any surface. #1459 IC-02: tag the skip with the
628
+ // structural `kind: 'unconsented'` so gsd-tools `list` marks it INACTIVE by discriminant, not
629
+ // by matching the (changeable) reason prose. NOTE (convergence finding 1): we `continue` here
630
+ // BEFORE materializeHookFragments, so an unconsented project overlay's fragment files are never
631
+ // read — a forged FIFO/oversized fragment cannot hang/OOM the loop.
632
+ warnings.push({ id, scope: root.scope, kind: 'unconsented', reason: 'discovered — no user consent record (inactive)' });
633
+ continue;
634
+ }
635
+ }
636
+ // 5b. Materialize path-based hook fragments (resolved against the overlay dir). Runs AFTER the
637
+ // project consent gate (convergence finding 1) so only a CONSENTED project overlay (or a
638
+ // trusted GLOBAL overlay) reaches the fragment reads. materializeHookFragments RETURNS errors
639
+ // (e.g. a fragment path escaping the capability dir, OR — convergence finding 1(b) — a fragment
640
+ // that is non-regular/oversized and refused by the shared bounded reader) — capture them; an
641
+ // un-materializable fragment is a skip, never a hang.
642
+ let fragErrs;
643
+ try {
644
+ fragErrs = validator.materializeHookFragments(cap, capDir) || [];
645
+ }
646
+ catch (e) {
647
+ skip('hook fragment could not be materialized: ' + errMessage(e));
648
+ continue;
649
+ }
650
+ if (fragErrs.length) {
651
+ skip('invalid hook fragment: ' + fragErrs.join('; '));
652
+ continue;
653
+ }
654
+ // 6. Full cross-capability validation over the merged set (the same invariants
655
+ // the build-time generator enforces): contract roles, consumes-satisfiability,
656
+ // owner-uniqueness, config-key exclusivity vs central schema, requires acyclicity
657
+ // + tier-monotone. Incremental: add the candidate, validate, drop on any error.
658
+ acceptedMap.set(id, cap);
659
+ // #1461 OVL-1 (HIGH): these validators are CONTRACTED to RETURN error arrays, but one can THROW
660
+ // (e.g. validateConsumesGlobal asserting on a duplicate producer). An unguarded throw here
661
+ // escapes loadRegistry and crashes EVERY consumer (loop-resolver, config-loader, surface,
662
+ // capability-state, gsd-tools). ADR-1244 D2: a malformed overlay is SKIPPED with a warning,
663
+ // never crashes the loop. So a throwing validator is treated EXACTLY like a validation failure:
664
+ // drop this one candidate (with a warning) and continue — the rest of the overlay set is
665
+ // unaffected. (The returns-errors path below is unchanged.)
666
+ let crossErrs;
667
+ try {
668
+ crossErrs = [
669
+ ...validator.validateAgainstContract(cap, id),
670
+ ...validator.validateConsumesGlobal(acceptedMap),
671
+ ...validator.validateCrossCapability(acceptedMap, getCentralKeys()),
672
+ ];
673
+ }
674
+ catch (e) {
675
+ acceptedMap.delete(id);
676
+ skip('cross-capability validation error: ' + errMessage(e));
677
+ continue;
678
+ }
679
+ if (crossErrs.length) {
680
+ acceptedMap.delete(id);
681
+ skip('cross-capability validation failed: ' + crossErrs.slice(0, 3).join('; '));
682
+ continue;
683
+ }
684
+ // Accepted.
685
+ overlayCaps.push(cap);
686
+ acceptedIds.add(id);
687
+ for (const s of skills)
688
+ claimedSkills.add(s);
689
+ for (const a of agents)
690
+ claimedAgents.add(a);
691
+ for (const k of cfgKeys)
692
+ claimedConfig.add(k);
693
+ for (const f of families)
694
+ claimedFamilies.add(f);
695
+ // Record the install root for a third-party cap that ships command modules, so a runtime
696
+ // dispatcher can require() the router FROM the install root (ADR-1244 Phase 5 / D7). Gated on
697
+ // a COMMITTED ledger entry (committedIds): executable CLI commands run only for a capability
698
+ // the user actually installed+consented to via the lifecycle — a bundle merely dropped on
699
+ // disk with no ledger entry provides declarative surfaces (Phase 2) but is NOT command-
700
+ // dispatchable. (Project-scope ledgers live in the repo tree and are thus only as trustworthy
701
+ // as the repo — see docs/explanation/capability-trust-model.md.)
702
+ if (families.length > 0 && committedIds.has(id))
703
+ commandRoots[id] = capDir;
704
+ }
705
+ catch (e) {
706
+ // #1461 finding 1 (HIGH): ANY throw from ANY validator/step in the per-candidate body lands
707
+ // here — drop just THIS candidate with a structured skip-warning and continue with the rest of
708
+ // the overlay set (the loop is never crashed). `skip()` ALSO fail-closes the candidate's
709
+ // declared gates (incompatibleGateCapIds/blockedGates) so a malformed gate-declaring overlay
710
+ // blocks rather than silently passing. Remove any half-committed acceptedMap entry so the
711
+ // partially-processed candidate cannot leak into the final buildRegistry compose.
712
+ acceptedMap.delete(id);
713
+ skip('overlay processing error: ' + errMessage(e));
714
+ continue;
715
+ }
716
+ }
717
+ }
718
+ const meta = { warnings, incompatibleGateCapIds, blockedGates, commandRoots };
719
+ if (overlayCaps.length === 0) {
720
+ // Nothing to compose. Return the frozen registry unchanged when there is
721
+ // also nothing to report (identity-stable); otherwise attach diagnostics.
722
+ if (warnings.length === 0)
723
+ return base;
724
+ return withOverlayMeta(base, meta);
725
+ }
726
+ // Compose via the canonical builder so every derived view matches first-party.
727
+ // acceptedMap already holds first-party ∪ accepted overlays (validated above).
728
+ //
729
+ // #1461 OVL-2 (HIGH): an overlay can pass every per-candidate step yet trip a STRICTER whole-build
730
+ // check inside buildRegistry (config-slice shape, topo cycle across the merged set, configFormat
731
+ // parity). An unguarded buildRegistry throw escapes loadRegistry and crashes the loop. ADR-1244 D2
732
+ // mandates NEVER-CRASH: on a compose failure, fall back to the frozen FIRST-PARTY registry plus a
733
+ // warning recording why — the loop still gets a usable registry, just without the overlay surfaces.
734
+ try {
735
+ const merged = getGenerator().buildRegistry(acceptedMap);
736
+ return withOverlayMeta(merged, meta);
737
+ }
738
+ catch (e) {
739
+ const reason = 'buildRegistry failed composing overlays: ' + errMessage(e) + '; falling back to first-party';
740
+ meta.warnings.push({ id: '*', scope: 'global', reason });
741
+ // #1461 finding 3 (LOW): the fallback DROPS every accepted overlay, so NO dropped overlay may
742
+ // retain a command root. A stale `commandRoots[capId]` would let a runtime dispatcher require()/
743
+ // run a third-party command family FROM the install root of a capability the fallback decided NOT
744
+ // to load. Clear the map (the first-party base never lists overlay commandRoots — first-party
745
+ // command modules ship in bin/lib/, not via _overlay.commandRoots).
746
+ meta.commandRoots = {};
747
+ // #1461 OVL-2 fail-CLOSED on compose failure (HIGH): the fallback DROPS every accepted overlay,
748
+ // so any accepted overlay that DECLARED a gate would have its gate silently vanish → a blocking
749
+ // gate FAILS OPEN, violating ADR-1244 (a skipped capability declaring a gate must FAIL CLOSED).
750
+ // Record each dropped gate-declaring overlay's gate as blocked using the SAME extraction the
751
+ // per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver injects the synthetic
752
+ // blocking gate at each declared point exactly as it would for a per-candidate skip.
753
+ for (const cap of overlayCaps) {
754
+ const gatePoints = gatePointsOf(cap);
755
+ if (gatePoints.length === 0)
756
+ continue;
757
+ meta.incompatibleGateCapIds.push(cap.id);
758
+ for (const point of gatePoints)
759
+ meta.blockedGates.push({ point, capId: cap.id, reason });
760
+ }
761
+ return withOverlayMeta(base, meta);
762
+ }
763
+ }
764
+ module.exports = { loadRegistry, _setValidatorForTest, _setGeneratorForTest };