opencode-longrun-harness 1.2.22

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/docs/V1.2.20_EVIDENCE.md +114 -0
  4. package/docs/V1.2.21_EVIDENCE.md +68 -0
  5. package/docs/V1.2.22_EVIDENCE.md +52 -0
  6. package/harness/commissioning/README.md +16 -0
  7. package/harness/commissioning/inspect-copied-run.mjs +25 -0
  8. package/harness/commissioning/verify-copied-case.mjs +35 -0
  9. package/harness/plugin/longrun.js +677 -0
  10. package/harness/src/cli.mjs +40 -0
  11. package/harness/src/controller.js +1413 -0
  12. package/harness/src/evidence.mjs +135 -0
  13. package/harness/src/execution.mjs +217 -0
  14. package/harness/src/executor.mjs +21 -0
  15. package/harness/src/install.mjs +435 -0
  16. package/harness/src/maintenance.mjs +257 -0
  17. package/harness/src/memory.mjs +472 -0
  18. package/harness/test/candidates.test.mjs +73 -0
  19. package/harness/test/checkpoint.test.mjs +65 -0
  20. package/harness/test/controller.test.mjs +230 -0
  21. package/harness/test/evidence.test.mjs +57 -0
  22. package/harness/test/fixtures/durable-host.mjs +27 -0
  23. package/harness/test/fixtures/example-app-run.json +1375 -0
  24. package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
  25. package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
  26. package/harness/test/fixtures/notes-recovery-run.json +622 -0
  27. package/harness/test/fixtures/presets-readout-run.json +825 -0
  28. package/harness/test/fixtures/routing-worker.mjs +35 -0
  29. package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
  30. package/harness/test/helper.mjs +41 -0
  31. package/harness/test/install.test.mjs +117 -0
  32. package/harness/test/lifecycle.test.mjs +102 -0
  33. package/harness/test/maintenance.test.mjs +204 -0
  34. package/harness/test/memory.test.mjs +145 -0
  35. package/harness/test/negative-control.test.mjs +91 -0
  36. package/harness/test/plugin.test.mjs +169 -0
  37. package/harness/test/recovery-runner.test.mjs +435 -0
  38. package/harness/test/recovery.test.mjs +68 -0
  39. package/harness/test/repair-mechanics.test.mjs +122 -0
  40. package/harness/test/toolbehavior.test.mjs +75 -0
  41. package/harness/test/v121-commissioning.test.mjs +177 -0
  42. package/harness/test/v1210-deadline.test.mjs +134 -0
  43. package/harness/test/v1211-pause.test.mjs +81 -0
  44. package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
  45. package/harness/test/v1213-readout.test.mjs +82 -0
  46. package/harness/test/v1214-durable.test.mjs +121 -0
  47. package/harness/test/v1215-guidance.test.mjs +57 -0
  48. package/harness/test/v1216-test-summary.test.mjs +39 -0
  49. package/harness/test/v1217-discovery.test.mjs +73 -0
  50. package/harness/test/v1218-completion-review.test.mjs +203 -0
  51. package/harness/test/v1219-budget-pause.test.mjs +134 -0
  52. package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
  53. package/harness/test/v1220-budget-amendment.test.mjs +343 -0
  54. package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
  55. package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
  56. package/harness/test/v123-plugin-e2e.test.mjs +120 -0
  57. package/harness/test/v123-receipt-model.test.mjs +185 -0
  58. package/harness/test/v124-canonical.test.mjs +147 -0
  59. package/harness/test/v124-installed.test.mjs +48 -0
  60. package/harness/test/v125-stability.test.mjs +183 -0
  61. package/harness/test/v126-execution.test.mjs +183 -0
  62. package/harness/test/v127-reconciliation.test.mjs +139 -0
  63. package/harness/test/v128-compaction.test.mjs +156 -0
  64. package/harness/test/v129-routing.test.mjs +165 -0
  65. package/harness/tools/audit-receipts.mjs +121 -0
  66. package/harness/tools/recovery-runner.mjs +499 -0
  67. package/package.json +49 -0
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ // Long-run maintenance entry point. Copied into the built install and run from ANY cwd
3
+ // WITHOUT the source repository. Resolves its own controller + installer copy by relative
4
+ // URL (reuses existing logic; no competing controller). Node-builtin only (works under the
5
+ // Electron/Node desktop runtime too). Run via the `longrun` launcher or:
6
+ // node longrun.mjs <doctor [--live]|status|pause|review|amend|disable|enable|uninstall> [--json] [--project PATH]
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+ import crypto from "node:crypto";
11
+
12
+ // ---- resolve installed siblings regardless of layout (bin/ vs src/) ----
13
+ function firstExisting(urls) {
14
+ for (const u of urls) { try { if (fs.existsSync(new URL(u))) return u; } catch {} }
15
+ return null;
16
+ }
17
+ const CTRL_URLS = ["../lib/controller.js", "./controller.js", "file://" + (process.env.LONGRUN_CONTROLLER_FILE || "")].filter(Boolean);
18
+ const INST_URLS = ["../lib/install.mjs", "./install.mjs"];
19
+ const ctrlUrl = firstExisting(CTRL_URLS.map((p) => (p.startsWith("file://") ? p : new URL(p, import.meta.url).href)));
20
+ const instUrl = firstExisting(INST_URLS.map((p) => new URL(p, import.meta.url).href));
21
+
22
+ function cfgDir() {
23
+ if (process.env.OPENCODE_CONFIG_DIR) return path.resolve(process.env.OPENCODE_CONFIG_DIR);
24
+ return path.resolve(path.join(process.env.HOME || os.homedir(), ".config", "opencode"));
25
+ }
26
+ function stateDir() { return process.env.LONGRUN_STATE_DIR || path.join(os.homedir() || os.tmpdir(), ".local", "state", "opencode-longrun", "v1"); }
27
+ function currentVersion() { try { return fs.readFileSync(path.join(cfgDir(), "longrun-harness", "current"), "utf8").trim(); } catch { return null; } }
28
+
29
+ async function loadMods() {
30
+ if (!ctrlUrl) throw new Error("controller copy not found next to maintenance entry point");
31
+ const C = await import(ctrlUrl);
32
+ const I = instUrl ? await import(instUrl) : null;
33
+ return { C, I };
34
+ }
35
+
36
+ // ---- doctor: TWO documented modes ----
37
+ // INSTALL mode (default): structural integrity of the owned install.
38
+ // LIVE mode (--live): requires an OBSERVED current-desktop load + hook activity. Must NOT
39
+ // report success for awaiting-restart / unverified. Machine-readable.
40
+ async function doctor({ live = false }) {
41
+ const cfg = cfgDir();
42
+ const res = { mode: live ? "live-readiness" : "install", configDir: cfg, ok: true, notes: [], degraded: [], failures: [] };
43
+ const ver = currentVersion();
44
+ if (!ver) { res.ok = false; res.notes.push("not installed"); return res; }
45
+ res.installedVersion = ver;
46
+ // resolve the controller the maintenance entry point will actually execute
47
+ try { res.executedController = path.relative(cfg, new URL(ctrlUrl).pathname) || ctrlUrl; } catch { res.executedController = ctrlUrl; }
48
+ res.reportedVsExecutedVersion = "see installedVersion + executedController";
49
+ if (!ctrlUrl) { res.ok = false; res.degraded.push("controller copy missing -> plugin cannot load"); return res; }
50
+ // structural: verify manifest-listed files still present + hash-match (user edits are fine = "preserved")
51
+ const { C, I } = await loadMods();
52
+ res.executedControllerVersion = C.LIFECYCLE_SCHEMA_VERSION;
53
+ if (C.LIFECYCLE_SCHEMA_VERSION !== ver || I?.VERSION !== ver) {
54
+ res.ok = false; res.degraded.push("release pointer / executed controller / installer version mismatch");
55
+ }
56
+ const mfPath = path.join(cfg, "longrun-harness", "releases", ver, "manifest.json");
57
+ let manifest = null; try { manifest = JSON.parse(fs.readFileSync(mfPath, "utf8")); } catch {}
58
+ if (!manifest) { res.ok = false; res.degraded.push("manifest missing"); return res; }
59
+ let missing = 0, edited = 0;
60
+ for (const f of manifest.files) {
61
+ const abs = path.join(cfg, f.rel);
62
+ let buf; try { buf = fs.readFileSync(abs); } catch { missing++; continue; }
63
+ if (crypto.createHash("sha256").update(buf).digest("hex") !== f.sha256) { edited++; res.notes.push("user-edited (preserved): " + f.rel); }
64
+ }
65
+ if (missing) { res.ok = false; res.degraded.push(`${missing} installed files missing`); }
66
+ // no duplicate registration: plugin file must not ALSO be named in a config plugin[]
67
+ for (const cfgf of ["opencode.json", "opencode.jsonc"]) {
68
+ const p = path.join(cfg, cfgf); if (!fs.existsSync(p)) continue;
69
+ const txt = fs.readFileSync(p, "utf8");
70
+ if (/"plugin"\s*:/.test(txt) && /longrun/.test(txt)) res.degraded.push(`possible duplicate plugin registration in ${cfgf}`);
71
+ }
72
+ if (!fs.existsSync(path.join(cfg, "plugins", "longrun.js"))) res.degraded.push("plugin file absent -> will not load next start");
73
+ res.notes.push("plugins are NOT an OS security sandbox; integrity checks are not tamper-proofing");
74
+
75
+ // ---- v1.2.1 subsystem report (structural; do NOT weaken the --live rules below) ----
76
+ try {
77
+ res.harnessVersion = ver;
78
+ res.pluginShape = (await import("file://" + path.join(cfg, "plugins", "longrun.js"))).default && typeof (await import("file://" + path.join(cfg, "plugins", "longrun.js"))).default.server === "function" ? "v1-server-plugin" : "UNKNOWN";
79
+ } catch { res.pluginShape = "unknown (import failed)"; }
80
+ res.runLifecycleSchema = { version: C.LIFECYCLE_SCHEMA_VERSION, actions: C.RUN_ACTIONS, noEllipsis: true };
81
+ res.memorySubsystem = {
82
+ present: !!(C.memory && typeof C.memory.initDeep === "function" && typeof C.memory.assessStaleness === "function"),
83
+ maxDepth: C.memory ? C.memory.DEFAULT_MAX_DEPTH : null,
84
+ agentsDiscovery: !!(C.memory && typeof C.memory.scanTree === "function"),
85
+ lspUsed: false, note: "deterministic filesystem signals; LSP/structural probes not wired (marked unmeasured, never failed on their absence)",
86
+ };
87
+ const projArg = (() => { const i = process.argv.indexOf("--project"); return i >= 0 ? process.argv[i + 1] : null; })();
88
+ const projDir = projArg || process.cwd();
89
+ const idx = C.memory && C.memory.readMemoryIndex(projDir);
90
+ res.memoryIndex = { project: projDir, present: !!idx, status: idx ? C.memory.assessStaleness(projDir, idx).status : "NO_MEMORY (optional)", schemaValid: !!idx && idx.schemaVersion === C.memory.SCHEMA_VERSION };
91
+ res.candidateLedger = { schemaVersion: 1, note: "candidate counters persist in run.json state; no active run to audit from doctor" };
92
+ res.evidenceSchema = { present: !!(C.evidence && Array.isArray(C.evidence.EVIDENCE_CLASSES)), classes: C.evidence ? C.evidence.EVIDENCE_CLASSES : [], verifyRejectsWeakerClass: true };
93
+ // ---- v1.2.3 authoritative receipt model + hard-gate recomputation (a real structural check) ----
94
+ // Doctor must not just confirm files exist; it self-tests that (a) there is ONE effective-receipt
95
+ // definition (no duplicate resolver), (b) a copied/fixture result cannot invalidate a project
96
+ // check, and (c) a hard gate is RECOMPUTED from evidence (a passing build can complete). If any
97
+ // part is missing/damaged the check FAILS (degraded), so a partial install is never reported healthy.
98
+ let rm;
99
+ try {
100
+ const fns = ["effectiveReceipt", "effectiveStatus", "evaluateGate", "gateStatuses", "normalizeStartArgs", "parseTestCounts", "deriveRunView", "checkDiagnostics"];
101
+ const present = fns.every((f) => typeof C[f] === "function");
102
+ // exactly one resolver + one lifecycle-verification block: count the canonical marker
103
+ let ctrlSrc = ""; try { ctrlSrc = fs.readFileSync(new URL(ctrlUrl), "utf8"); } catch {}
104
+ const resolverDefs = (ctrlSrc.match(/export function resolveVerification/g) || []).length;
105
+ const singleResolver = resolverDefs === 1;
106
+ const CUR = "doctor-selftest-fp";
107
+ const pass = C.makeReceipt({ checkId: "c-build", command: "b", exitCode: 0, requirementKind: "cmd", sourceFingerprint: CUR, startedAt: 1, finishedAt: 2 });
108
+ const copyFail = C.makeReceipt({ checkId: "c-build", command: "b", exitCode: 1, requirementKind: "cmd", sourceFingerprint: "copy-fp", startedAt: 3, finishedAt: 4, fpScope: "copy" });
109
+ const run = { status: "VERIFYING", sourceFingerprint: CUR, faults: [], receipts: [copyFail, pass], contract: { criteria: [{ id: "ENG", required: true, checks: ["c-build"], status: "PASS" }], gates: [{ id: "c-build", required: true, status: "FAIL" }], lossTarget: 0 } };
110
+ const copyIgnored = C.effectiveStatus(run, "c-build", CUR) === "PASS";
111
+ const unreviewedBlocked = C.canComplete(run, { currentFingerprint: CUR }).reason === "completion_review_required";
112
+ run.status = "PAUSED";
113
+ const reviewed = C.recordCompletionReview(run, { verdict: "accept", reason: "In-memory doctor self-test only; never product evidence.",
114
+ reviewId: "doctor-selftest", currentFingerprint: CUR, expectedBasis: C.completionReviewBasis(run, CUR) });
115
+ const gateRecomputed = C.canComplete(run, { currentFingerprint: CUR }).complete === true;
116
+ const noMask = (() => { const r2 = JSON.parse(JSON.stringify(run)); r2.receipts = [pass, C.makeReceipt({ checkId: "c-build", command: "b", exitCode: 1, requirementKind: "cmd", sourceFingerprint: CUR, startedAt: 5, finishedAt: 6 })]; return C.effectiveStatus(r2, "c-build", CUR) === "FAIL"; })();
117
+ rm = { singleSource: present && singleResolver, singleResolver, copySabotageIgnored: copyIgnored, gateRecomputedToComplete: gateRecomputed, failMasksOldPass: noMask };
118
+ rm.unreviewedCompletionBlocked = unreviewedBlocked;
119
+ rm.reviewedCompletionAllowed = reviewed.ok === true && gateRecomputed;
120
+ rm.ok = present && singleResolver && copyIgnored && gateRecomputed && noMask && unreviewedBlocked && reviewed.ok === true;
121
+ if (!rm.ok) { res.ok = false; res.degraded.push("receipt model self-test failed (effective-receipt / gate recomputation not intact)"); }
122
+ } catch (e) {
123
+ rm = { present: false, ok: false, error: String((e && e.message) || e) };
124
+ res.ok = false; res.degraded.push("receipt model check threw: " + rm.error);
125
+ }
126
+ res.receiptModel = rm;
127
+ res.continuationDefault = "OFF";
128
+ res.compactionIntegration = { mode: "native-auto + checkpoint/recovery (experimental.session.compacting supplements the prompt; does NOT replace it)", realCompactionVerified: false, trustedTelemetry: false, note: "no trustworthy per-call token counter; 75% custom trigger intentionally NOT implemented; auto-continuation disabled until a real Stop/abort discriminator is proven" };
129
+
130
+ // ---- LIVE-READINESS mode: needs evidence from a running desktop backend, not from disk. ----
131
+ // Trust rules (v1.1.2): a record counts ONLY with the non-forgeable provenance the plugin
132
+ // writes inside a REAL host process: nonce + toolsBuilt + live pid + fresh + hook activity,
133
+ // not flagged test. Legacy/probe-shaped, dead-pid, stale or test records are rejected —
134
+ // Node tests/probes can never satisfy the live check, and hook records without a genuine
135
+ // load nonce never pass.
136
+ if (live) {
137
+ const loadDir = path.join(stateDir(), "load");
138
+ let files = [];
139
+ try { files = fs.readdirSync(loadDir).map((f) => { try { return { f, r: JSON.parse(fs.readFileSync(path.join(loadDir, f), "utf8")) }; } catch { return null; } }).filter(Boolean); } catch {}
140
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
141
+ const now = Date.now();
142
+ const rejectReason = (r) => {
143
+ if (!r || typeof r !== "object") return "unparseable";
144
+ if (r.test === true) return "test-context";
145
+ if (!r.nonce || typeof r.nonce !== "string") return "missing-load-nonce";
146
+ if (r.toolsBuilt !== true) return "unverified-build";
147
+ // a record written by a process sharing this checker's node binary is a probe, not the app
148
+ if (!r.exec || typeof r.exec !== "string" || r.exec === process.execPath) return "checker-runtime-exec";
149
+ if (typeof r.pid !== "number" || !pidAlive(r.pid)) return "dead-or-missing-pid";
150
+ if (now - (r.at || 0) >= 15 * 60 * 1000) return "stale";
151
+ if ((r.hookActivity || 0) <= 0) return "no-hook-activity";
152
+ return null;
153
+ };
154
+ const genuine = [], rejected = [];
155
+ for (const { f, r } of files) { const why = rejectReason(r); if (why) rejected.push({ file: f, why }); else genuine.push(r); }
156
+ const freshGenuine = genuine.filter((r) => now - (r.at || 0) < 15 * 60 * 1000 && (r.hookActivity || 0) > 0);
157
+ res.liveEvidence = { loadRecords: files.length, rejectedUntrusted: rejected };
158
+ if (files.length === 0) {
159
+ res.ok = false; res.status = "AWAITING_RESTART"; res.live = "NOT_VERIFIED";
160
+ res.failures.push("No runtime load record observed: the plugin was not loaded by the running backend (it predates install). Restart required.");
161
+ } else if (freshGenuine.length === 0) {
162
+ res.ok = false; res.status = "STALE_LOAD_RECORD"; res.live = "NOT_VERIFIED";
163
+ for (const x of rejected) res.failures.push(`load record ${x.file} rejected (${x.why}): untrusted as live evidence`);
164
+ if (rejected.length === files.length && rejected.length > 0 && !rejected.some((x) => x.why === "dead-or-missing-pid" || x.why === "stale")) {
165
+ res.failures.push("Load record(s) exist but none are fresh with hook activity; treat as not currently loaded.");
166
+ }
167
+ } else {
168
+ const last = freshGenuine[freshGenuine.length - 1];
169
+ res.status = "LIVE_DETECTED"; res.live = "OBSERVED";
170
+ res.liveEvidence = { loadRecords: files.length, rejectedUntrusted: rejected, lastLoad: last, note: "plugin observed active in a current backend instance" };
171
+ res.notes.push("A live load was observed; still confirm cancellation/compaction legs separately before trusting bounded mode.");
172
+ }
173
+ }
174
+ return res;
175
+ }
176
+
177
+ async function main() {
178
+ const [, , cmd, ...rest] = process.argv;
179
+ const json = rest.includes("--json");
180
+ const live = rest.includes("--live");
181
+ const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; };
182
+ const { C, I } = await loadMods();
183
+ const out = { cmd };
184
+
185
+ if (cmd === "doctor") {
186
+ const r = await doctor({ live });
187
+ Object.assign(out, r);
188
+ } else if (cmd === "review") {
189
+ const proj = flag("--project"), runId = flag("--run"), reasonFile = flag("--reason-file");
190
+ if (!proj || !runId || !reasonFile) Object.assign(out, { ok: false, error: "REVIEW_ARGUMENTS_REQUIRED", detail: "Specify --project, --run, --verdict accept|reject, --expected-basis, --review-id and --reason-file. Read the current completionReview.basis from status first. Review is an operator action, not model self-approval." });
191
+ else {
192
+ const reason = fs.readFileSync(reasonFile, "utf8");
193
+ const key = C.stateKey(C.projectIdentity(proj), runId);
194
+ const result = await C.operatorCompletionReview(new C.Store(stateDir()), key, {
195
+ directory: proj, runId, verdict: flag("--verdict"), reason,
196
+ expectedBasis: flag("--expected-basis"), reviewId: flag("--review-id") });
197
+ Object.assign(out, { ok: !result.error, ...result });
198
+ }
199
+ } else if (cmd === "amend") {
200
+ // Operator-only: a finite additional candidate allowance and/or a new absolute deadline for an
201
+ // already-paused run whose original limits are exhausted/expired. Read the current basis and
202
+ // revision from `status --json` (amendmentBasis / controlRevision) first. This never resumes a
203
+ // run, never resets counters and never grants acceptance. The native model tool surface has no
204
+ // amendment action; a model must not self-amend through this CLI or by editing state.
205
+ const proj = flag("--project"), runId = flag("--run");
206
+ const authFile = flag("--authorization-file"), reasonFile = flag("--reason-file");
207
+ const amendmentId = flag("--amendment-id"), additionalCandidates = flag("--additional-candidates");
208
+ const newDeadline = flag("--new-deadline"), expectedBasis = flag("--expected-basis"), expectedRevision = flag("--expected-revision");
209
+ if (!proj || !runId || !authFile || !reasonFile || !amendmentId || !additionalCandidates || !newDeadline || !expectedBasis || !expectedRevision)
210
+ Object.assign(out, { ok: false, error: "AMENDMENT_ARGUMENTS_REQUIRED", detail: "Specify --project, --run, --amendment-id, --additional-candidates, --new-deadline (ISO-8601 absolute), --authorization-file, --reason-file, --expected-basis and --expected-revision. Read amendmentBasis/controlRevision from status --json first. This is an operator action, not model self-approval." });
211
+ else {
212
+ try {
213
+ const authorization = fs.readFileSync(authFile, "utf8");
214
+ const reason = fs.readFileSync(reasonFile, "utf8");
215
+ const key = C.stateKey(C.projectIdentity(proj), runId);
216
+ const result = await C.operatorBudgetAmendment(new C.Store(stateDir()), key, {
217
+ directory: proj, runId, amendmentId, authorization, reason,
218
+ additionalCandidates: Number(additionalCandidates), newDeadlineAt: Date.parse(newDeadline),
219
+ expectedBasis, expectedRevision: Number(expectedRevision) });
220
+ Object.assign(out, { ok: !result.error, ...result });
221
+ } catch (e) { Object.assign(out, { ok: false, error: "AMENDMENT_IO_ERROR", detail: String((e && e.message) || e) }); }
222
+ }
223
+ } else if (cmd === "status" || cmd === "pause") {
224
+ const proj = flag("--project") || process.cwd();
225
+ const id = C.projectIdentity(proj);
226
+ const key = C.stateKey(id, flag("--run") || "default");
227
+ const store = new C.Store(stateDir());
228
+ const found = store.readRun(key), run = found.run;
229
+ if (found.error === "NO_RUN") { Object.assign(out, { ok: true, state: "NO_RUN", project: id.root }); }
230
+ else if (found.error) { Object.assign(out, { ok: false, ...found }); }
231
+ else if (cmd === "pause") {
232
+ // Re-read under the same writer lock used by verification. Never write
233
+ // this earlier status snapshot over a concurrent receipt/result commit.
234
+ const paused = store.mutate(key, C.pauseRun);
235
+ Object.assign(out, { ok: !paused.error, ...paused });
236
+ }
237
+ else Object.assign(out, { ok: true, ...C.deriveRunView(run, { currentFingerprint: C.sourceFingerprint(proj), projectMemoryStatus: C.memory.assessStaleness(proj, C.memory.readMemoryIndex(proj)) }) });
238
+ } else if (cmd === "disable") { Object.assign(out, I ? I.disable({ configDir: cfgDir() }) : { error: "install lib missing" }); }
239
+ else if (cmd === "enable") { Object.assign(out, I ? I.enable({ configDir: cfgDir() }) : { error: "install lib missing" }); }
240
+ else if (cmd === "uninstall") { Object.assign(out, I ? I.uninstall({ configDir: cfgDir() }) : { error: "install lib missing" }); }
241
+ else { Object.assign(out, { error: "unknown command", usage: "doctor [--live]|status|pause|review|amend|disable|enable|uninstall [--json] [--project PATH]" }); }
242
+
243
+ if (json) process.stdout.write(JSON.stringify(out, null, 2) + "\n");
244
+ else {
245
+ process.stdout.write(`cmd=${cmd} mode=${out.mode || "n/a"} ok=${out.ok} status=${out.status || "n/a"} live=${out.live || "n/a"}\n`);
246
+ if (out.runLifecycleSchema) process.stdout.write(` lifecycle schema ${out.runLifecycleSchema.version}: ${out.runLifecycleSchema.actions.join("|")}\n`);
247
+ if (out.memorySubsystem) process.stdout.write(` memory: ${out.memorySubsystem.present ? "present" : "MISSING"} (maxDepth ${out.memorySubsystem.maxDepth}); index ${out.memoryIndex ? out.memoryIndex.status : "n/a"}\n`);
248
+ if (out.evidenceSchema) process.stdout.write(` evidence classes: ${out.evidenceSchema.classes.join("/") || "MISSING"}; continuation=${out.continuationDefault}\n`);
249
+ if (out.receiptModel) process.stdout.write(` receipt model: ${out.receiptModel.ok ? "ok" : "CHECK FAILED"} (singleSource=${out.receiptModel.singleSource}, singleResolver=${out.receiptModel.singleResolver}, copySabotageIgnored=${out.receiptModel.copySabotageIgnored}, gateRecomputed=${out.gateRecomputedToComplete ?? out.receiptModel.gateRecomputedToComplete})\n`);
250
+ for (const d of (out.degraded || [])) process.stdout.write(` DEGRADED ${d}\n`);
251
+ for (const f of (out.failures || [])) process.stdout.write(` FAIL ${f}\n`);
252
+ for (const n of (out.notes || [])) process.stdout.write(` note ${n}\n`);
253
+ }
254
+ // Preserve the exit status while letting asynchronous stdout writes drain to pipes.
255
+ process.exitCode = out.ok === false ? 2 : 0;
256
+ }
257
+ main().catch((e) => { process.stdout.write("ERROR " + (e && e.message) + "\n"); process.exitCode = 3; });