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