@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -0,0 +1,733 @@
1
+ 'use strict';
2
+ /**
3
+ * capability-command-router.cjs — ADR-2346 P2 (#2368).
4
+ *
5
+ * Behavior-preserving relocation of the former `case 'capability':` arm from
6
+ * gsd-tools.cjs (gsd-tools.cjs:1693..2399, pre-cutover). Owns the capability
7
+ * lifecycle CLI (state/list/install/upgrade/remove/consent/trust) and wires the
8
+ * capability-lifecycle / -trust / -consent / -ledger / -loader modules.
9
+ *
10
+ * Dispatched via HOST_COMMAND_ROUTERS.capability in runCommand's default case
11
+ * (host dispatch table, ADR-2346 Layer 2). Hand-authored CJS (sibling of
12
+ * ensure-runtime-build.cjs) — not a generated .cts, so it is committed directly.
13
+ *
14
+ * NOTE: the require() paths below are sibling-relative (./X.cjs), correct for
15
+ * this file's home in bin/lib/ — rewritten from the arm's original ./lib/X.cjs
16
+ * (which resolved relative to bin/gsd-tools.cjs).
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const io = require('./io.cjs');
22
+ const { output, error, ERROR_REASON } = io;
23
+ const { ExitError } = require('./cli-exit.cjs');
24
+ const capabilityState = require('./capability-state.cjs');
25
+ const capabilityWriter = require('./capability-writer.cjs');
26
+
27
+ async function routeCapabilityCommand({ args, cwd, raw }) {
28
+ // capability state [--config-dir <path>]
29
+ // Root resolution: 'capability' is NOT in SKIP_ROOT_RESOLUTION for the
30
+ // same reason 'loop' is not: both are registry/config queries that need
31
+ // the project root (cwd) for .planning/config.json activation resolution.
32
+ // If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
33
+ // be added at the same time to keep them consistent.
34
+ const capSubcommand = args[1];
35
+ // --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
36
+ // Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
37
+ // lazy-required inside each mutating branch so the common state/set paths never load them. ---
38
+ const capFlagValue = (name) => {
39
+ const i = args.indexOf(name);
40
+ if (i === -1) return undefined;
41
+ const v = args[i + 1];
42
+ if (!v || v.startsWith('--')) {
43
+ error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
44
+ }
45
+ return v;
46
+ };
47
+ const capHasFlag = (name) => args.includes(name);
48
+ const capRepeatedFlag = (name) => {
49
+ const out = [];
50
+ for (let i = 0; i < args.length; i++) {
51
+ if (args[i] === name) {
52
+ const v = args[i + 1];
53
+ if (!v || v.startsWith('--')) {
54
+ error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
55
+ }
56
+ out.push(v);
57
+ i++; // skip the consumed value
58
+ }
59
+ }
60
+ return out;
61
+ };
62
+ // Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
63
+ // .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
64
+ // read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
65
+ // project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
66
+ // via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
67
+ // Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
68
+ // regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
69
+ const capResolveScope = (scope) => {
70
+ const s = scope || 'global';
71
+ if (s !== 'global' && s !== 'project') {
72
+ error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
73
+ }
74
+ if (s === 'project') return { scope: 'project', runtimeDir: cwd };
75
+ const os = require('node:os');
76
+ return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
77
+ };
78
+ // capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
79
+ // loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
80
+ // undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
81
+ // malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
82
+ // silently downgraded to permissive here.
83
+ const capReadStrict = () => {
84
+ let cfgPath;
85
+ try {
86
+ const { planningDir } = require('./planning-workspace.cjs');
87
+ cfgPath = path.join(planningDir(cwd), 'config.json');
88
+ } catch {
89
+ return undefined; // cannot even resolve the project config dir — permissive default
90
+ }
91
+ if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
92
+ let cfg;
93
+ try {
94
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
95
+ } catch {
96
+ // Config is PRESENT but unreadable/unparseable: a security policy must not silently
97
+ // downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
98
+ // still allowed) until the config is fixed.
99
+ return [];
100
+ }
101
+ if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
102
+ return cfg.capabilities.strict_known_registries;
103
+ }
104
+ return undefined;
105
+ };
106
+ // Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
107
+ // #1920: prefer the authoritative gsd-core/VERSION the installer writes for EVERY runtime
108
+ // (gsd-core/bin/ -> ../VERSION), so installed layouts report the true version even when the
109
+ // walked-up ../../package.json is the versionless CommonJS marker or the user's own project.
110
+ // Fall back to the runtime-root package.json (dev/source tree), then fail-closed. Mirrors
111
+ // readHostVersion() in capability-loader.cts.
112
+ const capHostVersion = () => {
113
+ const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
114
+ try {
115
+ const v = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
116
+ if (SEMVER_PREFIX.test(v)) return v;
117
+ } catch { /* not an installed tree (no gsd-core/VERSION) */ }
118
+ try {
119
+ const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json')); // gsd-core/bin/lib/ -> repo root is three up
120
+ if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version;
121
+ } catch { /* runtime root has no package.json */ }
122
+ return '0.0.0';
123
+ };
124
+ // #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
125
+ // live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
126
+ // written here is the record the loader checks.
127
+ const capConsentHome = () => {
128
+ const osMod = require('node:os');
129
+ return process.env.GSD_HOME || osMod.homedir();
130
+ };
131
+ // #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
132
+ // record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
133
+ // path cannot be realpath'd (e.g. it does not exist yet).
134
+ const capProjectRoot = () => {
135
+ try { return fs.realpathSync(cwd); } catch { return cwd; }
136
+ };
137
+ // UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
138
+ // (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
139
+ // bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
140
+ // reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
141
+ // follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
142
+ // #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
143
+ // half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
144
+ // record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
145
+ // reconcile revokes nothing.
146
+ const capRunReconcile = (runtimeDir, lifecycle, scope) => {
147
+ try {
148
+ const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
149
+ if (report && Array.isArray(report.warnings)) {
150
+ for (const w of report.warnings) {
151
+ try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
152
+ }
153
+ }
154
+ } catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
155
+ };
156
+ if (capSubcommand === 'state') {
157
+ const configDirIdx = args.indexOf('--config-dir');
158
+ let configDir = null;
159
+ if (configDirIdx !== -1) {
160
+ const configDirVal = args[configDirIdx + 1];
161
+ // Validate that --config-dir has a following non-flag value.
162
+ if (!configDirVal || configDirVal.startsWith('--')) {
163
+ error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
164
+ }
165
+ configDir = configDirVal;
166
+ }
167
+ const resolvedConfigDir = configDir ? path.resolve(configDir) : null;
168
+ // --runtime <r> (#2003): explicit runtime override so the config-dir
169
+ // resolution bypasses the persisted-runtime fallback. Dual-form like
170
+ // --config-dir (--runtime X / --runtime=X).
171
+ let stateRuntime = undefined;
172
+ const stateRuntimeEqArg = args.find(arg => arg.startsWith('--runtime='));
173
+ const stateRuntimeIdx = args.indexOf('--runtime');
174
+ if (stateRuntimeEqArg) {
175
+ const value = stateRuntimeEqArg.slice('--runtime='.length).trim();
176
+ if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
177
+ stateRuntime = value;
178
+ } else if (stateRuntimeIdx !== -1) {
179
+ const value = args[stateRuntimeIdx + 1];
180
+ if (!value || value.startsWith('--')) {
181
+ error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
182
+ }
183
+ stateRuntime = value;
184
+ }
185
+ capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, { runtime: stateRuntime });
186
+ } else if (capSubcommand === 'set') {
187
+ // capability set <id> [--on|--off|--enable|--disable] [--gate <key>=<bool>]... [--config-dir <dir>] [--runtime <r>] [--scope <s>]
188
+ const capId = args[2];
189
+ if (!capId || capId.startsWith('--')) {
190
+ error('Missing capability id for: capability set <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
191
+ }
192
+ // Parse --config-dir
193
+ const setConfigDirIdx = args.indexOf('--config-dir');
194
+ let setConfigDir = null;
195
+ if (setConfigDirIdx !== -1) {
196
+ const setConfigDirVal = args[setConfigDirIdx + 1];
197
+ if (!setConfigDirVal || setConfigDirVal.startsWith('--')) {
198
+ error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
199
+ }
200
+ setConfigDir = setConfigDirVal;
201
+ }
202
+ const resolvedSetConfigDir = setConfigDir ? path.resolve(setConfigDir) : null;
203
+ // Parse --on/--enable and --off/--disable (mutually exclusive)
204
+ const hasOn = args.includes('--on') || args.includes('--enable');
205
+ const hasOff = args.includes('--off') || args.includes('--disable');
206
+ if (hasOn && hasOff) {
207
+ error('Conflicting flags: --on/--enable and --off/--disable cannot both be present', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
208
+ }
209
+ let setEnabled;
210
+ if (hasOn) {
211
+ setEnabled = true;
212
+ } else if (hasOff) {
213
+ setEnabled = false;
214
+ }
215
+ // Parse --gate <key>=<bool> (repeatable)
216
+ const setGates = {};
217
+ for (let gi = 0; gi < args.length; gi++) {
218
+ if (args[gi] === '--gate') {
219
+ const gateVal = args[gi + 1];
220
+ if (!gateVal || gateVal.startsWith('--')) {
221
+ error('Missing value for --gate (expected <key>=<true|false>)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
222
+ }
223
+ const eqIdx = gateVal.indexOf('=');
224
+ if (eqIdx === -1) {
225
+ error(`Malformed --gate value "${gateVal}": expected <key>=<true|false>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
226
+ }
227
+ const gateKey = gateVal.slice(0, eqIdx);
228
+ const gateBoolStr = gateVal.slice(eqIdx + 1);
229
+ if (gateBoolStr !== 'true' && gateBoolStr !== 'false') {
230
+ error(`Malformed --gate value "${gateVal}": bool must be true or false`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
231
+ }
232
+ setGates[gateKey] = gateBoolStr === 'true';
233
+ gi++; // skip consumed value
234
+ }
235
+ }
236
+ // Parse --runtime and --scope (validate that values are present and not flags)
237
+ const runtimeIdx = args.indexOf('--runtime');
238
+ let setRuntime;
239
+ if (runtimeIdx !== -1) {
240
+ const runtimeVal = args[runtimeIdx + 1];
241
+ if (!runtimeVal || runtimeVal.startsWith('--')) {
242
+ error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
243
+ }
244
+ setRuntime = runtimeVal;
245
+ }
246
+ const scopeIdx = args.indexOf('--scope');
247
+ let setScope;
248
+ if (scopeIdx !== -1) {
249
+ const scopeVal = args[scopeIdx + 1];
250
+ if (!scopeVal || scopeVal.startsWith('--')) {
251
+ error('Missing value for --scope', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
252
+ }
253
+ setScope = scopeVal;
254
+ }
255
+ capabilityWriter.cmdCapabilitySet(
256
+ cwd,
257
+ resolvedSetConfigDir,
258
+ capId,
259
+ { enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
260
+ raw,
261
+ );
262
+ } else if (capSubcommand === 'install') {
263
+ // capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
264
+ const spec = args[2];
265
+ if (!spec || spec.startsWith('--')) {
266
+ error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
267
+ }
268
+ const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
269
+ const lifecycle = require('./capability-lifecycle.cjs');
270
+ const trust = require('./capability-trust.cjs');
271
+ // Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
272
+ // staging, or any shared-config write — so an over-cap install fails fast with a clear count
273
+ // error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
274
+ // depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
275
+ const installSharedFiles = capRepeatedFlag('--shared-file');
276
+ const ledgerModInstall = require('./capability-ledger.cjs');
277
+ if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
278
+ error(
279
+ `capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
280
+ `exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
281
+ ERROR_REASON ? ERROR_REASON.USAGE : undefined,
282
+ );
283
+ }
284
+ capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
285
+ const res = await lifecycle.installCapability(spec, {
286
+ runtimeDir,
287
+ hostVersion: capHostVersion(),
288
+ consentGranted: capHasFlag('--yes'),
289
+ integrity: capFlagValue('--integrity'),
290
+ sharedFiles: installSharedFiles,
291
+ strictKnownRegistries: capReadStrict(),
292
+ // #1459: bind a user consent record for a CONSENTED project install (under the user-owned
293
+ // consent home, NOT in the repo). The lifecycle records nothing for global scope.
294
+ scope,
295
+ consentStoreDir: capConsentHome(),
296
+ });
297
+ if (res.status === 'installed') {
298
+ output({
299
+ status: 'installed',
300
+ id: res.id,
301
+ version: res.version,
302
+ scope,
303
+ disclosure: trust.summarizeDisclosure(res.disclosure || {}),
304
+ }, raw);
305
+ } else if (res.status === 'aborted') {
306
+ // 'aborted' always means "executable surface needs consent" in the lifecycle contract —
307
+ // match it regardless of the requiresConsent flag so a future aborted path can't fall
308
+ // through to the generic "blocked: unknown reason" arm with a misleading message.
309
+ const disclosure = trust.summarizeDisclosure(res.disclosure || {});
310
+ // UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
311
+ // can detect the consent requirement programmatically. We throw ExitError (not error(),
312
+ // which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
313
+ // is flushed before exit; the human-readable guidance still lands on stderr.
314
+ output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
315
+ throw new ExitError(
316
+ 1,
317
+ ['Error: This capability declares executable surfaces and needs your consent before install:']
318
+ .concat(disclosure.map((l) => ' ' + l))
319
+ .concat(['Re-run with --yes to grant consent and install.'])
320
+ .join('\n'),
321
+ );
322
+ } else {
323
+ error(
324
+ `capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
325
+ ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
326
+ );
327
+ }
328
+ } else if (capSubcommand === 'update') {
329
+ // capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
330
+ const all = capHasFlag('--all');
331
+ const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
332
+ if (!all && !id) {
333
+ error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
334
+ }
335
+ if (all && id) {
336
+ error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
337
+ }
338
+ const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
339
+ const lifecycle = require('./capability-lifecycle.cjs');
340
+ const ledgerMod = require('./capability-ledger.cjs');
341
+ const trust = require('./capability-trust.cjs');
342
+ // Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
343
+ // the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
344
+ // re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
345
+ // a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
346
+ const updateSharedFiles = capRepeatedFlag('--shared-file');
347
+ if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
348
+ error(
349
+ `capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
350
+ `exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
351
+ ERROR_REASON ? ERROR_REASON.USAGE : undefined,
352
+ );
353
+ }
354
+ capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
355
+ // readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError
356
+ // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
357
+ // corrupt-but-present ledger fails closed rather than silently reporting not_installed (<id>)
358
+ // or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
359
+ let ledger;
360
+ try {
361
+ ledger = ledgerMod.readLedgerStrict(runtimeDir);
362
+ } catch (err) {
363
+ error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
364
+ }
365
+ const entries = (ledger && ledger.entries) || {};
366
+ const upgradeOne = async (capId) => {
367
+ const entry = entries[capId];
368
+ if (!entry) return { id: capId, status: 'not_installed' };
369
+ // expectedId pins the op to the requested id: a retargeted/edited source that now resolves
370
+ // to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
371
+ const r = await lifecycle.upgradeCapability(entry.source, {
372
+ runtimeDir,
373
+ hostVersion: capHostVersion(),
374
+ consentGranted: capHasFlag('--yes'),
375
+ sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
376
+ strictKnownRegistries: capReadStrict(),
377
+ expectedId: capId,
378
+ // #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
379
+ scope,
380
+ consentStoreDir: capConsentHome(),
381
+ });
382
+ // UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
383
+ // them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
384
+ // stable per-entry shape for `--all` consumers.
385
+ return {
386
+ id: capId,
387
+ status: r.status,
388
+ fromVersion: r.fromVersion ?? null,
389
+ toVersion: r.toVersion ?? null,
390
+ requiresConsent: r.requiresConsent ?? null,
391
+ blockReasons: r.blockReasons ?? null,
392
+ disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
393
+ };
394
+ };
395
+ if (all) {
396
+ // Sequential by design: each upgrade takes the per-scope capability lock; parallel
397
+ // runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
398
+ const results = [];
399
+ for (const capId of Object.keys(entries)) {
400
+ results.push(await upgradeOne(capId));
401
+ }
402
+ const failed = results.filter((x) => x.status !== 'upgraded');
403
+ if (failed.length > 0) {
404
+ // UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
405
+ // alike), then set a non-zero exit. Previously the results JSON was embedded inside the
406
+ // error STRING on stderr, so automation could not parse a partial-failure run as
407
+ // structured data. We throw ExitError (not error(), which calls process.exit and would
408
+ // bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
409
+ // concise reason still lands on stderr.
410
+ output({ scope, updated: results }, raw);
411
+ throw new ExitError(
412
+ 1,
413
+ `Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
414
+ `(see the JSON result on stdout for per-capability status).`,
415
+ );
416
+ }
417
+ output({ scope, updated: results }, raw);
418
+ } else {
419
+ const r = await upgradeOne(id);
420
+ if (r.status === 'upgraded') {
421
+ output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
422
+ } else if (r.status === 'not_installed') {
423
+ error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
424
+ } else if (r.status === 'aborted') {
425
+ // 'aborted' always means "needs consent" (see install) — handle it independently of the
426
+ // requiresConsent flag so it never falls through to the generic blocked arm.
427
+ error(
428
+ [`capability update for "${id}" changes its executable surface and needs your consent:`]
429
+ .concat((r.disclosure || []).map((l) => ' ' + l))
430
+ .concat(['Re-run with --yes to grant consent and update.'])
431
+ .join('\n'),
432
+ ERROR_REASON ? ERROR_REASON.USAGE : undefined,
433
+ );
434
+ } else {
435
+ error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
436
+ }
437
+ }
438
+ } else if (capSubcommand === 'remove') {
439
+ // capability remove <id> [--purge-data] [--scope global|project]
440
+ const id = args[2];
441
+ if (!id || id.startsWith('--')) {
442
+ error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
443
+ }
444
+ const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
445
+ const lifecycle = require('./capability-lifecycle.cjs');
446
+ const ledgerMod = require('./capability-ledger.cjs');
447
+ capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
448
+ // Ledger first: an installed overlay is removable even if its id shadows a first-party name.
449
+ // Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
450
+ // Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
451
+ // silently reporting "first-party cannot be removed" for any id (finding 7).
452
+ let removeLedger;
453
+ try {
454
+ removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
455
+ } catch (err) {
456
+ error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
457
+ }
458
+ const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
459
+ if (!inLedger) {
460
+ const base = require('./capability-loader.cjs').loadRegistry();
461
+ if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
462
+ error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
463
+ }
464
+ }
465
+ const res = lifecycle.removeCapability(id, {
466
+ runtimeDir,
467
+ removeData: capHasFlag('--purge-data'),
468
+ // #1459: a project-scope removal revokes the user consent record so a later repo-dropped
469
+ // bundle of the same id cannot silently re-activate against a stale consent.
470
+ scope,
471
+ consentStoreDir: capConsentHome(),
472
+ });
473
+ if (res.status === 'removed') {
474
+ // #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
475
+ // could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
476
+ // record remains. Surface it on stderr + in the JSON so the user knows to clear it.
477
+ if (res.consentRevokeFailed) {
478
+ process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
479
+ }
480
+ output({
481
+ status: 'removed',
482
+ id,
483
+ scope,
484
+ removedFiles: res.removedFiles,
485
+ strippedEdits: res.strippedEdits,
486
+ dataPreserved: res.dataPreserved,
487
+ consentRevokeFailed: res.consentRevokeFailed || undefined,
488
+ consentRevokeWarning: res.consentRevokeWarning || undefined,
489
+ }, raw);
490
+ } else if (res.status === 'not_installed') {
491
+ error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
492
+ } else {
493
+ error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
494
+ }
495
+ } else if (capSubcommand === 'list') {
496
+ // capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
497
+ // When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
498
+ // corrupt unrelated ledger in another scope does not block a scoped list).
499
+ const loader = require('./capability-loader.cjs');
500
+ const ledgerMod = require('./capability-ledger.cjs');
501
+ const semver = require('./semver-compare.cjs');
502
+ const host = capHostVersion();
503
+ const rows = [];
504
+ const listScopeArg = capFlagValue('--scope');
505
+ // Validate --scope if provided.
506
+ if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
507
+ error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
508
+ }
509
+ // First-party capabilities are always included (they have no scope concept).
510
+ const base = loader.loadRegistry();
511
+ const fp = (base && base.capabilities) || {};
512
+ // #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
513
+ // (a bundle whose project ledger looks committed but has no user consent record on THIS
514
+ // machine) is marked status:'inactive' with a reason, instead of silently appearing active.
515
+ // loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
516
+ const inactiveById = {};
517
+ try {
518
+ const composed = loader.loadRegistry({ includeInstalled: true, cwd });
519
+ const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
520
+ for (const w of overlayWarnings) {
521
+ // #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
522
+ // human-readable reason prose (which is free to change without breaking this filter).
523
+ if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
524
+ inactiveById[`${w.scope} ${w.id}`] = w.reason;
525
+ }
526
+ }
527
+ } catch { /* best-effort — list still works without the inactive annotation */ }
528
+ // Issue #2045 (DEFECT 3): derive each capability's SURFACED state from the
529
+ // SAME resolver `capability state` uses (resolveCapabilityRuntimeState), so
530
+ // `list` and `state` stop disagreeing. `list` previously derived `status`
531
+ // purely from ledger-entry existence — an installed-but-not-surfaced cap
532
+ // reported active in `list` and absent in `state`. Surfaced is evaluated at
533
+ // the default runtime config dir (the resolver resolves it when undefined),
534
+ // matching `capability state <id>` with no --config-dir. Best-effort: a
535
+ // resolver failure leaves surfacedById empty (rows report surfaced:null).
536
+ const surfacedById = {};
537
+ // surfacedById is keyed by capId only (NOT `${scope} ${capId}`): surface
538
+ // state is single-source — one runtime config dir → one .gsd-surface.json
539
+ // → one surfaced truth per capId — and the loader dedupes overlay caps to
540
+ // one registry entry per id (first-party-wins). So a cap installed in both
541
+ // scopes correctly shares one surfaced value across its list rows.
542
+ try {
543
+ const surfaceState = capabilityState.resolveCapabilityRuntimeState(cwd, undefined);
544
+ for (const cap of (surfaceState && surfaceState.capabilities) || []) {
545
+ if (cap && typeof cap.id === 'string') {
546
+ surfacedById[cap.id] = cap.surfaced === true;
547
+ }
548
+ }
549
+ } catch { /* best-effort — list still works without the surfaced annotation */ }
550
+ for (const capId of Object.keys(fp)) {
551
+ const cap = fp[capId] || {};
552
+ rows.push({
553
+ id: capId,
554
+ role: cap.role || null,
555
+ version: cap.version || null,
556
+ tier: cap.tier || null,
557
+ source: 'first-party',
558
+ scope: 'first-party',
559
+ status: 'active',
560
+ surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null,
561
+ title: cap.title || null,
562
+ });
563
+ }
564
+ // Overlay scopes: honor --scope to read only the requested scope (finding 8).
565
+ const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
566
+ for (const sc of overlayScopes) {
567
+ const { runtimeDir } = capResolveScope(sc);
568
+ // readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
569
+ // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
570
+ // corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
571
+ // dropping overlay entries and returning a first-party-only list (site A fix, #1462).
572
+ let ledger;
573
+ try {
574
+ ledger = ledgerMod.readLedgerStrict(runtimeDir);
575
+ } catch (err) {
576
+ // UX-3: name the offending scope so the user knows WHICH ledger to fix.
577
+ error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
578
+ }
579
+ if (!ledger || !ledger.entries) continue;
580
+ for (const capId of Object.keys(ledger.entries)) {
581
+ const entry = ledger.entries[capId];
582
+ let manifest = {};
583
+ try {
584
+ // #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
585
+ // bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
586
+ // a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
587
+ // an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
588
+ // declarative capability.json. A null (genuinely missing) or a bounded-reader throw
589
+ // (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
590
+ // (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
591
+ const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
592
+ manifest = raw === null ? {} : JSON.parse(raw);
593
+ } catch { manifest = {}; }
594
+ let status = 'active';
595
+ let reason = null;
596
+ const range = manifest.engines && manifest.engines.gsd;
597
+ if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
598
+ // #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
599
+ const inactiveReason = inactiveById[`${sc} ${capId}`];
600
+ if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
601
+ rows.push({
602
+ id: capId,
603
+ role: manifest.role || null,
604
+ version: entry.version || null,
605
+ tier: manifest.tier || null,
606
+ source: entry.source || null,
607
+ scope: sc,
608
+ status,
609
+ reason,
610
+ // Issue #2045 (DEFECT 3): surfaced reflects surface composition, so
611
+ // list and state agree. An inactive (unconsented/incompatible) cap is
612
+ // surfaced:false by definition; otherwise defer to the resolver.
613
+ surfaced: status === 'active'
614
+ ? (Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null)
615
+ : false,
616
+ title: manifest.title || null,
617
+ });
618
+ }
619
+ }
620
+ output(rows, raw || capHasFlag('--json'));
621
+ } else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
622
+ // capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
623
+ const id = args[2];
624
+ if (!id || id.startsWith('--')) {
625
+ error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
626
+ }
627
+ const dCfg = capFlagValue('--config-dir');
628
+ capabilityWriter.cmdCapabilitySet(
629
+ cwd,
630
+ dCfg ? path.resolve(dCfg) : null,
631
+ id,
632
+ { enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
633
+ raw,
634
+ );
635
+ } else if (capSubcommand === 'outdated') {
636
+ // capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
637
+ // For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
638
+ // latest available version and report whether a newer one exists. This never re-clones/re-packs;
639
+ // a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
640
+ const lifecycle = require('./capability-lifecycle.cjs');
641
+ const outdatedScopeArg = capFlagValue('--scope');
642
+ if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
643
+ error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
644
+ }
645
+ // Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
646
+ const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
647
+ const records = [];
648
+ for (const sc of outdatedScopes) {
649
+ const { runtimeDir } = capResolveScope(sc);
650
+ // outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
651
+ const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
652
+ for (const r of scRecords) records.push({ ...r, scope: sc });
653
+ }
654
+ const asJson = raw || capHasFlag('--json');
655
+ if (asJson) {
656
+ output(records, false); // machine output: the records array (JSON).
657
+ } else {
658
+ // Human-readable table: ID | Source | Current | Latest | Status.
659
+ const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
660
+ const cell = (v) => (v === null || v === undefined ? '-' : String(v));
661
+ const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
662
+ const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
663
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
664
+ const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
665
+ for (const row of tableRows) lines.push(fmt(row));
666
+ if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
667
+ output(records, true, lines.join('\n') + '\n');
668
+ }
669
+ } else if (capSubcommand === 'trust') {
670
+ // capability trust list [--scope project] [--json]
671
+ // capability trust revoke <id> [--project <path>]
672
+ // The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
673
+ const consentMod = require('./capability-consent.cjs');
674
+ const trustSub = args[2];
675
+ if (trustSub === 'list') {
676
+ // --scope is accepted for symmetry; only 'project' records exist today.
677
+ const listScope = capFlagValue('--scope');
678
+ if (listScope && listScope !== 'project') {
679
+ error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
680
+ }
681
+ const store = consentMod.readConsentStore(capConsentHome());
682
+ const rows = Object.keys(store.records).map((k) => {
683
+ const r = store.records[k];
684
+ // #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
685
+ // binding against the current bundle (e.g. `gsd capability list` showing inactive after a
686
+ // tamper) and understand why a consented cap deactivated. The contentHash is THE security
687
+ // binding the loader checks; disclosureSignature is the executable-surface re-consent key.
688
+ return {
689
+ id: r.id, scope: r.scope, projectRoot: r.projectRoot,
690
+ integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
691
+ consentedAt: r.consentedAt,
692
+ };
693
+ });
694
+ output(rows, raw || capHasFlag('--json'));
695
+ } else if (trustSub === 'revoke') {
696
+ const id = args[3];
697
+ if (!id || id.startsWith('--')) {
698
+ error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
699
+ }
700
+ // --project pins the project root whose consent is revoked; defaults to realpath(cwd).
701
+ const projFlag = capFlagValue('--project');
702
+ let projectRoot;
703
+ try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
704
+ catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
705
+ // #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
706
+ // (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
707
+ // error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
708
+ // consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
709
+ try {
710
+ consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
711
+ } catch (err) {
712
+ error(
713
+ `capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
714
+ `(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
715
+ ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
716
+ );
717
+ }
718
+ output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
719
+ } else {
720
+ error(
721
+ `Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
722
+ ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
723
+ );
724
+ }
725
+ } else {
726
+ error(
727
+ `Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
728
+ ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
729
+ );
730
+ }
731
+ }
732
+
733
+ module.exports = { routeCapabilityCommand };