superdev-cli 0.1.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 (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,539 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capability doctor.
4
+ *
5
+ * `inspect` - READ-ONLY, zero writes, bounded, root-confined, network-free.
6
+ * Harness state comes from the LIVE supported interfaces
7
+ * (`claude plugin list --json`, `codex plugin list --json`, `skills ls --json`);
8
+ * filesystem inspection is a documented fallback only. A failed or malformed
9
+ * probe NEVER fails open.
10
+ *
11
+ * `plan` - a deterministic plan with a stable planId (hash). Executes nothing.
12
+ * `apply` - executes ONLY the exact allowlisted commands of an approved plan,
13
+ * bound to that plan's id; refuses changed/expired/unknown/tampered plans and
14
+ * every broad-consent flag. Uses argument arrays, never shell interpolation.
15
+ * Re-inspects afterwards and reports the ACTUAL resulting state.
16
+ * `lock` - records the capability lock.
17
+ *
18
+ * Every output path is redacted at the boundary: absolute machine paths become
19
+ * neutral labels and any sensitive shape is removed before stdout, --out, or
20
+ * persistence.
21
+ *
22
+ * Exit codes: 0 ok, 1 not ready / consent required / partial failure, 2 usage.
23
+ */
24
+ import { parseArgs } from "node:util";
25
+ import { execFileSync } from "node:child_process";
26
+ import crypto from "node:crypto";
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import {
30
+ TalksError, isMain, resolveRoot, safeResolve, stableStringify, atomicWrite,
31
+ assertNotInterrupted, assertSafeField, nowStamp, usageError, writeReport, redactSensitive,
32
+ } from "../../src/model/toolkit.mjs";
33
+ import { PROVIDERS, PROVIDER_IDS, getProvider, VERSION_POLICY } from "../providers/registry.mjs";
34
+ import { detectAll, defaultEnv, liveClaudePlugins, liveCodexPlugins, liveSkillsList } from "../providers/detect.mjs";
35
+ import { installationPlan } from "../providers/adapter.mjs";
36
+
37
+ const USAGE = `Usage: node doctor.mjs <inspect|setup|plan|apply|lock> --root <path> [options]
38
+ inspect READ-ONLY capability report; writes nothing
39
+ setup first-use readiness: summary + one bounded plan; installs nothing
40
+ plan [--install <ids>] deterministic plan with a planId; executes nothing
41
+ [--consent] record that the informed-consent step was given
42
+ apply --plan-id <id> --consent execute ONLY that approved plan's commands
43
+ lock [--apply] write talks/state/capabilities.lock.json
44
+ [--home <dir>] inspect a different home (testing / clean profiles)
45
+ --json / --out <file>`;
46
+
47
+ export const LOCK_REL = "talks/state/capabilities.lock.json";
48
+ export const PLAN_REL = "talks/state/setup-plan.json";
49
+ export const LOCK_FRESHNESS_MS = 7 * 24 * 60 * 60 * 1000;
50
+
51
+ /** Commands this tool is ever willing to execute, as argument ARRAYS. Anything
52
+ * not derivable from a provider's recorded install command is not executable. */
53
+ export function allowedCommandFor(providerId) {
54
+ const p = getProvider(providerId);
55
+ if (!p?.install?.command) return null;
56
+ const parts = String(p.install.command).trim().split(/\s+/);
57
+ // Reject anything with shell metacharacters - we never interpolate a shell.
58
+ if (parts.some((t) => /[;&|><`$(){}[\]\\]/.test(t))) return null;
59
+ const forbidden = new Set(p.consent?.forbiddenFlags ?? ["--all", "-y", "--yes"]);
60
+ if (parts.some((t) => forbidden.has(t))) return null;
61
+ return { cmd: parts[0], args: parts.slice(1) };
62
+ }
63
+
64
+ // ---- harness inspection (LIVE first, fallback documented) -----------------
65
+
66
+ function probe(env, cli, args) {
67
+ try { return String(env.run(cli, args)).trim().split("\n")[0]; } catch { return null; }
68
+ }
69
+
70
+ function inspectClaude(env) {
71
+ const version = probe(env, "claude", ["--version"]);
72
+ const live = version ? liveClaudePlugins(env) : { ok: false, reason: "claude not present" };
73
+ const storeFile = path.join(env.home, ".claude", "plugins", "installed_plugins.json");
74
+ const hasStore = fs.existsSync(storeFile);
75
+ const state = !version ? "missing"
76
+ : live.ok ? "available-and-ready"
77
+ : hasStore ? "installed-but-unhealthy" : "unknown";
78
+ return {
79
+ harness: "claude-code", present: Boolean(version), version,
80
+ inspection: live.ok ? "live:claude plugin list --json" : hasStore ? "fallback:plugin store" : "none",
81
+ liveListingAvailable: live.ok,
82
+ liveListingProblem: live.ok ? null : (version ? live.reason : null),
83
+ pluginsVisible: live.ok ? live.entries.length : null,
84
+ supports: version ? { pluginValidate: true, pluginDir: true, nativeDependencies: true, hooks: true, livePluginList: live.ok } : null,
85
+ state,
86
+ remediation: version
87
+ ? (live.ok ? null : `Claude Code is present but its plugin listing could not be read (${live.reason}); capability is reported from the fallback and may be incomplete.`)
88
+ : "Claude Code was not detected. Superdev's plugin surface requires it; the skills/ surface still works standalone.",
89
+ };
90
+ }
91
+
92
+ function inspectCodex(env) {
93
+ const version = probe(env, "codex", ["--version"]);
94
+ const live = version ? liveCodexPlugins(env) : { ok: false, reason: "codex not present" };
95
+ return {
96
+ harness: "codex", present: Boolean(version), version,
97
+ inspection: live.ok ? "live:codex plugin list --json" : "none",
98
+ liveListingAvailable: live.ok,
99
+ liveListingProblem: version && !live.ok ? live.reason : null,
100
+ pluginsVisible: live.ok ? live.entries.length : null,
101
+ // Verified: Codex exposes NO offline manifest validator, and hooks require
102
+ // explicit user trust before they fire.
103
+ supports: version ? { pluginCli: true, offlinePluginValidate: false, hooksRequireTrust: true, livePluginList: live.ok } : null,
104
+ state: version ? "available-and-ready" : "optional-and-absent",
105
+ degradation: version
106
+ ? "Codex hooks run only after the user explicitly trusts them; until trusted, Superdev's lifecycle behavior falls back to manual invocation and says so."
107
+ : "Codex is not installed. Superdev's Codex surface is unavailable - the Claude and skills surfaces are unaffected.",
108
+ setupPath: version ? "codex plugin marketplace add <source> && codex plugin add superdev" : "Install Codex, then add the marketplace and plugin.",
109
+ remediation: version ? null : "Optional: install Codex to use the Codex distribution surface.",
110
+ };
111
+ }
112
+
113
+ function inspectSkillsSh(env, rootReal) {
114
+ const version = probe(env, "skills", ["--version"]);
115
+ const live = version ? liveSkillsList(env) : { ok: false, reason: "skills CLI not present" };
116
+ return {
117
+ harness: "skills.sh", present: Boolean(version), version,
118
+ inspection: live.ok ? "live:skills ls --json" : "none",
119
+ liveListingAvailable: live.ok,
120
+ liveListingProblem: version && !live.ok ? live.reason : null,
121
+ skillsVisible: live.ok ? live.entries.length : null,
122
+ // Verified: the CLI has no validate/doctor subcommand; skills-ref validates.
123
+ supports: version ? { add: true, find: true, update: true, validateSubcommand: false, liveList: live.ok } : null,
124
+ state: version ? "available-and-ready" : "optional-and-absent",
125
+ degradation: version ? null : "The skills CLI is absent; skill discovery and installation are unavailable. Superdev never fabricates a catalogue - it reports the gap.",
126
+ setupPath: "npx skills add <skill> (never with --all, never with a silent -y)",
127
+ remediation: version ? null : "Optional: use `npx skills` (no global install required) or install the skills CLI.",
128
+ };
129
+ }
130
+
131
+ /** Report which lifecycle automation is actually active, per harness.
132
+ *
133
+ * These hooks are development convenience - session context, dirty marking,
134
+ * bounded project-view refresh, and end-of-session continuity. They are NOT a permission gate: external,
135
+ * destructive, publishing and installation actions are governed by the
136
+ * harness's own permission model. Reported honestly per surface, because a
137
+ * user who believes automation is running when it is not is worse off than
138
+ * one who knows it is not.
139
+ */
140
+ function inspectLifecycle(harnesses) {
141
+ const hooksFile = new URL("../../hooks/hooks.json", import.meta.url);
142
+ let declared = null, problem = null;
143
+ try {
144
+ const parsed = JSON.parse(fs.readFileSync(hooksFile, "utf8"));
145
+ declared = Object.keys(parsed.hooks ?? {});
146
+ } catch (e) { problem = `hooks/hooks.json could not be read (${e.code ?? "error"})`; }
147
+
148
+ const by = (h) => harnesses.find((x) => x.harness === h);
149
+ const coverage = [];
150
+ if (by("claude-code")?.present) {
151
+ coverage.push({
152
+ harness: "claude-code", active: Boolean(declared), events: declared ?? [],
153
+ covers: "session context on start, dirty marking after Write/Edit, one bounded projection/dashboard refresh after a batch edits canonical product records, and end-of-session/pre-compaction continuity prompts",
154
+ caveat: "Plugin hooks load when the plugin is installed or run with --plugin-dir.",
155
+ manualFallback: null,
156
+ });
157
+ }
158
+ if (by("codex")?.present) {
159
+ coverage.push({
160
+ harness: "codex", active: "requires-trust", events: declared ?? [],
161
+ // Verified 2026-07-25 against the Codex migration reference.
162
+ covers: "shell-command events only - Write/Edit are NOT intercepted on Codex, so dirty marking does not fire there",
163
+ caveat: "Each hook must be explicitly trusted (hash-based) before it fires.",
164
+ manualFallback: 'Ask for "project status" - it reconciles from canonical records regardless of hooks.',
165
+ });
166
+ }
167
+ if (by("skills.sh")?.present) {
168
+ coverage.push({
169
+ harness: "skills.sh", active: false, events: [],
170
+ covers: "nothing - skills.sh has no hook mechanism",
171
+ caveat: "A standalone skills.sh install gets no session context and no dirty marking.",
172
+ manualFallback: 'Ask for "project status" at the start and end of a session; it re-derives everything from canonical records.',
173
+ });
174
+ }
175
+ return {
176
+ declared: declared ?? [],
177
+ problem,
178
+ purpose: "development automation only - session continuity, staleness marking, and bounded derived-view convergence",
179
+ notASecurityBoundary:
180
+ "Superdev is a development orchestrator, not a security sandbox. External, destructive, publishing and installation actions are gated by the harness's own permission model and by explicit skill-level approval, never by these hooks.",
181
+ coverage,
182
+ };
183
+ }
184
+
185
+ // ---- capability lock -----------------------------------------------------
186
+
187
+ export function readLock(rootReal) {
188
+ const file = safeResolve(rootReal, LOCK_REL);
189
+ if (!fs.existsSync(file)) return null;
190
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
191
+ catch { throw new TalksError("E_LOCK_TORN", "the capability lock is unreadable (integrity); re-run `doctor lock --apply`"); }
192
+ }
193
+
194
+ function lockEntry(p) {
195
+ const provider = getProvider(p.id);
196
+ return {
197
+ id: p.id, identity: p.identity, delivery: p.delivery,
198
+ source: provider.install.marketplaceSource ?? provider.install.source ?? (provider.install.npm ? { source: "npm", package: provider.install.npm } : null),
199
+ version: p.version ?? null, versionResolution: p.versionResolution,
200
+ constraint: provider.minVersion ?? null, versionEnforced: Boolean(provider.minVersion),
201
+ scope: "user",
202
+ harness: p.delivery === "claude-plugin" ? "claude-code" : p.delivery === "cli" ? "shell" : "agent-skills",
203
+ license: "unverified", trust: "user-installed",
204
+ readiness: p.state, lastVerified: nowStamp(), verificationMethod: provider.detection.kind,
205
+ };
206
+ }
207
+
208
+ // ---- operations ----------------------------------------------------------
209
+
210
+ export function inspect(rootReal, { env = defaultEnv() } = {}) {
211
+ const harnesses = [inspectClaude(env), inspectCodex(env), inspectSkillsSh(env, rootReal)];
212
+ const providerReport = detectAll({ env, rootReal });
213
+ let lock = null, lockError = null;
214
+ try { lock = readLock(rootReal); } catch (e) { lockError = e.message; }
215
+ const now = Date.now();
216
+ const providers = providerReport.providers.map((p) => {
217
+ const locked = lock?.providers?.find((l) => l.id === p.id) ?? null;
218
+ const age = locked?.lastVerified ? now - Date.parse(locked.lastVerified) : null;
219
+ return {
220
+ ...p,
221
+ lock: locked ? {
222
+ recordedReadiness: locked.readiness, recordedVersion: locked.version, lastVerified: locked.lastVerified,
223
+ stale: age === null ? true : age > LOCK_FRESHNESS_MS,
224
+ agreesWithLive: locked.readiness === p.state && (locked.version ?? null) === (p.version ?? null),
225
+ } : null,
226
+ };
227
+ });
228
+ const notReady = providers.filter((p) => !p.ready);
229
+ return {
230
+ version: 1, mode: "inspect", readOnly: true,
231
+ lifecycle: inspectLifecycle(harnesses),
232
+ // Installed and invocable-from-here are different questions.
233
+ harness: {
234
+ current: providerReport.harness,
235
+ invocableHere: providerReport.invocableHere,
236
+ installedTotal: providerReport.ready,
237
+ unreachableHere: providerReport.unreachableHere,
238
+ note: "A provider installed on another harness is not invocable from this one; unreachable providers are listed rather than credited.",
239
+ },
240
+ note: "read-only: this inspection installed, enabled, configured and invoked nothing",
241
+ versionPolicy: VERSION_POLICY,
242
+ harnesses, providers,
243
+ summary: { providersReady: providers.length - notReady.length, providersTotal: providers.length, harnessesPresent: harnesses.filter((h) => h.present).length },
244
+ lockPresent: Boolean(lock), lockError,
245
+ lockDisagreements: providers.filter((p) => p.lock && !p.lock.agreesWithLive).map((p) => p.id),
246
+ remediation: notReady.map((p) => ({ provider: p.id, state: p.state, action: p.remediation })),
247
+ };
248
+ }
249
+
250
+ /** Deterministic plan identity: same targets + same commands => same planId. */
251
+ function computePlanId(steps) {
252
+ return crypto.createHash("sha256").update(JSON.stringify(steps.map((s) => [s.provider, s.identity, s.command]))).digest("hex").slice(0, 16);
253
+ }
254
+
255
+ export function plan(rootReal, { install = [], consent = false, flags = [], env = defaultEnv() } = {}) {
256
+ const unknown = install.filter((id) => !PROVIDER_IDS.includes(id));
257
+ if (unknown.length) throw new TalksError("E_PROVIDER_UNKNOWN", `unknown provider(s): ${unknown.join(", ")} (known: ${PROVIDER_IDS.join(", ")})`);
258
+ const live = detectAll({ env, rootReal });
259
+ const targets = install.length ? install : live.providers.filter((p) => !p.ready).map((p) => p.id);
260
+ const steps = targets.map((id) => {
261
+ const state = live.providers.find((p) => p.id === id);
262
+ const p = installationPlan(id, { consentGiven: consent, flags });
263
+ const allowed = allowedCommandFor(id);
264
+ return {
265
+ provider: id, currentState: state?.state ?? "unknown", alreadyReady: Boolean(state?.ready),
266
+ identity: p.identity, source: p.source, command: p.command,
267
+ executable: allowed ? { cmd: allowed.cmd, args: allowed.args } : null,
268
+ effect: `installs ${p.identity} into the user scope`,
269
+ risk: "adds an external capability to this machine; nothing is removed or overwritten",
270
+ consentRequired: p.consentRequired, consentGiven: p.consentGiven,
271
+ mayProceed: p.mayProceed && !state?.ready, prompt: p.prompt, forbiddenFlags: p.forbiddenFlags,
272
+ };
273
+ });
274
+ const planId = computePlanId(steps);
275
+ const needConsent = steps.filter((s) => !s.alreadyReady && !s.consentGiven);
276
+ return {
277
+ version: 1, mode: "plan", executesNothing: true, planId,
278
+ informedConsent: {
279
+ required: needConsent.length > 0, given: consent,
280
+ boundTo: planId,
281
+ statement: needConsent.length
282
+ ? `Install ${needConsent.length} provider(s): ${needConsent.map((s) => `${s.provider} (${s.identity})`).join("; ")}. Approval is bound to plan ${planId}; nothing is installed until you approve, and no blanket or auto-approve flag is ever used.`
283
+ : "No installation is required.",
284
+ },
285
+ steps, blocked: needConsent.map((s) => s.provider),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Readiness facts for one capability state, stated EXPLICITLY.
291
+ *
292
+ * Deriving these by exclusion ("installed unless the state is missing") turned
293
+ * every state the author had not enumerated into a positive claim: a `missing`
294
+ * provider reported enabled and healthy, and `policy-state-unavailable` - the
295
+ * one state whose whole purpose is refusing to conclude "permitted" - reported
296
+ * installed, enabled AND healthy. This is the readiness function; asserting here
297
+ * is the worst place in the product to assert.
298
+ *
299
+ * `null` means UNPROVEN. It is never treated as true, and never as false.
300
+ */
301
+ export function readinessFacts(state) {
302
+ switch (state) {
303
+ case "available-and-ready": return { installed: true, enabled: true, healthy: true };
304
+ case "installed-but-disabled": return { installed: true, enabled: false, healthy: true };
305
+ case "installed-but-unhealthy": return { installed: true, enabled: true, healthy: false };
306
+ case "installed-but-incompatible": return { installed: true, enabled: true, healthy: false };
307
+ case "authentication-required": return { installed: true, enabled: true, healthy: false };
308
+ case "policy-blocked": return { installed: true, enabled: false, healthy: null };
309
+ // partially installed: some required component is absent, so "installed" is
310
+ // not honestly true and not honestly false
311
+ case "partially-ready": return { installed: null, enabled: null, healthy: false };
312
+ case "missing": return { installed: false, enabled: false, healthy: false };
313
+ case "optional-and-absent": return { installed: false, enabled: false, healthy: false };
314
+ case "marketplace-unavailable": return { installed: false, enabled: null, healthy: null };
315
+ // we could not determine anything; claim nothing
316
+ case "policy-state-unavailable": return { installed: null, enabled: null, healthy: null };
317
+ case "unknown": return { installed: null, enabled: null, healthy: null };
318
+ default: return { installed: null, enabled: null, healthy: null };
319
+ }
320
+ }
321
+
322
+ /**
323
+ * First-use readiness: one call the orchestrator makes before substantial work.
324
+ *
325
+ * This is where the REAL inspection happens - deliberately not in a lifecycle
326
+ * hook, which has a couple of seconds and must never spawn provider probes or
327
+ * touch the network. The hook only reports that verification is due.
328
+ *
329
+ * Returns the readiness summary plus, when something is missing, exactly one
330
+ * bounded plan. It installs nothing: consent is a separate, explicit step.
331
+ */
332
+ export function setup(rootReal, { env = defaultEnv(), harness = null } = {}) {
333
+ const report = inspect(rootReal, { env });
334
+ const here = harness ?? report.harness.current;
335
+
336
+ const providers = report.providers.map((p) => ({
337
+ id: p.id, title: p.title,
338
+ ...readinessFacts(p.state),
339
+ activated: p.harnessReadiness?.activationRequired ? "requires " + p.harnessReadiness.activationRequired : "not required",
340
+ invocableHere: Boolean(p.harnessReadiness?.invocableHere),
341
+ state: p.state,
342
+ version: p.version ?? null,
343
+ route: p.harnessReadiness?.invocationRoute ?? null,
344
+ limitation: p.harnessReadiness?.limitation ?? null,
345
+ remediation: p.remediation ?? null,
346
+ }));
347
+
348
+ // `null` means unproven, and unproven is not the same as false. It must never
349
+ // collapse into a positive claim on either side of a filter.
350
+ const needsInstall = providers.filter((x) => x.installed === false);
351
+ const unproven = providers.filter((x) => x.installed === null || x.healthy === null || x.enabled === null);
352
+ const unhealthy = providers.filter((x) => x.installed === true && x.healthy === false);
353
+ const unreachable = providers.filter((x) => x.installed === true && x.healthy === true && !x.invocableHere);
354
+ const usable = providers.filter((x) => x.invocableHere);
355
+
356
+ // A provider that is absent OR only partially installed both need the same
357
+ // remedy: an install. An unproven state is reported, never silently installed.
358
+ const installable = [...needsInstall, ...providers.filter((x) => x.state === "partially-ready")];
359
+ const proposal = installable.length ? plan(rootReal, { install: [...new Set(installable.map((m) => m.id))], consent: false, env }) : null;
360
+
361
+ return {
362
+ version: 1, mode: "setup", executesNothing: true,
363
+ harness: here,
364
+ verification: capabilityVerificationState(rootReal),
365
+ summary: {
366
+ usable: usable.length, total: providers.length,
367
+ missing: needsInstall.map((m) => m.id),
368
+ partiallyInstalled: providers.filter((x) => x.state === "partially-ready").map((m) => m.id),
369
+ unhealthy: unhealthy.map((m) => m.id),
370
+ // reported separately: "we could not determine this" is not a readiness verdict
371
+ unproven: unproven.map((m) => ({ id: m.id, state: m.state })),
372
+ unreachableHere: unreachable.map((m) => m.id),
373
+ },
374
+ providers,
375
+ // Exactly one bounded plan, or none. Never a partial or implicit install.
376
+ plan: proposal ? {
377
+ planId: proposal.planId,
378
+ installs: proposal.steps.map((s) => ({ provider: s.provider, identity: s.identity, source: s.source, command: s.command })),
379
+ consentStatement: proposal.informedConsent.statement,
380
+ approveWith: `doctor apply --plan-id ${proposal.planId} --consent`,
381
+ } : null,
382
+ nothingInstalled: true,
383
+ };
384
+ }
385
+
386
+ /** Lock freshness, read from the file rather than assumed. */
387
+ function capabilityVerificationState(rootReal) {
388
+ let lock = null;
389
+ try { lock = readLock(rootReal); } catch { return { locked: false, stale: true, reason: "the capability lock is unreadable" }; }
390
+ if (!lock) return { locked: false, stale: true, reason: "capabilities have never been verified in this project" };
391
+ const stamps = (lock.providers ?? []).map((p) => Date.parse(p.lastVerified)).filter(Number.isFinite);
392
+ if (!stamps.length) return { locked: true, stale: true, reason: "the lock records no verification time" };
393
+ const age = Date.now() - Math.min(...stamps);
394
+ return age > LOCK_FRESHNESS_MS
395
+ ? { locked: true, stale: true, reason: `last verified ${Math.floor(age / 86400000)} days ago` }
396
+ : { locked: true, stale: false, reason: null };
397
+ }
398
+
399
+ /**
400
+ * Execute an approved plan. ONLY the exact allowlisted commands of the plan
401
+ * whose id matches `planId` may run, and only with explicit consent.
402
+ * `runner` is injectable so tests never touch real installations.
403
+ */
404
+ export function apply(rootReal, { planId = null, consent = false, install = [], flags = [], env = defaultEnv(), runner = null } = {}) {
405
+ if (!consent) throw new TalksError("E_CONSENT_REQUIRED", "apply requires explicit consent bound to an approved plan");
406
+ if (!planId) throw new TalksError("E_PLAN_ID_REQUIRED", "apply requires --plan-id naming the exact approved plan");
407
+ const current = plan(rootReal, { install, consent: true, flags, env });
408
+ // The plan must be EXACTLY the one approved: a changed target set, a changed
409
+ // command, or an unknown id all produce a different id and are refused.
410
+ if (current.planId !== planId)
411
+ throw new TalksError("E_PLAN_MISMATCH", `the approved plan ${planId} no longer matches the current plan ${current.planId}; re-plan and re-approve`);
412
+
413
+ const exec = runner ?? ((cmd, args) => execFileSync(cmd, args, { encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "pipe"] }));
414
+ const results = [];
415
+ for (const step of current.steps) {
416
+ if (step.alreadyReady) { results.push({ provider: step.provider, skipped: "already ready" }); continue; }
417
+ if (!step.executable) { results.push({ provider: step.provider, executed: false, error: "no allowlisted command is recorded for this provider" }); continue; }
418
+ // Argument ARRAY only - never a shell string.
419
+ try {
420
+ const out = exec(step.executable.cmd, step.executable.args);
421
+ results.push({ provider: step.provider, executed: true, command: `${step.executable.cmd} ${step.executable.args.join(" ")}`, output: String(out ?? "").slice(0, 400) });
422
+ } catch (e) {
423
+ results.push({ provider: step.provider, executed: true, failed: true, command: `${step.executable.cmd} ${step.executable.args.join(" ")}`, error: String(e.code ?? e.message ?? "failed").slice(0, 200) });
424
+ }
425
+ }
426
+ // Re-inspect: report the ACTUAL resulting state, never the intended one.
427
+ const after = detectAll({ env, rootReal });
428
+ const stillNotReady = after.providers.filter((p) => current.steps.some((s) => s.provider === p.id) && !p.ready);
429
+ return {
430
+ version: 1, mode: "apply", planId, consentBound: true,
431
+ results,
432
+ resultingState: after.providers.filter((p) => current.steps.some((s) => s.provider === p.id)).map((p) => ({ provider: p.id, state: p.state, ready: p.ready })),
433
+ // Partial failure is truthful and recoverable - never "ready".
434
+ partialFailure: stillNotReady.length > 0,
435
+ stillNotReady: stillNotReady.map((p) => ({ provider: p.id, state: p.state, remediation: p.remediation })),
436
+ note: stillNotReady.length ? "Some providers are still not ready after apply; they are NOT marked ready. Re-run inspect after resolving each remediation." : "All planned providers are ready.",
437
+ };
438
+ }
439
+
440
+ export function writeLock(rootReal, { apply: doApply = false, env = defaultEnv() } = {}) {
441
+ const live = detectAll({ env, rootReal });
442
+ const record = {
443
+ schemaVersion: 1, generatedAt: nowStamp(),
444
+ note: "A RECORD of what was verified, not proof of current state. The doctor rechecks live state on every inspect.",
445
+ versionPolicy: VERSION_POLICY,
446
+ providers: live.providers.map(lockEntry),
447
+ };
448
+ assertSafeField(record, "capability lock");
449
+ // Redact before persisting: a lock must never carry machine paths.
450
+ const safe = redactSensitive(record);
451
+ if (!doApply) return { mode: "lock", applied: false, plan: safe };
452
+ assertNotInterrupted(rootReal);
453
+ atomicWrite(safeResolve(rootReal, LOCK_REL), stableStringify(safe));
454
+ return { mode: "lock", applied: true, file: LOCK_REL, providers: safe.providers.length };
455
+ }
456
+
457
+ function main() {
458
+ let args, positionals;
459
+ try {
460
+ const parsed = parseArgs({
461
+ options: {
462
+ root: { type: "string", default: "." }, home: { type: "string" },
463
+ install: { type: "string" }, consent: { type: "boolean", default: false },
464
+ "plan-id": { type: "string" }, apply: { type: "boolean", default: false },
465
+ all: { type: "boolean", default: false }, yes: { type: "boolean", default: false },
466
+ json: { type: "boolean", default: false }, out: { type: "string" }, help: { type: "boolean", default: false },
467
+ },
468
+ allowPositionals: true,
469
+ });
470
+ args = parsed.values; positionals = parsed.positionals;
471
+ } catch (err) { usageError(String(err.message ?? err), USAGE); }
472
+ if (args.help) { console.log(USAGE); process.exit(0); }
473
+ const op = positionals[0] ?? "inspect";
474
+ if (!["inspect", "setup", "plan", "apply", "lock"].includes(op)) usageError(`unknown operation: ${op}`, USAGE);
475
+ let rootReal;
476
+ try { rootReal = resolveRoot(args.root); } catch (e) { usageError(e.message, USAGE); }
477
+ const env = defaultEnv();
478
+ if (args.home) env.home = args.home;
479
+ const flags = [...(args.all ? ["--all"] : []), ...(args.yes ? ["--yes"] : [])];
480
+ const ids = args.install ? args.install.split(",").map((s) => s.trim()).filter(Boolean) : [];
481
+ try {
482
+ let report, failed = false;
483
+ if (op === "inspect") {
484
+ report = inspect(rootReal, { env });
485
+ failed = report.summary.providersReady < report.summary.providersTotal;
486
+ } else if (op === "setup") {
487
+ report = setup(rootReal, { env });
488
+ failed = report.summary.usable < report.summary.total;
489
+ } else if (op === "plan") {
490
+ report = plan(rootReal, { install: ids, consent: args.consent, flags, env });
491
+ failed = report.blocked.length > 0;
492
+ } else if (op === "apply") {
493
+ report = apply(rootReal, { planId: args["plan-id"], consent: args.consent, install: ids, flags, env });
494
+ failed = report.partialFailure;
495
+ } else {
496
+ report = writeLock(rootReal, { apply: args.apply, env });
497
+ }
498
+ // (A7) Redact at the report boundary - stdout AND persisted reports.
499
+ const safe = redactSensitive(report);
500
+ console.log(args.json ? JSON.stringify(safe, null, 2) : humanize(op, safe));
501
+ writeReport(args.out, safe);
502
+ process.exit(failed ? 1 : 0);
503
+ } catch (e) {
504
+ if (e instanceof TalksError) {
505
+ console.error(`[${e.code}] ${e.message}`);
506
+ process.exit(e.code === "E_PROVIDER_UNKNOWN" ? 2 : 1);
507
+ }
508
+ throw e;
509
+ }
510
+ }
511
+
512
+ function humanize(op, r) {
513
+ if (op === "inspect") {
514
+ const h = r.harnesses.map((x) => ` ${x.present ? "✓" : "·"} ${x.harness}${x.version ? ` ${x.version}` : ""} - ${x.state} [${x.inspection}]`).join("\n");
515
+ const p = r.providers.map((x) => ` ${x.ready ? "✓" : "✗"} ${x.title}${x.version ? ` ${x.version}` : ""} - ${x.state}${x.ready ? "" : `\n → ${x.remediation}`}`).join("\n");
516
+ const g = r.lifecycle
517
+ ? `\nlifecycle automation (development convenience, not a permission gate): ${r.lifecycle.declared.join(", ") || "not declared"}\n` +
518
+ r.lifecycle.coverage.map((c) => ` ${c.harness}: ${c.active === true ? "active" : c.active === false ? "NOT ACTIVE" : c.active} - ${c.covers}`).join("\n")
519
+ : "";
520
+ const hs = r.harness ? `\nharness: ${r.harness.current} - ${r.harness.invocableHere}/${r.harness.installedTotal} installed providers are invocable here` +
521
+ (r.harness.unreachableHere?.length ? `\n unreachable here: ${r.harness.unreachableHere.map((u) => u.id).join(", ")}` : "") : "";
522
+ const s = r.storage ? `\nstorage: ${r.storage.backend}${r.storage.accelerated ? " (SQLite acceleration active)" : ` (SQLite unavailable - ${r.storage.reason}; direct canonical projection in use)`}` : "";
523
+ return `doctor (read-only; nothing was installed or invoked)\nharnesses:\n${h}\nproviders: ${r.summary.providersReady}/${r.summary.providersTotal} ready\n${p}${hs}${s}${g}`;
524
+ }
525
+ if (op === "setup") {
526
+ const rows = r.providers.map((p) => ` ${p.invocableHere ? "✓" : p.installed ? "!" : "·"} ${p.title}${p.version ? ` ${p.version}` : ""} - ${p.invocableHere ? p.route : (p.limitation ?? p.remediation ?? p.state)}`).join("\n");
527
+ const head = `readiness on ${r.harness}: ${r.summary.usable}/${r.summary.total} providers usable here` +
528
+ (r.verification.stale ? `\ncapability verification: DUE (${r.verification.reason})` : "\ncapability verification: current");
529
+ const plan = r.plan
530
+ ? `\n\n${r.plan.consentStatement}\n${r.plan.installs.map((i) => ` ${i.provider}: ${i.command ?? "(no command)"} [${i.source ?? "source unrecorded"}]`).join("\n")}\napprove with: ${r.plan.approveWith}\n(nothing has been installed)`
531
+ : "\n\nNo installation is required.";
532
+ return `${head}\n${rows}${plan}`;
533
+ }
534
+ if (op === "plan") return `installation plan ${r.planId} (executes nothing)\n${r.informedConsent.statement}\n${r.steps.map((s) => ` ${s.provider}: ${s.command ?? "(no command)"}${s.alreadyReady ? " [already ready]" : s.consentGiven ? " [approved]" : " [awaiting consent]"}`).join("\n")}`;
535
+ if (op === "apply") return `apply for plan ${r.planId}\n${r.results.map((x) => ` ${x.provider}: ${x.skipped ?? (x.failed ? `FAILED (${x.error})` : "executed")}`).join("\n")}\n${r.note}`;
536
+ return r.applied ? `capability lock written: ${r.file} (${r.providers} providers)` : `capability lock plan (${r.plan.providers.length} providers) - re-run with --apply to write`;
537
+ }
538
+
539
+ if (isMain(import.meta.url)) main();