@opengsd/gsd-core 1.5.0 → 1.6.0-rc.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.
Files changed (63) 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/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -386,6 +386,120 @@ function dispatchCapabilityCommand({ command, args, cwd, raw, error, registry, r
386
386
  return true;
387
387
  }
388
388
 
389
+ /**
390
+ * Require a THIRD-PARTY capability's router module from its install root, confined to that root.
391
+ * The module name must be a bare `.cjs` basename (same conservative pattern the generator enforces).
392
+ * The install root is realpath-resolved (defeating symlinked path components) and the resolved
393
+ * module must live strictly inside it; the module file is then realpath-checked so a symlinked file
394
+ * cannot escape the root either. ADR-1244 Phase 5 (D7).
395
+ *
396
+ * @param {string} installRoot Absolute install-root dir of the owning capability
397
+ * @param {string} m Bare `.cjs` module basename from the capability manifest
398
+ * @returns {*} the required module
399
+ */
400
+ function defaultRequireFromInstallRoot(installRoot, m) {
401
+ if (typeof m !== 'string' || !/^[A-Za-z0-9._-]+\.cjs$/.test(m)) {
402
+ throw new Error('capability module must be a bare .cjs basename: ' + JSON.stringify(m));
403
+ }
404
+ // Realpath the root so a symlinked ancestor can't widen confinement.
405
+ const realRoot = fs.realpathSync(installRoot);
406
+ const resolved = path.resolve(realRoot, m);
407
+ if (resolved === realRoot || !resolved.startsWith(realRoot + path.sep)) {
408
+ throw new Error('capability module path escapes its install root: ' + JSON.stringify(m));
409
+ }
410
+ // The module file itself must not be a symlink pointing outside the root.
411
+ const realResolved = fs.realpathSync(resolved);
412
+ if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) {
413
+ throw new Error('capability module resolves outside its install root (symlink): ' + JSON.stringify(m));
414
+ }
415
+ return require(realResolved);
416
+ }
417
+
418
+ /**
419
+ * Dispatch a THIRD-PARTY (installed overlay) capability command family — ADR-1244 Phase 5 (D7).
420
+ * This is where third-party code executes, so it is doubly gated:
421
+ * - CONSENT: `loadRegistry({ includeInstalled })` excludes `_pending` (unconsented) capabilities,
422
+ * and only third-party caps that declared `commands` appear in `_overlay.commandRoots`. A capId
423
+ * absent from `commandRoots` is first-party (handled by dispatchCapabilityCommand) or not an
424
+ * installed overlay — we fall through.
425
+ * - CONFINEMENT: the router module is `require()`'d FROM the capability's install root, confined to
426
+ * that root (basename validation + realpath containment), so a manifest can never reach code
427
+ * outside its own bundle.
428
+ * Returns true when consumed (suppress "Unknown command"), false to fall through.
429
+ *
430
+ * @param {object} opts
431
+ * @param {Function} [opts.loadRegistry] Injectable overlay loader (for tests)
432
+ * @param {Function} [opts.requireModule] Injectable (installRoot, module) loader (for tests)
433
+ */
434
+ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, loadRegistry, requireModule }) {
435
+ if (command === '__proto__' || command === 'constructor' || command === 'prototype') {
436
+ return false;
437
+ }
438
+
439
+ let reg;
440
+ try {
441
+ const load = loadRegistry !== undefined ? loadRegistry : require('./lib/capability-loader.cjs').loadRegistry;
442
+ reg = load({ includeInstalled: true, cwd });
443
+ } catch (_) {
444
+ return false; // overlay load failed — fall through to "Unknown command"
445
+ }
446
+
447
+ const families = reg && reg.commandFamilies;
448
+ const commandRoots = reg && reg._overlay && reg._overlay.commandRoots;
449
+ if (!families || typeof families !== 'object' || !commandRoots || typeof commandRoots !== 'object') {
450
+ return false; // no installed overlay command families
451
+ }
452
+
453
+ const entry = families[command];
454
+ if (!entry || typeof entry !== 'object') return false;
455
+
456
+ // Only THIRD-PARTY overlay caps are dispatched here. A capId present in commandRoots is an
457
+ // accepted, committed (consented) overlay cap; a capId absent is first-party or not an overlay.
458
+ const capId = entry.capId;
459
+ if (typeof capId !== 'string' || !Object.prototype.hasOwnProperty.call(commandRoots, capId)) {
460
+ return false;
461
+ }
462
+ const installRoot = commandRoots[capId];
463
+ if (typeof installRoot !== 'string' || !installRoot) return false;
464
+
465
+ const loadModule = requireModule !== undefined ? requireModule : defaultRequireFromInstallRoot;
466
+ let mod;
467
+ try {
468
+ mod = loadModule(installRoot, entry.module);
469
+ } catch (_) {
470
+ error('capability command "' + command + '" module "' + entry.module + '" failed to load from its install root');
471
+ return true; // consumed — don't emit "Unknown command"
472
+ }
473
+
474
+ if (!mod || !Object.prototype.hasOwnProperty.call(mod, entry.router)) {
475
+ error('capability command "' + command + '" router "' + entry.router + '" is not an own export of module "' + entry.module + '"');
476
+ return true;
477
+ }
478
+ const fn = mod[entry.router];
479
+ if (typeof fn !== 'function') {
480
+ error('capability command "' + command + '" router "' + entry.router + '" is not a function in module "' + entry.module + '"');
481
+ return true;
482
+ }
483
+
484
+ let _result;
485
+ try {
486
+ _result = fn({ args, cwd, raw, error });
487
+ } catch (e) {
488
+ if (e instanceof ExitError) throw e;
489
+ error(
490
+ 'capability command "' + command + '" router "' + entry.router + '" in module "' + entry.module + '" threw: ' + (e && e.message ? e.message : String(e)),
491
+ ERROR_REASON.SDK_FAIL_FAST,
492
+ );
493
+ }
494
+ if (_result && typeof _result.then === 'function') {
495
+ error(
496
+ 'capability command "' + command + '" router "' + entry.router + '" in module "' + entry.module + '" must be synchronous (returned a Promise); async capability routers are not supported.',
497
+ ERROR_REASON.SDK_FAIL_FAST,
498
+ );
499
+ }
500
+ return true;
501
+ }
502
+
389
503
  // ─── Arg parsing helpers ──────────────────────────────────────────────────────
390
504
 
391
505
  // ─── CLI Router ───────────────────────────────────────────────────────────────
@@ -637,6 +751,14 @@ function captureStdoutSyncWrites(run) {
637
751
  return captured;
638
752
  }, (err) => {
639
753
  restore();
754
+ // The wrapped command may have written to stdout BEFORE it threw — e.g. a --raw
755
+ // command that emits a JSON result/error envelope and THEN throws ExitError to set a
756
+ // non-zero exit code (capability set/disable on an unknown id). Without this flush that
757
+ // captured output is silently discarded (the success-path flush at the call site never
758
+ // runs on a throw). Emit it now; the error still propagates so the exit code is preserved.
759
+ if (captured) {
760
+ try { originalWriteSync.call(fs, 1, resolveAtFileOutput(captured)); } catch { /* best-effort flush */ }
761
+ }
640
762
  throw err;
641
763
  });
642
764
  }
@@ -1301,6 +1423,118 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1301
1423
  // If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
1302
1424
  // be added at the same time to keep them consistent.
1303
1425
  const capSubcommand = args[1];
1426
+ // --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
1427
+ // Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
1428
+ // lazy-required inside each mutating branch so the common state/set paths never load them. ---
1429
+ const capFlagValue = (name) => {
1430
+ const i = args.indexOf(name);
1431
+ if (i === -1) return undefined;
1432
+ const v = args[i + 1];
1433
+ if (!v || v.startsWith('--')) {
1434
+ error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1435
+ }
1436
+ return v;
1437
+ };
1438
+ const capHasFlag = (name) => args.includes(name);
1439
+ const capRepeatedFlag = (name) => {
1440
+ const out = [];
1441
+ for (let i = 0; i < args.length; i++) {
1442
+ if (args[i] === name) {
1443
+ const v = args[i + 1];
1444
+ if (!v || v.startsWith('--')) {
1445
+ error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1446
+ }
1447
+ out.push(v);
1448
+ i++; // skip the consumed value
1449
+ }
1450
+ }
1451
+ return out;
1452
+ };
1453
+ // Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
1454
+ // .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
1455
+ // read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
1456
+ // project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
1457
+ // via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
1458
+ // Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
1459
+ // regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
1460
+ const capResolveScope = (scope) => {
1461
+ const s = scope || 'global';
1462
+ if (s !== 'global' && s !== 'project') {
1463
+ error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1464
+ }
1465
+ if (s === 'project') return { scope: 'project', runtimeDir: cwd };
1466
+ const os = require('node:os');
1467
+ return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
1468
+ };
1469
+ // capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
1470
+ // loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
1471
+ // undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
1472
+ // malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
1473
+ // silently downgraded to permissive here.
1474
+ const capReadStrict = () => {
1475
+ let cfgPath;
1476
+ try {
1477
+ const { planningDir } = require('./lib/planning-workspace.cjs');
1478
+ cfgPath = path.join(planningDir(cwd), 'config.json');
1479
+ } catch {
1480
+ return undefined; // cannot even resolve the project config dir — permissive default
1481
+ }
1482
+ if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
1483
+ let cfg;
1484
+ try {
1485
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
1486
+ } catch {
1487
+ // Config is PRESENT but unreadable/unparseable: a security policy must not silently
1488
+ // downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
1489
+ // still allowed) until the config is fixed.
1490
+ return [];
1491
+ }
1492
+ if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
1493
+ return cfg.capabilities.strict_known_registries;
1494
+ }
1495
+ return undefined;
1496
+ };
1497
+ // Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
1498
+ const capHostVersion = () => {
1499
+ try {
1500
+ const pkg = require('../../package.json'); // gsd-core/bin/ -> repo root is two up
1501
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
1502
+ } catch {
1503
+ return '0.0.0';
1504
+ }
1505
+ };
1506
+ // #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
1507
+ // live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
1508
+ // written here is the record the loader checks.
1509
+ const capConsentHome = () => {
1510
+ const osMod = require('node:os');
1511
+ return process.env.GSD_HOME || osMod.homedir();
1512
+ };
1513
+ // #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
1514
+ // record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
1515
+ // path cannot be realpath'd (e.g. it does not exist yet).
1516
+ const capProjectRoot = () => {
1517
+ try { return fs.realpathSync(cwd); } catch { return cwd; }
1518
+ };
1519
+ // UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
1520
+ // (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
1521
+ // bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
1522
+ // reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
1523
+ // follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
1524
+ // #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
1525
+ // half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
1526
+ // record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
1527
+ // reconcile revokes nothing.
1528
+ const capRunReconcile = (runtimeDir, lifecycle, scope) => {
1529
+ try {
1530
+ const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
1531
+ if (report && Array.isArray(report.warnings)) {
1532
+ for (const w of report.warnings) {
1533
+ try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
1534
+ }
1535
+ }
1536
+ } catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
1537
+ };
1304
1538
  if (capSubcommand === 'state') {
1305
1539
  const configDirIdx = args.indexOf('--config-dir');
1306
1540
  let configDir = null;
@@ -1390,9 +1624,443 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1390
1624
  { enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
1391
1625
  raw,
1392
1626
  );
1627
+ } else if (capSubcommand === 'install') {
1628
+ // capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
1629
+ const spec = args[2];
1630
+ if (!spec || spec.startsWith('--')) {
1631
+ error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1632
+ }
1633
+ const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
1634
+ const lifecycle = require('./lib/capability-lifecycle.cjs');
1635
+ const trust = require('./lib/capability-trust.cjs');
1636
+ // Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
1637
+ // staging, or any shared-config write — so an over-cap install fails fast with a clear count
1638
+ // error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
1639
+ // depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
1640
+ const installSharedFiles = capRepeatedFlag('--shared-file');
1641
+ const ledgerModInstall = require('./lib/capability-ledger.cjs');
1642
+ if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
1643
+ error(
1644
+ `capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
1645
+ `exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
1646
+ ERROR_REASON ? ERROR_REASON.USAGE : undefined,
1647
+ );
1648
+ }
1649
+ capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
1650
+ const res = await lifecycle.installCapability(spec, {
1651
+ runtimeDir,
1652
+ hostVersion: capHostVersion(),
1653
+ consentGranted: capHasFlag('--yes'),
1654
+ integrity: capFlagValue('--integrity'),
1655
+ sharedFiles: installSharedFiles,
1656
+ strictKnownRegistries: capReadStrict(),
1657
+ // #1459: bind a user consent record for a CONSENTED project install (under the user-owned
1658
+ // consent home, NOT in the repo). The lifecycle records nothing for global scope.
1659
+ scope,
1660
+ consentStoreDir: capConsentHome(),
1661
+ });
1662
+ if (res.status === 'installed') {
1663
+ output({
1664
+ status: 'installed',
1665
+ id: res.id,
1666
+ version: res.version,
1667
+ scope,
1668
+ disclosure: trust.summarizeDisclosure(res.disclosure || {}),
1669
+ }, raw);
1670
+ } else if (res.status === 'aborted') {
1671
+ // 'aborted' always means "executable surface needs consent" in the lifecycle contract —
1672
+ // match it regardless of the requiresConsent flag so a future aborted path can't fall
1673
+ // through to the generic "blocked: unknown reason" arm with a misleading message.
1674
+ const disclosure = trust.summarizeDisclosure(res.disclosure || {});
1675
+ // UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
1676
+ // can detect the consent requirement programmatically. We throw ExitError (not error(),
1677
+ // which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
1678
+ // is flushed before exit; the human-readable guidance still lands on stderr.
1679
+ output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
1680
+ throw new ExitError(
1681
+ 1,
1682
+ ['Error: This capability declares executable surfaces and needs your consent before install:']
1683
+ .concat(disclosure.map((l) => ' ' + l))
1684
+ .concat(['Re-run with --yes to grant consent and install.'])
1685
+ .join('\n'),
1686
+ );
1687
+ } else {
1688
+ error(
1689
+ `capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
1690
+ ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
1691
+ );
1692
+ }
1693
+ } else if (capSubcommand === 'update') {
1694
+ // capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
1695
+ const all = capHasFlag('--all');
1696
+ const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
1697
+ if (!all && !id) {
1698
+ error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1699
+ }
1700
+ if (all && id) {
1701
+ error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1702
+ }
1703
+ const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
1704
+ const lifecycle = require('./lib/capability-lifecycle.cjs');
1705
+ const ledgerMod = require('./lib/capability-ledger.cjs');
1706
+ const trust = require('./lib/capability-trust.cjs');
1707
+ // Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
1708
+ // the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
1709
+ // re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
1710
+ // a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
1711
+ const updateSharedFiles = capRepeatedFlag('--shared-file');
1712
+ if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
1713
+ error(
1714
+ `capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
1715
+ `exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
1716
+ ERROR_REASON ? ERROR_REASON.USAGE : undefined,
1717
+ );
1718
+ }
1719
+ capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
1720
+ // readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError
1721
+ // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
1722
+ // corrupt-but-present ledger fails closed rather than silently reporting not_installed (<id>)
1723
+ // or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
1724
+ let ledger;
1725
+ try {
1726
+ ledger = ledgerMod.readLedgerStrict(runtimeDir);
1727
+ } catch (err) {
1728
+ error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1729
+ }
1730
+ const entries = (ledger && ledger.entries) || {};
1731
+ const upgradeOne = async (capId) => {
1732
+ const entry = entries[capId];
1733
+ if (!entry) return { id: capId, status: 'not_installed' };
1734
+ // expectedId pins the op to the requested id: a retargeted/edited source that now resolves
1735
+ // to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
1736
+ const r = await lifecycle.upgradeCapability(entry.source, {
1737
+ runtimeDir,
1738
+ hostVersion: capHostVersion(),
1739
+ consentGranted: capHasFlag('--yes'),
1740
+ sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
1741
+ strictKnownRegistries: capReadStrict(),
1742
+ expectedId: capId,
1743
+ // #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
1744
+ scope,
1745
+ consentStoreDir: capConsentHome(),
1746
+ });
1747
+ // UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
1748
+ // them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
1749
+ // stable per-entry shape for `--all` consumers.
1750
+ return {
1751
+ id: capId,
1752
+ status: r.status,
1753
+ fromVersion: r.fromVersion ?? null,
1754
+ toVersion: r.toVersion ?? null,
1755
+ requiresConsent: r.requiresConsent ?? null,
1756
+ blockReasons: r.blockReasons ?? null,
1757
+ disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
1758
+ };
1759
+ };
1760
+ if (all) {
1761
+ // Sequential by design: each upgrade takes the per-scope capability lock; parallel
1762
+ // runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
1763
+ const results = [];
1764
+ for (const capId of Object.keys(entries)) {
1765
+ results.push(await upgradeOne(capId));
1766
+ }
1767
+ const failed = results.filter((x) => x.status !== 'upgraded');
1768
+ if (failed.length > 0) {
1769
+ // UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
1770
+ // alike), then set a non-zero exit. Previously the results JSON was embedded inside the
1771
+ // error STRING on stderr, so automation could not parse a partial-failure run as
1772
+ // structured data. We throw ExitError (not error(), which calls process.exit and would
1773
+ // bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
1774
+ // concise reason still lands on stderr.
1775
+ output({ scope, updated: results }, raw);
1776
+ throw new ExitError(
1777
+ 1,
1778
+ `Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
1779
+ `(see the JSON result on stdout for per-capability status).`,
1780
+ );
1781
+ }
1782
+ output({ scope, updated: results }, raw);
1783
+ } else {
1784
+ const r = await upgradeOne(id);
1785
+ if (r.status === 'upgraded') {
1786
+ output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
1787
+ } else if (r.status === 'not_installed') {
1788
+ error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1789
+ } else if (r.status === 'aborted') {
1790
+ // 'aborted' always means "needs consent" (see install) — handle it independently of the
1791
+ // requiresConsent flag so it never falls through to the generic blocked arm.
1792
+ error(
1793
+ [`capability update for "${id}" changes its executable surface and needs your consent:`]
1794
+ .concat((r.disclosure || []).map((l) => ' ' + l))
1795
+ .concat(['Re-run with --yes to grant consent and update.'])
1796
+ .join('\n'),
1797
+ ERROR_REASON ? ERROR_REASON.USAGE : undefined,
1798
+ );
1799
+ } else {
1800
+ error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1801
+ }
1802
+ }
1803
+ } else if (capSubcommand === 'remove') {
1804
+ // capability remove <id> [--purge-data] [--scope global|project]
1805
+ const id = args[2];
1806
+ if (!id || id.startsWith('--')) {
1807
+ error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1808
+ }
1809
+ const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
1810
+ const lifecycle = require('./lib/capability-lifecycle.cjs');
1811
+ const ledgerMod = require('./lib/capability-ledger.cjs');
1812
+ capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
1813
+ // Ledger first: an installed overlay is removable even if its id shadows a first-party name.
1814
+ // Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
1815
+ // Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
1816
+ // silently reporting "first-party cannot be removed" for any id (finding 7).
1817
+ let removeLedger;
1818
+ try {
1819
+ removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
1820
+ } catch (err) {
1821
+ error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1822
+ }
1823
+ const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
1824
+ if (!inLedger) {
1825
+ const base = require('./lib/capability-loader.cjs').loadRegistry();
1826
+ if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
1827
+ error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1828
+ }
1829
+ }
1830
+ const res = lifecycle.removeCapability(id, {
1831
+ runtimeDir,
1832
+ removeData: capHasFlag('--purge-data'),
1833
+ // #1459: a project-scope removal revokes the user consent record so a later repo-dropped
1834
+ // bundle of the same id cannot silently re-activate against a stale consent.
1835
+ scope,
1836
+ consentStoreDir: capConsentHome(),
1837
+ });
1838
+ if (res.status === 'removed') {
1839
+ // #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
1840
+ // could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
1841
+ // record remains. Surface it on stderr + in the JSON so the user knows to clear it.
1842
+ if (res.consentRevokeFailed) {
1843
+ process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
1844
+ }
1845
+ output({
1846
+ status: 'removed',
1847
+ id,
1848
+ scope,
1849
+ removedFiles: res.removedFiles,
1850
+ strippedEdits: res.strippedEdits,
1851
+ dataPreserved: res.dataPreserved,
1852
+ consentRevokeFailed: res.consentRevokeFailed || undefined,
1853
+ consentRevokeWarning: res.consentRevokeWarning || undefined,
1854
+ }, raw);
1855
+ } else if (res.status === 'not_installed') {
1856
+ error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1857
+ } else {
1858
+ error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1859
+ }
1860
+ } else if (capSubcommand === 'list') {
1861
+ // capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
1862
+ // When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
1863
+ // corrupt unrelated ledger in another scope does not block a scoped list).
1864
+ const loader = require('./lib/capability-loader.cjs');
1865
+ const ledgerMod = require('./lib/capability-ledger.cjs');
1866
+ const semver = require('./lib/semver-compare.cjs');
1867
+ const host = capHostVersion();
1868
+ const rows = [];
1869
+ const listScopeArg = capFlagValue('--scope');
1870
+ // Validate --scope if provided.
1871
+ if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
1872
+ error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1873
+ }
1874
+ // First-party capabilities are always included (they have no scope concept).
1875
+ const base = loader.loadRegistry();
1876
+ const fp = (base && base.capabilities) || {};
1877
+ // #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
1878
+ // (a bundle whose project ledger looks committed but has no user consent record on THIS
1879
+ // machine) is marked status:'inactive' with a reason, instead of silently appearing active.
1880
+ // loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
1881
+ const inactiveById = {};
1882
+ try {
1883
+ const composed = loader.loadRegistry({ includeInstalled: true, cwd });
1884
+ const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
1885
+ for (const w of overlayWarnings) {
1886
+ // #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
1887
+ // human-readable reason prose (which is free to change without breaking this filter).
1888
+ if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
1889
+ inactiveById[`${w.scope} ${w.id}`] = w.reason;
1890
+ }
1891
+ }
1892
+ } catch { /* best-effort — list still works without the inactive annotation */ }
1893
+ for (const capId of Object.keys(fp)) {
1894
+ const cap = fp[capId] || {};
1895
+ rows.push({
1896
+ id: capId,
1897
+ role: cap.role || null,
1898
+ version: cap.version || null,
1899
+ tier: cap.tier || null,
1900
+ source: 'first-party',
1901
+ scope: 'first-party',
1902
+ status: 'active',
1903
+ title: cap.title || null,
1904
+ });
1905
+ }
1906
+ // Overlay scopes: honor --scope to read only the requested scope (finding 8).
1907
+ const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
1908
+ for (const sc of overlayScopes) {
1909
+ const { runtimeDir } = capResolveScope(sc);
1910
+ // readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
1911
+ // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
1912
+ // corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
1913
+ // dropping overlay entries and returning a first-party-only list (site A fix, #1462).
1914
+ let ledger;
1915
+ try {
1916
+ ledger = ledgerMod.readLedgerStrict(runtimeDir);
1917
+ } catch (err) {
1918
+ // UX-3: name the offending scope so the user knows WHICH ledger to fix.
1919
+ error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
1920
+ }
1921
+ if (!ledger || !ledger.entries) continue;
1922
+ for (const capId of Object.keys(ledger.entries)) {
1923
+ const entry = ledger.entries[capId];
1924
+ let manifest = {};
1925
+ try {
1926
+ // #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
1927
+ // bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
1928
+ // a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
1929
+ // an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
1930
+ // declarative capability.json. A null (genuinely missing) or a bounded-reader throw
1931
+ // (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
1932
+ // (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
1933
+ const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
1934
+ manifest = raw === null ? {} : JSON.parse(raw);
1935
+ } catch { manifest = {}; }
1936
+ let status = 'active';
1937
+ let reason = null;
1938
+ const range = manifest.engines && manifest.engines.gsd;
1939
+ if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
1940
+ // #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
1941
+ const inactiveReason = inactiveById[`${sc} ${capId}`];
1942
+ if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
1943
+ rows.push({
1944
+ id: capId,
1945
+ role: manifest.role || null,
1946
+ version: entry.version || null,
1947
+ tier: manifest.tier || null,
1948
+ source: entry.source || null,
1949
+ scope: sc,
1950
+ status,
1951
+ reason,
1952
+ title: manifest.title || null,
1953
+ });
1954
+ }
1955
+ }
1956
+ output(rows, raw || capHasFlag('--json'));
1957
+ } else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
1958
+ // capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
1959
+ const id = args[2];
1960
+ if (!id || id.startsWith('--')) {
1961
+ error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1962
+ }
1963
+ const dCfg = capFlagValue('--config-dir');
1964
+ capabilityWriter.cmdCapabilitySet(
1965
+ cwd,
1966
+ dCfg ? path.resolve(dCfg) : null,
1967
+ id,
1968
+ { enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
1969
+ raw,
1970
+ );
1971
+ } else if (capSubcommand === 'outdated') {
1972
+ // capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
1973
+ // For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
1974
+ // latest available version and report whether a newer one exists. This never re-clones/re-packs;
1975
+ // a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
1976
+ const lifecycle = require('./lib/capability-lifecycle.cjs');
1977
+ const outdatedScopeArg = capFlagValue('--scope');
1978
+ if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
1979
+ error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1980
+ }
1981
+ // Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
1982
+ const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
1983
+ const records = [];
1984
+ for (const sc of outdatedScopes) {
1985
+ const { runtimeDir } = capResolveScope(sc);
1986
+ // outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
1987
+ const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
1988
+ for (const r of scRecords) records.push({ ...r, scope: sc });
1989
+ }
1990
+ const asJson = raw || capHasFlag('--json');
1991
+ if (asJson) {
1992
+ output(records, false); // machine output: the records array (JSON).
1993
+ } else {
1994
+ // Human-readable table: ID | Source | Current | Latest | Status.
1995
+ const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
1996
+ const cell = (v) => (v === null || v === undefined ? '-' : String(v));
1997
+ const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
1998
+ const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
1999
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
2000
+ const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
2001
+ for (const row of tableRows) lines.push(fmt(row));
2002
+ if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
2003
+ output(records, true, lines.join('\n') + '\n');
2004
+ }
2005
+ } else if (capSubcommand === 'trust') {
2006
+ // capability trust list [--scope project] [--json]
2007
+ // capability trust revoke <id> [--project <path>]
2008
+ // The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
2009
+ const consentMod = require('./lib/capability-consent.cjs');
2010
+ const trustSub = args[2];
2011
+ if (trustSub === 'list') {
2012
+ // --scope is accepted for symmetry; only 'project' records exist today.
2013
+ const listScope = capFlagValue('--scope');
2014
+ if (listScope && listScope !== 'project') {
2015
+ error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2016
+ }
2017
+ const store = consentMod.readConsentStore(capConsentHome());
2018
+ const rows = Object.keys(store.records).map((k) => {
2019
+ const r = store.records[k];
2020
+ // #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
2021
+ // binding against the current bundle (e.g. `gsd capability list` showing inactive after a
2022
+ // tamper) and understand why a consented cap deactivated. The contentHash is THE security
2023
+ // binding the loader checks; disclosureSignature is the executable-surface re-consent key.
2024
+ return {
2025
+ id: r.id, scope: r.scope, projectRoot: r.projectRoot,
2026
+ integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
2027
+ consentedAt: r.consentedAt,
2028
+ };
2029
+ });
2030
+ output(rows, raw || capHasFlag('--json'));
2031
+ } else if (trustSub === 'revoke') {
2032
+ const id = args[3];
2033
+ if (!id || id.startsWith('--')) {
2034
+ error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
2035
+ }
2036
+ // --project pins the project root whose consent is revoked; defaults to realpath(cwd).
2037
+ const projFlag = capFlagValue('--project');
2038
+ let projectRoot;
2039
+ try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
2040
+ catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
2041
+ // #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
2042
+ // (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
2043
+ // error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
2044
+ // consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
2045
+ try {
2046
+ consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
2047
+ } catch (err) {
2048
+ error(
2049
+ `capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
2050
+ `(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
2051
+ ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
2052
+ );
2053
+ }
2054
+ output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
2055
+ } else {
2056
+ error(
2057
+ `Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
2058
+ ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
2059
+ );
2060
+ }
1393
2061
  } else {
1394
2062
  error(
1395
- `Unknown capability subcommand: ${capSubcommand}. Available: state, set`,
2063
+ `Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
1396
2064
  ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
1397
2065
  );
1398
2066
  }
@@ -2207,6 +2875,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
2207
2875
  // this returns true when a registered capability owns the command, false otherwise.
2208
2876
  if (dispatchCapabilityCommand({ command, args, cwd, raw, error })) break;
2209
2877
 
2878
+ // ADR-1244 Phase 5 (D7): if no first-party family owns the command, try an INSTALLED
2879
+ // THIRD-PARTY (overlay) capability — dispatched only if committed/consented and only by
2880
+ // require()-ing its router FROM the capability's install root (confined to that root).
2881
+ if (dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error })) break;
2882
+
2210
2883
  // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
2211
2884
  // above split it so `command` here is the head ("foo"). Use
2212
2885
  // originalCommand to reconstruct the original dotted form and suggest
@@ -2236,4 +2909,6 @@ if (require.main === module) {
2236
2909
  // ─── Exports (for tests) ──────────────────────────────────────────────────────
2237
2910
  // ADR-959: export dispatchCapabilityCommand so tests can exercise it with
2238
2911
  // synthetic registry + requireModule injections.
2239
- module.exports = { dispatchCapabilityCommand };
2912
+ // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
2913
+ // the third-party overlay dispatch + install-root confinement tests.
2914
+ module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot };