@milpa/comal 0.2.6 → 0.2.8

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.
@@ -0,0 +1,981 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFile, writeFile, mkdir, lstat, rename, rm, symlink, readdir, stat as statFile, open as openFile } from 'node:fs/promises';
3
+ import { realpathSync } from 'node:fs';
4
+ import { PLUGIN_REPAIR_OPERATION_NAMES } from './operations.mjs';
5
+ import path from 'node:path';
6
+ import { readPluginConfig, writePluginConfigRaw, inspectPluginModule, parsePluginConfig } from './plugin-config.mjs';
7
+ import { processIdentity, processDead } from './process-lock.mjs';
8
+ import { configDigest } from './plugin-trust.mjs';
9
+ import { openCandidate, readCandidate, verifyCandidate, verifyPlacement, candidateDiff, discardCandidate } from './plugin-workbench.mjs';
10
+ import { defaultStateDir } from './compat.mjs';
11
+ import { validateCases, caseDigest, evaluateCase, compareContracts } from './plugin-contract.mjs';
12
+ import { runCandidate, hostServiceStandIns } from './plugin-candidate-runner.mjs';
13
+ import { t } from './i18n/index.mjs';
14
+
15
+ // Repair by contract (docs/superpowers/specs/2026-11-10-house-findings-design.md, «Repair: by contract»):
16
+ // the five operations the model drives. Who establishes what:
17
+ // - a case result is obtained by THIS process calling the live operation through the model's own
18
+ // house binding (permissions, guards, receipts), and evaluated by the house (evaluateCase);
19
+ // - the contract that must be kept is runtime.contract(id) (live) against the contract the runtime
20
+ // staged from the overlay (admit), both computed in this process;
21
+ // - the candidate's pre-flight report (PluginRepairTest) is the candidate's own account: shown to the
22
+ // model as advice, never read by PluginRepairAccept;
23
+ // - a change of contract needs an act signed by the human (a sealed plugin_contract_act row).
24
+ // Model-facing descriptions and errors are English; what a human reads goes through t().
25
+
26
+ export const PLUGIN_REPAIR_OPERATIONS = PLUGIN_REPAIR_OPERATION_NAMES;
27
+ // How long PluginRepairTest lets a candidate's separate process import, activate and replay: 20 s, or
28
+ // COMAL_PLUGIN_CANDIDATE_TIMEOUT_MS (a positive whole number of ms; anything above 120000 counts as
29
+ // 120000). Read once, by the CLI.
30
+ export const CANDIDATE_TIMEOUT_CAP_MS = 120000;
31
+ export function candidateTimeoutMs(env = process.env) {
32
+ const raw = String(env.COMAL_PLUGIN_CANDIDATE_TIMEOUT_MS ?? '').trim();
33
+ if (!/^[1-9]\d*$/.test(raw)) return 20000;
34
+ return Number(raw) <= CANDIDATE_TIMEOUT_CAP_MS ? Number(raw) : CANDIDATE_TIMEOUT_CAP_MS;
35
+ }
36
+ // The pending marker of an Accept: one per plugin, <stateDir>/plugin-repair-pending/<id>.json, created
37
+ // exclusively and removed only by the Accept that owns it. The single file of the previous release
38
+ // (<stateDir>/plugin-repair-pending.json) is still read for one release: recovered, then deleted.
39
+ const PENDING = 'plugin-repair-pending.json', PENDING_DIR = 'plugin-repair-pending';
40
+ const pendingMarker = (stateDir, id) => path.join(stateDir, PENDING_DIR, `${id}.json`);
41
+ /** The real path of a location that may not exist yet: its nearest existing ancestor resolved, plus the rest. */
42
+ export function realLocation(location) {
43
+ let ancestor = path.resolve(location); const suffix = [];
44
+ for (;;) {
45
+ try { return path.join(realpathSync(ancestor), ...suffix); }
46
+ catch { const parent = path.dirname(ancestor); if (parent === ancestor) return path.resolve(location); suffix.unshift(path.basename(ancestor)); ancestor = parent; }
47
+ }
48
+ }
49
+ const sameOwner = (a, b) => Boolean(a && b) && a.pid === b.pid && (a.start ?? null) === (b.start ?? null) && (a.boot ?? null) === (b.boot ?? null) && (a.host ?? null) === (b.host ?? null);
50
+ const SHA = /^[0-9a-f]{64}$/;
51
+ const ID = { type: 'string', pattern: '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$', maxLength: 24 };
52
+ const UUID = { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' };
53
+ const sha256 = value => createHash('sha256').update(value).digest('hex');
54
+ const ref = row => row?.audit && Number.isInteger(row.audit.sequence) && typeof row.audit.sha256 === 'string' ? { sequence: row.audit.sequence, sha256: row.audit.sha256 } : null;
55
+ // A refusal is an ordinary operation result for the model: a thrown error with a code and what to do next.
56
+ const refusal = (code, message, next, details = {}) => Object.assign(new Error(`${message} Next: ${next}`), { code, details: { ...details, next_action: next } });
57
+ // Refusals before the handler ran (permission, plan mode, guard, unavailable, stale catalogue, invalid input).
58
+ const NOT_RUN = /^(HOUSE_(REFUSED|GUARD|STALE|AUTHORITY_CHANGED|SURFACE_CHANGED|INPUT_SCHEMA|PERMISSION_DENIED)|GOVERNANCE_REFUSED|TOOL_PACK_REQUIRED)$/;
59
+
60
+ // A contract as the act and the accepted row name it: the surface digest and every case's digest.
61
+ const summary = contract => ({ surface_digest: contract.surface_digest, cases: contract.cases.map(item => ({ name: item.name, digest: item.digest })) });
62
+ const sameContract = (a, b) => a.surface_digest === b.surface_digest && JSON.stringify(a.cases.map(item => item.digest).sort()) === JSON.stringify(b.cases.map(item => item.digest).sort());
63
+ const listed = items => items.length ? items.join(', ') : t('(none)');
64
+ const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
65
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : plain(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
66
+ const ITEM = 600, CARD = 24000;
67
+ // What PluginRepairAccept's answer shows of each case run: at most CASE_OUTPUT bytes per case and
68
+ // CASE_OUTPUT_TOTAL for all of them. The journal's rows keep digests and receipts, never these.
69
+ const CASE_OUTPUT = 4 * 1024, CASE_OUTPUT_TOTAL = 16 * 1024;
70
+ const bytesOf = text => Buffer.byteLength(text);
71
+ function cutBytes(text, limit) {
72
+ let out = text.slice(0, Math.max(0, limit));
73
+ while (bytesOf(out) > limit) out = out.slice(0, out.length - Math.max(1, Math.ceil((bytesOf(out) - limit) / 4)));
74
+ return out;
75
+ }
76
+ // One case run as the model sees it: the returned value (parsed when it is JSON), or what it threw. A value
77
+ // that does not fit is cut to the text that does and marked output_truncated.
78
+ function caseShown(result, budget) {
79
+ if (result.status === 'threw') {
80
+ const message = cutBytes(String(result.message ?? ''), Math.min(CASE_OUTPUT, budget.left)); budget.left -= bytesOf(message);
81
+ return { threw: { ...(result.code ? { code: result.code } : {}), message }, ...(message.length < String(result.message ?? '').length ? { output_truncated: true } : {}) };
82
+ }
83
+ if (result.status !== 'returned') return {};
84
+ let value = result.output, text;
85
+ if (typeof value === 'string') { try { value = JSON.parse(value); } catch {} }
86
+ try { text = JSON.stringify(value) ?? 'null'; } catch { text = String(result.output); value = text; }
87
+ const limit = Math.min(CASE_OUTPUT, budget.left);
88
+ if (bytesOf(text) <= limit) { budget.left -= bytesOf(text); return { output: value }; }
89
+ const cut = cutBytes(text, limit); budget.left -= bytesOf(cut);
90
+ return { ...(cut ? { output: cut } : {}), output_truncated: true };
91
+ }
92
+ // One side of a change as compact canonical JSON, at most ITEM chars.
93
+ const compact = value => { const text = JSON.stringify(canonical(value ?? null)); return text.length > ITEM ? { text: `${text.slice(0, ITEM)}… [truncated]`, cut: true } : { text, cut: false }; };
94
+ // What an entry of surface_changed names, on one side of the comparison.
95
+ function surfaceSide(contract, item) {
96
+ const match = /^operation:(.+?):(.+)$/.exec(item), surface = contract.surface ?? {};
97
+ if (!match) return { present: Object.hasOwn(surface, item), value: surface[item] };
98
+ const op = (surface.operations ?? []).find(entry => entry.name === match[1]), field = match[2];
99
+ if (field === 'added' || field === 'removed') return { present: Boolean(op), value: op };
100
+ if (!op) return { present: false };
101
+ if (field.startsWith('origin.')) return { present: Boolean(op.origin) && Object.hasOwn(op.origin, field.slice(7)), value: op.origin?.[field.slice(7)] };
102
+ return { present: Object.hasOwn(op, field), value: op[field] };
103
+ }
104
+ const caseSide = (contract, name) => { const found = contract.cases.find(item => item.name === name); return found ? { present: true, value: { input: found.input, expect: found.expect } } : { present: false }; };
105
+
106
+ // The card the human reads before signing a change of contract (default No): every change with its
107
+ // before and after. Returns the text and how many items had to be truncated.
108
+ function actCard({ plugin, comparison, reason, files, before, after, saved, unfollowed = [] }) {
109
+ let truncated = 0;
110
+ const detail = (name, sides) => {
111
+ const lines = [` • ${name}`]; let cut = false;
112
+ for (const [label, side] of sides) if (side.present) { const shown = compact(side.value); cut ||= shown.cut; lines.push(` ${t(label, { value: shown.text })}`); }
113
+ if (cut) truncated++;
114
+ return lines;
115
+ };
116
+ const sides = (pick, name) => [['before: {value}', pick(before, name)], ['after: {value}', pick(after, name)]];
117
+ const lines = [
118
+ comparison.kept ? t('Plugin {plugin}: the repair keeps what it promises, but it holds code the house cannot follow by reading it. Sign only if you reviewed that code.', { plugin })
119
+ : t('Plugin {plugin}: the repair changes what it promises. Sign only if you accept the new contract.', { plugin }),
120
+ t('Surface changed: {list}', { list: listed(comparison.surface_changed) }), ...comparison.surface_changed.flatMap(item => detail(item, sides(surfaceSide, item))),
121
+ t('Cases removed: {list}', { list: listed(comparison.cases_removed) }), ...comparison.cases_removed.flatMap(name => detail(name, sides(caseSide, name))),
122
+ t('Cases changed: {list}', { list: listed(comparison.cases_changed) }), ...comparison.cases_changed.flatMap(name => detail(name, sides(caseSide, name))),
123
+ t('Cases added: {list}', { list: listed(comparison.cases_added) }), ...comparison.cases_added.flatMap(name => detail(name, sides(caseSide, name))),
124
+ t('Code the house cannot follow: {list}', { list: listed(unfollowed.map(item => `${item.file}: ${item.construct}`)) }),
125
+ t("The model's reason: {reason}", { reason: reason || t('(none given)') }),
126
+ t('Files in the patch: {list}', { list: listed(files) }),
127
+ ];
128
+ let text = lines.join('\n');
129
+ if (text.length > CARD) { text = text.slice(0, CARD); truncated++; }
130
+ const trailer = [...(truncated && saved ? [t('{count} item(s) truncated; the full contracts before and after are in {before} and {after}.', { count: truncated, before: saved.before, after: saved.after })] : []),
131
+ t('Yes signs the act and the repaired plugin is published and checked; No (the default) leaves everything as it is.')];
132
+ return { text: [text, ...trailer].join('\n'), truncated };
133
+ }
134
+
135
+ // A private file written whole or not at all: temporary file in the same directory, fsync, rename.
136
+ async function writeAtomic(file, data, mode = 0o600) {
137
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}-${randomUUID()}`);
138
+ try {
139
+ const handle = await openFile(temporary, 'wx', mode);
140
+ try { await handle.writeFile(data); await handle.sync(); } finally { await handle.close(); }
141
+ await rename(temporary, file);
142
+ } catch (error) { await rm(temporary, { force: true }).catch(() => {}); throw error; }
143
+ }
144
+
145
+ const MARKER_BYTES = 2 * 1024 * 1024;
146
+ const sameJson = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
147
+
148
+ // The edit an Accept makes, and only it: the same configuration except that the marker's plugin entry
149
+ // points at an overlay under <stateDir>/plugin-overlays/<id>/ and remembers the module it replaced.
150
+ function repairShaped({ previous, current, plugin, pluginsFile, stateDir }) {
151
+ let before, after;
152
+ try { before = JSON.parse(previous.toString('utf8')); after = JSON.parse(current.toString('utf8')); parsePluginConfig(previous, pluginsFile); parsePluginConfig(current, pluginsFile); }
153
+ catch { return 'not_a_plugin_config'; }
154
+ const { plugins: was, ...restBefore } = before, { plugins: now, ...restAfter } = after;
155
+ if (!sameJson(restBefore, restAfter) || !Array.isArray(was) || !Array.isArray(now) || was.length !== now.length) return 'not_repair_shaped';
156
+ // Real paths on both sides: Accept writes the overlay's real module path, and the state directory may be reached through a link.
157
+ const home = path.join(realLocation(stateDir), 'plugin-overlays', plugin) + path.sep;
158
+ let repaired = 0;
159
+ for (let index = 0; index < was.length; index++) {
160
+ const a = was[index], b = now[index];
161
+ if (a?.id !== plugin) { if (!sameJson(a, b)) return 'not_repair_shaped'; continue; }
162
+ repaired++;
163
+ // repaired_from names the human's original: kept as it was when the entry was already a repair.
164
+ if (b?.id !== plugin || b.repaired_from !== (a.repaired_from ?? a.module) || typeof b.module !== 'string' || !realLocation(path.resolve(path.dirname(pluginsFile), b.module)).startsWith(home)) return 'not_repair_shaped';
165
+ const { module: _m, repaired_from: _r, ...keepA } = a, { module: _n, repaired_from: _s, ...keepB } = b;
166
+ if (!sameJson(keepA, keepB)) return 'not_repair_shaped';
167
+ }
168
+ return repaired === 1 ? null : 'not_repair_shaped';
169
+ }
170
+
171
+ /**
172
+ * One pending marker, as startup recovery treats it. Nothing happens while the marker's owner is alive (its
173
+ * Accept is still running). With the owner dead:
174
+ * - a repair that completed (the marker says so, or `completed(marker)` finds it in its journal) is kept;
175
+ * - otherwise the repair's trust record (reason contract_repair, for this marker's overlay) is removed and the
176
+ * record that was there before is put back, whatever happened to plugins.json since: a repair with no sealed
177
+ * acceptance must not keep trust;
178
+ * - and plugins.json is put back only when it is still exactly what that Accept wrote AND the change is the
179
+ * repair's shape; its mode is kept. Anything else (missing, edited since) is left alone.
180
+ * The marker is then removed. A corrupt, oversized or ownerless marker is removed, never thrown. Returns null
181
+ * when there is no marker, when its owner is alive, or when it belongs to another plugins.json.
182
+ */
183
+ async function recoverMarker(marker, { stateDir, pluginsFile, trustStore, completed, expectPlugin } = {}) {
184
+ const corrupt = async error => { await rm(marker, { force: true }).catch(() => {}); return { recovered: false, restored: false, corrupt: true, error: String(error?.message ?? error) }; };
185
+ let info;
186
+ try { info = await statFile(marker); } catch (error) { if (error.code === 'ENOENT') return null; return corrupt(error); }
187
+ if (!info.isFile() || info.size > MARKER_BYTES) return corrupt('the marker is not a regular file of at most 2 MB');
188
+ let pending, previous;
189
+ try {
190
+ pending = JSON.parse(await readFile(marker, 'utf8'));
191
+ if (!plain(pending) || typeof pending.plugin !== 'string' || typeof pending.plugins_file !== 'string' || typeof pending.previous_bytes !== 'string' || !SHA.test(pending.previous_sha256 ?? '') || !SHA.test(pending.written_sha256 ?? '') || !plain(pending.owner) || !Number.isSafeInteger(pending.owner.pid)) throw new Error('invalid marker');
192
+ if (expectPlugin !== undefined && pending.plugin !== expectPlugin) throw new Error('the marker does not name the plugin of its file');
193
+ previous = Buffer.from(pending.previous_bytes, 'base64');
194
+ if (sha256(previous) !== pending.previous_sha256) throw new Error('the previous bytes do not match their hash');
195
+ } catch (error) { return corrupt(error); }
196
+ if (realLocation(pending.plugins_file) !== realLocation(pluginsFile)) return null;
197
+ if (!processDead(pending.owner)) return null; // its Accept is still running: never touch it
198
+ const result = { recovered: true, plugin: pending.plugin, candidate: pending.candidate ?? null, session: pending.session ?? null, overlay: pending.overlay ?? null, plugins_file: pluginsFile };
199
+ let done = pending.completed === true;
200
+ if (!done && typeof completed === 'function') { try { done = (await completed(pending)) === true; } catch {} }
201
+ if (done) { await rm(marker, { force: true }); return { ...result, restored: false, reason: 'completed' }; }
202
+ // The repair's trust record replaced the human's one for the code it replaced: put that one back. The crash
203
+ // may have come between the trust write and the sealed row, so this does not depend on plugins.json.
204
+ const restoreTrust = async () => {
205
+ try {
206
+ await trustStore?.load?.();
207
+ const now = trustStore?.find?.(pluginsFile, pending.plugin);
208
+ if (trustStore && now?.reason === 'contract_repair' && now.code_fingerprint === pending.overlay_fingerprint) { await trustStore.restore(pluginsFile, pending.plugin, pending.previous_trust ?? undefined); return 'restored'; }
209
+ return 'unchanged';
210
+ } catch (error) { return `failed: ${String(error.message).slice(0, 200)}`; }
211
+ };
212
+ let current = null, mode = 0o600;
213
+ try { current = await readFile(pluginsFile); mode = (await statFile(pluginsFile)).mode & 0o777; } catch {}
214
+ const why = !current ? 'missing' : sha256(current) !== pending.written_sha256 ? 'edited_since' : repairShaped({ previous, current, plugin: pending.plugin, pluginsFile, stateDir });
215
+ if (why) { const trust = await restoreTrust(); await rm(marker, { force: true }); return { ...result, restored: false, reason: why, trust }; }
216
+ await writeAtomic(pluginsFile, previous, mode);
217
+ const trust = await restoreTrust();
218
+ await rm(marker, { force: true });
219
+ return { ...result, restored: true, trust };
220
+ }
221
+
222
+ /**
223
+ * At startup, before plugins load: every repair that was accepting when its process died left a marker
224
+ * (<stateDir>/plugin-repair-pending/<id>.json, or the previous release's single plugin-repair-pending.json).
225
+ * Each is treated as recoverMarker says. Returns one result per marker that was acted on. A marker whose
226
+ * recovery throws is NOT removed: its result says { failed: true, marker_kept: true, error } and the others
227
+ * still run.
228
+ */
229
+ export async function recoverPendingRepairs({ stateDir, pluginsFile, trustStore, completed } = {}) {
230
+ const state = realLocation(stateDir), results = [];
231
+ const each = async (marker, expectPlugin) => {
232
+ try { const result = await recoverMarker(marker, { stateDir: state, pluginsFile, trustStore, completed, expectPlugin }); if (result) results.push(result); }
233
+ catch (error) { results.push({ recovered: false, restored: false, failed: true, marker_kept: true, marker, ...(expectPlugin ? { plugin: expectPlugin } : {}), error: String(error?.message ?? error).slice(0, 2000) }); }
234
+ };
235
+ await each(path.join(state, PENDING));
236
+ const dir = path.join(state, PENDING_DIR);
237
+ let info = null; try { info = await lstat(dir); } catch {}
238
+ if (info?.isDirectory()) {
239
+ for (const name of (await readdir(dir)).sort()) { const match = /^(.+)\.json$/.exec(name); if (match && PLUGIN_DIR.test(match[1])) await each(path.join(dir, name), match[1]); }
240
+ }
241
+ return results;
242
+ }
243
+ /** The first result of recoverPendingRepairs, or null: the shape of the previous release's single marker. */
244
+ export async function recoverPendingRepair(options) { return (await recoverPendingRepairs(options))[0] ?? null; }
245
+
246
+ // The human's view (/plugins repairs, /plugins acts): small records the house writes beside its work.
247
+ // They are informational. The authority stays the journal and the trust store, and /plugins revert works
248
+ // from plugins.json's repaired_from, never from these files.
249
+ // <stateDir>/plugin-overlays/<id>/<fingerprint>/repair.json one accepted repair (beside repair.patch)
250
+ // <stateDir>/plugin-acts/<id>/<time>-<uuid>.json one act the human signed (it outlives a rollback)
251
+ export const REPAIR_RECORD = 'repair.json';
252
+ export const RECORD_LIMIT = 200;
253
+ const RECORD_BYTES = 256 * 1024;
254
+ const FINGERPRINT = /^[0-9a-f]{64}$/;
255
+ export const overlaysDir = stateDir => path.join(stateDir, 'plugin-overlays');
256
+ export const actsDir = stateDir => path.join(stateDir, 'plugin-acts');
257
+ const PLUGIN_DIR = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
258
+ async function entries(dir) { try { return await readdir(dir, { withFileTypes: true }); } catch { return []; } }
259
+ async function smallJson(file) {
260
+ try { const info = await lstat(file); if (!info.isFile() || info.size > RECORD_BYTES) return null; const value = JSON.parse(await readFile(file, 'utf8')); return plain(value) ? value : null; } catch { return null; }
261
+ }
262
+ // Newest first, at most `limit`; `total` says how many there were.
263
+ const newest = (items, key, limit) => ({ items: items.sort((a, b) => String(b[key] ?? '').localeCompare(String(a[key] ?? ''))).slice(0, limit), total: items.length });
264
+ /** The accepted repairs of this project (each overlay with a repair.json), newest first. */
265
+ export async function readRepairRecords(stateDir, { limit = RECORD_LIMIT } = {}) {
266
+ const items = [];
267
+ for (const plugin of await entries(overlaysDir(stateDir))) {
268
+ if (!plugin.isDirectory() || !PLUGIN_DIR.test(plugin.name)) continue;
269
+ for (const overlay of await entries(path.join(overlaysDir(stateDir), plugin.name))) {
270
+ if (!overlay.isDirectory() || !FINGERPRINT.test(overlay.name)) continue;
271
+ const dir = path.join(overlaysDir(stateDir), plugin.name, overlay.name), record = await smallJson(path.join(dir, REPAIR_RECORD));
272
+ if (record && record.plugin === plugin.name) items.push({ ...record, dir, patch: path.join(dir, 'repair.patch') });
273
+ }
274
+ }
275
+ return newest(items, 'accepted_at', limit);
276
+ }
277
+ /** The acts the human signed in this project's sessions, newest first. */
278
+ export async function readActRecords(stateDir, { limit = RECORD_LIMIT } = {}) {
279
+ const items = [];
280
+ for (const plugin of await entries(actsDir(stateDir))) {
281
+ if (!plugin.isDirectory() || !PLUGIN_DIR.test(plugin.name)) continue;
282
+ for (const file of await entries(path.join(actsDir(stateDir), plugin.name))) {
283
+ if (!file.isFile() || !file.name.endsWith('.json') || file.name.startsWith('.')) continue;
284
+ const record = await smallJson(path.join(actsDir(stateDir), plugin.name, file.name));
285
+ if (record && record.plugin === plugin.name) items.push({ ...record, file: path.join(actsDir(stateDir), plugin.name, file.name) });
286
+ }
287
+ }
288
+ return newest(items, 'signed_at', limit);
289
+ }
290
+ /** The repair.json of the overlay a module path lives in, or null when the module is not an overlay. */
291
+ export function repairRecordFile(stateDir, id, module) {
292
+ const relative = path.relative(path.join(overlaysDir(stateDir), id), path.resolve(module)).split(path.sep);
293
+ return relative.length > 2 && FINGERPRINT.test(relative[0]) && relative[1] === 'tree' ? path.join(overlaysDir(stateDir), id, relative[0], REPAIR_RECORD) : null;
294
+ }
295
+ export async function readRepairRecord(file) { return file ? smallJson(file) : null; }
296
+ /**
297
+ * The chain of repairs under a live overlay, newest first: each record's `before_module` is what was live
298
+ * when it was accepted, an earlier overlay or the original. The last one is the first repair, whose
299
+ * `fingerprint_before` is the original's fingerprint as it was then. `complete` is false when a record is
300
+ * missing midway. Bounded, and a cycle stops it.
301
+ */
302
+ export async function repairChain(stateDir, id, module, { limit = 32 } = {}) {
303
+ const records = [], seen = new Set();
304
+ let file = repairRecordFile(stateDir, id, module);
305
+ while (file && !seen.has(file) && records.length < limit) {
306
+ seen.add(file);
307
+ const record = await readRepairRecord(file);
308
+ if (!record) return { records, complete: false };
309
+ records.push({ ...record, file });
310
+ const before = record.before_module ?? record.repaired_from;
311
+ if (typeof before !== 'string') return { records, complete: false };
312
+ file = repairRecordFile(stateDir, id, before);
313
+ }
314
+ return { records, complete: Boolean(records.length) && !file };
315
+ }
316
+ /** History is kept: a reverted repair's record says when. */
317
+ export async function markRepairReverted(file, at = new Date().toISOString()) {
318
+ const record = await readRepairRecord(file); if (!record) return false;
319
+ await writeAtomic(file, JSON.stringify({ ...record, reverted_at: at }, null, 2) + '\n'); return true;
320
+ }
321
+
322
+ export class PluginRepair {
323
+ #sessions = new Map(); // sessionId -> { reproduced: Map<plugin, Map<digest, entry>>, open: Map<candidateId, state>, acts: [] }
324
+ #watch = new Map(); // parent invocation id -> sealed house_receipt rows of its nested calls
325
+ #busy = new Set();
326
+ #running = new Map(); // plugin -> the promise of its running Accept
327
+ #standing = new Map(); // plugin -> the verification standing of one Accept's step 6 (in memory only)
328
+
329
+ /**
330
+ * runtime: PluginRuntime; binding: the house binding of the model's tool calls (actor root-agent);
331
+ * recorder: FindingRecorder or () => FindingRecorder; trustStore: { find, trust, restore };
332
+ * policy: PermissionPolicy (candidate write roots); confirm(name, input, options) → strict boolean, or
333
+ * undefined when no human is present; append(row) → sealed row in the session journal; completed(marker) →
334
+ * whether the journal holds the sealed acceptance of a dead process's pending marker (as startup recovery asks);
335
+ * releaseFence({ plugin, accepted, sessionId, invocation_id }) → the work steps the accepted repair released
336
+ * ([{ task_id, step_id, operation }], see releaseRepairedSteps in work-plan.mjs), or undefined where there is no work plan.
337
+ */
338
+ constructor({ runtime, binding, recorder, trustStore, policy, confirm, cwd = process.cwd(), stateDir, append, trustByFlag = false, runner, timeoutMs = 20000, completed, releaseFence } = {}) {
339
+ if (!runtime || !binding?.execute || typeof append !== 'function') throw new TypeError('PluginRepair requires a runtime, a house binding and append(row).');
340
+ Object.assign(this, { runtime, binding, recorder, trustStore, policy, confirm, cwd, stateDir, append, trustByFlag, runner, timeoutMs, completed, releaseFence });
341
+ }
342
+
343
+ /** The host's sealed house rows: nested receipts of a repair operation are matched to it here. */
344
+ observe(row) {
345
+ if (row?.type !== 'house_receipt' || typeof row.parent_invocation_id !== 'string') return;
346
+ this.#watch.get(row.parent_invocation_id)?.push(row);
347
+ }
348
+
349
+ /**
350
+ * While Accept runs a published repair's cases (a contract kept, or a change the human signed an act for): what the host's rule gate needs to let the saved rules of
351
+ * the previous generation apply. Only for a call nested directly under that Accept invocation; undefined
352
+ * for every other call, and once step 6 ends (success, rollback or throw).
353
+ */
354
+ standing(id, invocation) {
355
+ const entry = this.#standing.get(id);
356
+ if (!entry || typeof invocation?.parent_invocation_id !== 'string' || invocation.parent_invocation_id !== entry.invocation) return undefined;
357
+ return structuredClone({ module: entry.module, code_fingerprint: entry.code_fingerprint, config_sha256: entry.config_sha256, previous: entry.previous });
358
+ }
359
+
360
+ /** Whether a repair of `id` is in progress: an open candidate in a session of this process, or an Accept running. */
361
+ busy(id) { return this.#busy.has(id) || [...this.#sessions.values()].some(session => [...session.open.values()].some(state => state.plugin === id)); }
362
+
363
+ #recorder() { return typeof this.recorder === 'function' ? this.recorder() : this.recorder; }
364
+ #session(id) {
365
+ if (!this.#sessions.has(id)) this.#sessions.set(id, { reproduced: new Map(), open: new Map(), acts: [] });
366
+ return this.#sessions.get(id);
367
+ }
368
+ async #row(row, options) { return this.append({ source: 'plugin_repair', ...(options?.invocation_id ? { invocation_id: options.invocation_id } : {}), ...row }); }
369
+
370
+ // Only the model's own binding may drive a repair: its calls re-enter the house as that same actor.
371
+ #caller(options) {
372
+ const own = this.binding.identity?.();
373
+ if (own && (options?.actor?.id !== own.actor.id || options?.actor?.kind !== own.actor.kind || options?.sessionId !== own.session_id)) {
374
+ throw refusal('REPAIR_ACTOR', 'Plugin repair is available to the root agent of this session only.', 'Ask the root agent to run the repair.');
375
+ }
376
+ return this.#session(options?.sessionId);
377
+ }
378
+
379
+ async #configured(id) {
380
+ let source;
381
+ try { source = await readPluginConfig(this.runtime.file, { missing: true }); }
382
+ catch (error) { throw refusal('REPAIR_NOT_CONFIGURED', `plugins.json cannot be read: ${error.message}`, 'Ask the human to fix plugins.json.'); }
383
+ const entry = source.entries.find(item => item.id === id);
384
+ const live = this.runtime.status().plugins.find(item => item.id === id);
385
+ if (!entry || !live || live.builtin) throw refusal('REPAIR_NOT_CONFIGURED', `Plugin ${id} is not a configured local plugin of this project.`, 'Use PluginStatus to see the configured plugins.');
386
+ const contract = this.runtime.contract(id);
387
+ if (!contract || live.state !== 'active') throw refusal('REPAIR_NOT_LIVE', `Plugin ${id} is not active, so it has no live contract to keep.`, 'Enable the plugin first; a repair starts from the plugin that is serving.');
388
+ // A contract that cannot be stated cannot be kept: there is nothing for the house to compare a repair with.
389
+ if (contract.invalid) throw refusal('REPAIR_CONTRACT_INVALID', `The contract of plugin ${id} cannot be stated (${contract.invalid}), so the house cannot tell whether a repair keeps it.`, 'Tell the human: the plugin\'s author must fix its cases or its operation schemas (plain JSON only) before it can be repaired by contract.');
390
+ return { source, entry, live, contract };
391
+ }
392
+
393
+ /**
394
+ * call(operation, input): the live operation through the model's house binding, as a tool call of the
395
+ * model nested under the repair operation. { status: 'returned', output } | { status: 'threw', code,
396
+ * message } | { status: 'not_run', reason }, each with the sealed receipt reference when there is one.
397
+ */
398
+ async #call(name, input, options) {
399
+ const parent = options?.invocation_id, seen = [];
400
+ if (parent) this.#watch.set(parent, seen);
401
+ let outcome;
402
+ try { outcome = { status: 'returned', output: await this.binding.execute(name, structuredClone(input), { signal: options?.signal }) }; }
403
+ catch (error) {
404
+ if (options?.signal?.aborted) throw error;
405
+ const receipt = seen.findLast(row => row.operation === name);
406
+ const notRun = receipt ? receipt.status === 'refused' : NOT_RUN.test(String(error?.code ?? ''));
407
+ outcome = notRun ? { status: 'not_run', reason: String(error?.message ?? error).slice(0, 2000), ...(error?.code ? { code: error.code } : {}) } : { status: 'threw', code: typeof error?.code === 'string' ? error.code : undefined, message: String(error?.message ?? error).slice(0, 2000) };
408
+ } finally { if (parent) this.#watch.delete(parent); }
409
+ const receipt = seen.findLast(row => row.operation === name);
410
+ return { ...outcome, receipt: ref(receipt) };
411
+ }
412
+
413
+ async reproduce({ plugin, case: proposed }, options) {
414
+ const session = this.#caller(options);
415
+ const { contract, live } = await this.#configured(plugin);
416
+ let item;
417
+ try { [item] = validateCases([proposed], contract.surface.operations.filter(op => op.audience !== 'human').map(op => op.name)); }
418
+ catch (error) { throw refusal('REPAIR_CASE_INVALID', error.message, 'State the case as { name, operation, input, expect } naming one of the plugin\'s operations.'); }
419
+ const digest = caseDigest(item), result = await this.#call(`plugin__${plugin}__${item.operation}`, item.input, options);
420
+ const verdict = result.status === 'not_run' ? { pass: false, reason: `Not run: ${result.reason}` } : evaluateCase(item, result);
421
+ const shown = { status: result.status, ...(result.status === 'returned' ? { output: result.output } : result.status === 'threw' ? { code: result.code, message: result.message } : { reason: result.reason }), pass: verdict.pass, ...(verdict.reason ? { reason: verdict.reason } : {}) };
422
+ // Only a case that ran and failed is a broken promise. One that passes, or that the house could not
423
+ // run (a permission refusal, plan mode), reproduces nothing and is not remembered.
424
+ const reproduced = result.status !== 'not_run' && !verdict.pass;
425
+ let finding;
426
+ if (reproduced) {
427
+ const recorder = this.#recorder();
428
+ finding = await recorder?.emit({ owner: `plugin:${plugin}`, owner_basis: 'rule:case_failed', kind: 'contract_mismatch', code: 'CASE_FAILED',
429
+ where: { operation: `plugin__${plugin}__${item.operation}`, case: item.name, case_digest: digest }, evidence: result.receipt ? [result.receipt] : [] });
430
+ if (finding) recorder.hold(finding.id);
431
+ if (!session.reproduced.has(plugin)) session.reproduced.set(plugin, new Map());
432
+ const known = session.reproduced.get(plugin).get(digest);
433
+ session.reproduced.get(plugin).set(digest, { case: item, digest, fingerprint: live.code_fingerprint, receipt: result.receipt, finding: known?.finding ?? finding?.id ?? null, findings: [...(known?.findings ?? []), ...(finding ? [finding.id] : [])] });
434
+ }
435
+ await this.#row({ type: 'plugin_case_reproduced', plugin, case: item.name, case_digest: digest, operation: item.operation, status: result.status, pass: verdict.pass, ...(verdict.reason ? { reason: verdict.reason.slice(0, 2000) } : {}), receipt: result.receipt, ...(finding ? { finding: finding.id } : {}) }, options);
436
+ return { reproduced, result: shown, ...(finding ? { finding: finding.id } : {}), case_digest: digest,
437
+ next_action: reproduced ? `The promise is broken and recorded. Open a candidate with PluginRepairOpen {"plugin":"${plugin}"}, fix the code and add this case to the plugin's cases.` : result.status === 'not_run' ? 'The house could not run the case; nothing was reproduced.' : 'The live plugin keeps this promise; there is nothing to repair for this case.' };
438
+ }
439
+
440
+ // The state directory by its real path, resolved once: overlays are recorded by real path (the inspector
441
+ // resolves modules), and every comparison with them must be on the same footing.
442
+ #stateReal;
443
+ get #state() { return this.#stateReal ??= realLocation(path.resolve(this.cwd, this.stateDir ?? defaultStateDir(this.cwd))); }
444
+
445
+ // No other Accept may hold this plugin: a marker whose owner is alive (another Comal process) refuses; one
446
+ // left by a dead process is recovered here as at startup, and what it restored is found by the base checks.
447
+ async #markerFree(plugin, options) {
448
+ const project = this.runtime.file, own = pendingMarker(this.#state, plugin), legacy = path.join(this.#state, PENDING);
449
+ for (const file of [own, legacy]) {
450
+ let result = null;
451
+ try { result = await recoverMarker(file, { stateDir: this.#state, pluginsFile: project, trustStore: this.trustStore, completed: this.completed, ...(file === own ? { expectPlugin: plugin } : {}) }); }
452
+ catch (error) { throw refusal('REPAIR_BUSY_ELSEWHERE', `An interrupted repair of plugin ${plugin} could not be recovered: ${error.message}`, 'Report the reason; restart Comal to recover it.'); }
453
+ if (result) await this.#row({ type: 'plugin_repair_recovered', ...result }, options).catch(() => {});
454
+ let held = null;
455
+ try { held = JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') continue; held = {}; }
456
+ // The previous release's single file names one plugin of one plugins.json; another's does not concern this Accept.
457
+ if (file === legacy && (held?.plugin !== plugin || typeof held.plugins_file !== 'string' || realLocation(held.plugins_file) !== realLocation(project))) continue;
458
+ throw refusal('REPAIR_BUSY_ELSEWHERE', `Another Comal process is accepting a repair of plugin ${plugin} (pid ${held?.owner?.pid ?? 'unknown'}).`, 'Wait for it to finish, then reproduce the case again: the plugin may already be repaired.');
459
+ }
460
+ }
461
+ #writes(options) { if (options?.mode === 'plan') throw refusal('REPAIR_PLAN_MODE', 'Plan mode is read-only: a repair changes files and the running plugin.', 'Leave plan mode to repair the plugin.'); }
462
+ #candidate(session, candidateId) {
463
+ const state = session.open.get(candidateId);
464
+ if (!state) throw refusal('REPAIR_UNKNOWN_CANDIDATE', `No open candidate ${candidateId} in this session.`, 'Use the candidate id that PluginRepairOpen returned, or open a new one.');
465
+ return state;
466
+ }
467
+
468
+ async open({ plugin }, options) {
469
+ const session = this.#caller(options); this.#writes(options);
470
+ const reproduced = session.reproduced.get(plugin);
471
+ if (!reproduced?.size) throw refusal('REPAIR_NOTHING_REPRODUCED', `No broken promise of plugin ${plugin} was reproduced in this session.`, 'State the failing use case with PluginRepairReproduce first; a repair is built on a case the house saw fail.');
472
+ const already = [...session.open.values()].find(item => item.plugin === plugin);
473
+ if (already) throw refusal('REPAIR_ALREADY_OPEN', `Plugin ${plugin} already has an open candidate (${already.candidateId}).`, 'Test, accept or discard that candidate first.', { candidate: already.candidateId, directory: already.root });
474
+ if (typeof this.policy?.addCandidateRoot !== 'function') throw refusal('REPAIR_UNAVAILABLE', 'This session cannot open a candidate write root.', 'Repair is not available here.');
475
+ const { source, entry, live } = await this.#configured(plugin);
476
+ this.#fresh(session, plugin, live, [...reproduced.values()]);
477
+ let candidate;
478
+ try { candidate = await openCandidate({ cwd: this.cwd, stateDir: this.#state, entry }); }
479
+ catch (error) { throw refusal('REPAIR_OPEN_FAILED', `The candidate could not be opened: ${error.message}`, 'Report the reason; the plugin was not changed.', { workbench_code: error.code ?? null }); }
480
+ const drop = () => discardCandidate(candidate, { cwd: this.cwd, stateDir: this.#state }).catch(() => {});
481
+ // The copy must be the code that is serving: a plugin whose files changed since it loaded is not.
482
+ if (candidate.base.fingerprint !== live.code_fingerprint) { await drop(); throw refusal('REPAIR_BASE_MOVED', `The files of plugin ${plugin} changed since it was loaded; the copy would not be the live plugin.`, `Reload the plugin (/plugins reload ${plugin}) and reproduce the case again.`); }
483
+ let root;
484
+ try { root = (await this.policy.addCandidateRoot(candidate.tree, { container: path.join(this.#state, 'plugin-candidates') })).path; }
485
+ catch (error) { await drop(); throw refusal('REPAIR_OPEN_FAILED', `The candidate tree could not be opened for writing: ${error.message}`, 'Report the reason; the plugin was not changed.'); }
486
+ const state = { plugin, candidateId: candidate.candidateId, seal: candidate.seal, pluginsHash: source.hash, liveFingerprint: live.code_fingerprint, liveModule: live.module, root, dir: candidate.dir, tree: candidate.tree, entry: path.relative(candidate.tree, candidate.module) };
487
+ session.open.set(candidate.candidateId, state);
488
+ // A discard released the findings; a new candidate holds them again until it is accepted or discarded.
489
+ const recorder = this.#recorder();
490
+ for (const item of reproduced.values()) for (const id of item.findings) recorder?.hold?.(id);
491
+ const files = Object.keys(candidate.base.files).sort(), hostModules = Object.keys(candidate.host_modules ?? {}).length;
492
+ await this.#row({ type: 'plugin_repair_opened', plugin, candidate: candidate.candidateId, seal: candidate.seal, plugins_json_sha256: source.hash, live_fingerprint: live.code_fingerprint, base_module: candidate.base.module, entry: state.entry, files, host_modules: hostModules, write_root: root }, options);
493
+ return { candidate: candidate.candidateId, directory: root, module: path.join(root, state.entry), files, host_modules: hostModules,
494
+ note: hostModules ? `Only the plugin's own files (listed in files, under ${path.dirname(path.join(root, state.entry))}) can be repaired. The other ${hostModules} file(s) it imports are host modules: links to the installed files, shared with the house. They cannot be edited, replaced or added to here.` : 'Every file of this plugin is its own; there are no host modules.',
495
+ cases_to_add: [...reproduced.values()].map(item => item.case),
496
+ next_action: `Fix the plugin inside ${root} only, and add every case in cases_to_add to the plugin definition's cases unchanged. Then run PluginRepairTest, and PluginRepairAccept when it passes.` };
497
+ }
498
+
499
+ // A reproduced case is evidence about the generation it failed on. Cases from a generation that is no
500
+ // longer live are forgotten (their findings released) and the repair is refused until reproduced again.
501
+ #fresh(session, plugin, live, items) {
502
+ const stale = items.filter(item => item.fingerprint !== live.code_fingerprint);
503
+ if (!stale.length) return;
504
+ const recorder = this.#recorder(), known = session.reproduced.get(plugin);
505
+ for (const item of stale) { if (known?.get(item.digest) === item) known.delete(item.digest); for (const id of item.findings) recorder?.release?.(id); }
506
+ if (known && !known.size) session.reproduced.delete(plugin);
507
+ throw refusal('REPAIR_REPRODUCTION_STALE', `The broken promise of plugin ${plugin} was reproduced on code that is no longer live (${stale.map(item => item.case.name).join(', ')}).`, 'Reproduce the case again on the live plugin with PluginRepairReproduce.');
508
+ }
509
+
510
+ /**
511
+ * Offer the repair operations while at least one configured local plugin is repairable (enabled in
512
+ * plugins.json and active or failed in this session, not a builtin), and withdraw them when none is.
513
+ */
514
+ async sync(registry) {
515
+ let repairable = false;
516
+ try {
517
+ const enabled = new Set((await readPluginConfig(this.runtime.file, { missing: true })).entries.filter(item => item.enabled !== false).map(item => item.id));
518
+ repairable = this.runtime.status().plugins.some(item => !item.builtin && enabled.has(item.id) && ['active', 'failed'].includes(item.state));
519
+ } catch {}
520
+ const definitions = this.operations();
521
+ if (repairable) { for (const definition of definitions) if (!registry.get(definition.name)) registry.add(definition); return true; }
522
+ const present = definitions.map(item => item.name).filter(name => registry.get(name));
523
+ if (present.length) registry.replace(present, []);
524
+ return false;
525
+ }
526
+
527
+ async #discardOpen(session, state, reason, options) {
528
+ this.policy?.removeCandidateRoot?.(state.root); session.open.delete(state.candidateId);
529
+ await discardCandidate({ id: state.plugin, candidateId: state.candidateId, dir: state.dir }, { cwd: this.cwd, stateDir: this.#state }).catch(() => {});
530
+ const recorder = this.#recorder();
531
+ for (const item of session.reproduced.get(state.plugin)?.values() ?? []) for (const id of item.findings) recorder?.release?.(id);
532
+ await this.#row({ type: 'plugin_repair_discarded', plugin: state.plugin, candidate: state.candidateId, reason }, options).catch(() => {});
533
+ }
534
+
535
+ /**
536
+ * /new, /resume and the end of the process: open candidates of the session are discarded. One whose
537
+ * Accept is running is waited for (at most waitMs); if it is still running, it is discarded when it ends.
538
+ */
539
+ async endSession(sessionId, { reason = 'session_end', waitMs = 10000 } = {}) {
540
+ const session = this.#sessions.get(sessionId); if (!session) return 0;
541
+ let count = 0;
542
+ for (const state of [...session.open.values()]) {
543
+ const running = this.#running.get(state.plugin);
544
+ if (running) { let timer; await Promise.race([running.catch(() => {}), new Promise(resolve => { timer = setTimeout(resolve, waitMs); })]); clearTimeout(timer); }
545
+ if (!session.open.has(state.candidateId)) continue; // the Accept took it
546
+ if (this.#busy.has(state.plugin)) { state.discardOnEnd = reason; continue; }
547
+ await this.#discardOpen(session, state, reason); count++;
548
+ }
549
+ return count;
550
+ }
551
+
552
+ async #readCandidate(state) {
553
+ try { return await readCandidate({ cwd: this.cwd, stateDir: this.#state, id: state.plugin, candidateId: state.candidateId, seal: state.seal }); }
554
+ catch (error) { throw refusal('REPAIR_CANDIDATE_REFUSED', `The candidate was refused: ${error.message}`, 'Discard this candidate and open a new one.', { workbench_code: error.code ?? null }); }
555
+ }
556
+
557
+ // Advice for the model: the candidate's own account, with each case evaluated by the house on the
558
+ // output the candidate reported. PluginRepairAccept never reads any of it.
559
+ async test({ candidate: candidateId }, options) {
560
+ const session = this.#caller(options); this.#writes(options);
561
+ const state = this.#candidate(session, candidateId), candidate = await this.#readCandidate(state);
562
+ const { entry, contract: live } = await this.#configured(state.plugin);
563
+ const run = this.runner ?? runCandidate, prefix = `plugin__${state.plugin}__`;
564
+ // A replay performs the operation for real, so it is judged like the live operation it replaces.
565
+ const judge = async ({ operation, input }) => { try { await this.binding.judge(operation, input, { signal: options?.signal }); return true; } catch { return false; } };
566
+ // The cases are the candidate's: known only once it has activated, so a first run lists them.
567
+ // The run is the operation's: an interrupted turn aborts it and its process group is killed.
568
+ // The child has no house: the house's own services are stood in by their data (see the runner).
569
+ const standIns = hostServiceStandIns(this.runtime), base = { config: entry.config, services: standIns.services, serviceFunctions: standIns.functions, timeoutMs: this.timeoutMs, signal: options?.signal };
570
+ const first = await run(candidate, base);
571
+ const listed = first.outcome === 'loaded' ? first.cases : [];
572
+ // A case runs for real wherever it runs, and Accept runs every case on the published plugin. One whose
573
+ // operation writes outside (effects.external 'write', as the live plugin or the candidate declares it)
574
+ // is left to Accept, so its effect happens once.
575
+ const short = name => name.startsWith(prefix) ? name.slice(prefix.length) : name;
576
+ const liveEffects = new Map(live.surface.operations.map(op => [op.name, op.effects])), candidateEffects = new Map((first.operations ?? []).map(op => [short(op.name), op.effects]));
577
+ const external = item => [liveEffects.get(item.operation), candidateEffects.get(item.operation)].some(effects => effects?.external === 'write');
578
+ const runnable = listed.filter(item => !external(item));
579
+ const report = first.outcome === 'loaded' && runnable.length ? await run(candidate, { ...base, judge, replay: runnable.map(item => ({ operation: item.operation, input: item.input, want_output: true })) }) : first;
580
+ const cases = listed.map(item => {
581
+ if (external(item)) return { name: item.name, skipped: 'external effects' };
582
+ const replay = report === first ? undefined : report.replays[runnable.indexOf(item)];
583
+ // A case that needs the house itself says nothing about the candidate here.
584
+ if (replay?.outcome === 'threw' && replay.code === 'PLUGIN_PREFLIGHT_UNAVAILABLE') return { name: item.name, skipped: 'house service not available in the pre-flight' };
585
+ let verdict;
586
+ if (!replay) verdict = { pass: false, reason: `Not run: the candidate run ended ${report.outcome}.` };
587
+ else if (replay.outcome === 'returned' && replay.output === undefined) verdict = { pass: false, reason: `The candidate did not report the output (${replay.output_omitted ?? 'not_reported'}).` };
588
+ else verdict = evaluateCase(item, replay.outcome === 'returned' ? { status: 'returned', output: replay.output } : replay.outcome === 'threw' ? { status: 'threw', code: replay.code, message: replay.error } : { status: 'not_run', reason: replay.outcome === 'not_permitted' ? 'the house did not permit the operation' : String(replay.outcome) });
589
+ return { name: item.name, pass: verdict.pass, ...(verdict.reason ? { reason: verdict.reason } : {}) };
590
+ });
591
+ let diff = null;
592
+ try { const full = await candidateDiff(candidate); diff = { changed: full.changed, added: full.added, removed: full.removed, host_modules: full.host_modules, review_complete: full.review_complete, unreviewed: full.unreviewed, fingerprint: full.fingerprint }; }
593
+ catch (error) { diff = { error: error.message, code: error.code ?? null }; }
594
+ // What the candidate reports covers only its operations' name, description, schemas, effects and
595
+ // audience and its cases; the rest of the live surface is assumed unchanged here, never at accept.
596
+ let contract = null;
597
+ if (first.outcome === 'loaded') {
598
+ const was = new Map(live.surface.operations.map(op => [op.name, op]));
599
+ const operations = report.operations.map(op => { const name = op.name.startsWith(prefix) ? op.name.slice(prefix.length) : op.name; const base = was.get(name) ?? {}; return { ...base, name, description: op.description, input_schema: op.input_schema, output_schema: op.output_schema ?? null, effects: op.effects ?? base.effects ?? null, audience: op.audience }; });
600
+ contract = { ...compareContracts(live, { surface: { ...live.surface, operations }, cases: listed.map(item => ({ name: item.name, digest: caseDigest(item) })) }), reported_by: 'candidate', covers: ['operations', 'cases'] };
601
+ }
602
+ const missing = [...(session.reproduced.get(state.plugin)?.values() ?? [])].filter(item => !listed.some(c => caseDigest(c) === item.digest)).map(item => item.case.name);
603
+ await this.#row({ type: 'plugin_repair_tested', plugin: state.plugin, candidate: candidateId, outcome: report.outcome, trust: report.trust, fingerprint: report.fingerprint ?? null, cases: cases.map(({ name, pass, skipped }) => skipped ? { name, skipped } : { name, pass }), contract_kept: contract?.kept ?? null, reproduced_missing: missing, ...(report.unavailable_services?.length ? { unavailable_services: report.unavailable_services } : {}) }, options);
604
+ const skipped = cases.filter(item => item.skipped).length;
605
+ return { outcome: report.outcome, trust: report.trust, ...(report.error ? { error: report.error } : {}), ...(report.code ? { code: report.code } : {}), ...(report.unavailable_services?.length ? { unavailable_services: report.unavailable_services } : {}), stderr_tail: report.stderr_tail, cases, diff, contract,
606
+ ...(missing.length ? { reproduced_cases_missing: missing } : {}), checks: report.checks, dynamic: report.dynamic, packages: report.packages ?? [],
607
+ next_action: report.outcome === 'aborted' ? 'The pre-flight was interrupted; nothing was learned about the candidate.'
608
+ : report.outcome === 'not_judged' ? `The pre-flight cannot judge this candidate: activating it needs ${(report.unavailable_services ?? []).join(', ') || 'a service'}, which the separate process does not have. Nothing was learned about the candidate here, and nothing is wrong with it for that. Accept with PluginRepairAccept {"candidate":"${candidateId}"}: the house loads it in this session, verifies the contract and runs every case itself, and restores the previous plugin if any of that fails.`
609
+ : report.outcome !== 'loaded' ? 'The candidate did not load in the separate process; fix it before accepting.' : missing.length ? 'Add the reproduced cases to the candidate\'s cases unchanged.' : cases.every(item => item.pass || item.skipped) ? `${skipped ? `${skipped} case(s) were skipped here (see cases): one with external effects is run once, for real, by Accept, and one that needs the house cannot run in a separate process. ` : ''}${report.dynamic?.length || report.packages?.length ? 'The changed files hold code the house cannot follow by reading it (see dynamic and packages): Accept will need an act signed by the human unless you remove it. ' : ''}This is the candidate's own account. Accept with PluginRepairAccept {"candidate":"${candidateId}"}: the house checks the contract and runs the cases itself.` : 'Some cases fail in the candidate; fix them before accepting.' };
610
+ }
611
+
612
+ // The overlay at <stateDir>/plugin-overlays/<id>/<fingerprint>: exactly the own files verification hashed,
613
+ // copied; exactly the candidate's host modules, linked to the installed files (never copied, so the
614
+ // repaired plugin shares the host's module instances); what open placed beside the candidate's tree
615
+ // (package.json, the node_modules link); and the patch. Built or found from an earlier repair, it is
616
+ // verified whole before it is used (verifyPlacement): anything else in it, or anything missing, refuses.
617
+ async #overlay(state, candidate, verified, patch) {
618
+ const mismatch = message => refusal('REPAIR_COPY_MISMATCH', `The overlay copy is not the verified candidate: ${message}`, 'Discard this candidate and open a new one.');
619
+ const root = path.join(this.#state, 'plugin-overlays'), home = path.join(root, state.plugin);
620
+ await mkdir(home, { recursive: true, mode: 0o700 });
621
+ for (const dir of [root, home]) { const info = await lstat(dir); if (info.isSymbolicLink() || !info.isDirectory()) throw mismatch(`${dir} is not a plain directory`); }
622
+ const final = path.join(home, verified.fingerprint), entry = state.entry.split(path.sep), hostModules = candidate.host_modules ?? {};
623
+ const place = dir => ({ dir, tree: path.join(dir, 'tree'), module: path.join(dir, 'tree', ...entry), host_modules: hostModules, node_modules: candidate.node_modules ?? null, package_json: candidate.package_json });
624
+ const same = async dir => {
625
+ try { await verifyPlacement(place(dir), { files: verified.files, fingerprint: verified.fingerprint }); }
626
+ catch (error) { throw mismatch(error.message); }
627
+ // The identity trust is recorded for: the inspector's own reading, which hashes host modules at their real paths.
628
+ return inspectPluginModule(place(dir).module);
629
+ };
630
+ let present = null;
631
+ try { present = await lstat(final); } catch (error) { if (error.code !== 'ENOENT') throw error; }
632
+ if (present) {
633
+ // The same code was accepted before: reuse it only when it is, whole, what was verified now.
634
+ if (present.isSymbolicLink() || !present.isDirectory()) throw mismatch(`${final} is not a plain directory`);
635
+ let code;
636
+ try { code = await same(final); }
637
+ catch (error) { throw error.code === 'REPAIR_COPY_MISMATCH' ? error : mismatch(error.message); }
638
+ // The patch kept beside it is this repair's, so it matches the accepted row's patch_sha256.
639
+ await writeAtomic(path.join(final, 'repair.patch'), patch);
640
+ return { dir: final, code, created: false };
641
+ }
642
+ const incoming = path.join(home, `.incoming-${randomUUID()}`);
643
+ try {
644
+ await mkdir(path.join(incoming, 'tree'), { recursive: true, mode: 0o700 });
645
+ for (const [relative, hash] of Object.entries(verified.files).sort(([a], [b]) => a < b ? -1 : 1)) {
646
+ const source = path.join(candidate.tree, ...relative.split('/')), info = await lstat(source);
647
+ if (!info.isFile()) throw mismatch(`${relative} is no longer a regular file`);
648
+ const bytes = await readFile(source);
649
+ if (sha256(bytes) !== hash) throw mismatch(`${relative} changed after verification`);
650
+ const target = path.join(incoming, 'tree', ...relative.split('/'));
651
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
652
+ await writeFile(target, bytes, { mode: 0o600, flag: 'wx' });
653
+ }
654
+ for (const [relative, host] of Object.entries(hostModules).sort(([a], [b]) => a < b ? -1 : 1)) {
655
+ const link = path.join(incoming, 'tree', ...relative.split('/'));
656
+ await mkdir(path.dirname(link), { recursive: true, mode: 0o700 });
657
+ await symlink(host.target, link, 'file');
658
+ }
659
+ const packageJson = await readFile(path.join(candidate.dir, 'package.json'));
660
+ if (sha256(packageJson) !== candidate.package_json) throw mismatch('package.json beside the tree changed');
661
+ await writeFile(path.join(incoming, 'package.json'), packageJson, { mode: 0o600, flag: 'wx' });
662
+ if (candidate.node_modules) await symlink(candidate.node_modules, path.join(incoming, 'node_modules'), 'dir');
663
+ await writeFile(path.join(incoming, 'repair.patch'), patch, { mode: 0o600, flag: 'wx' });
664
+ await same(incoming);
665
+ await rename(incoming, final);
666
+ return { dir: final, code: await same(final), created: true };
667
+ } catch (error) {
668
+ await rm(incoming, { recursive: true, force: true }).catch(() => {});
669
+ throw error.code === 'REPAIR_COPY_MISMATCH' ? error : mismatch(error.message);
670
+ }
671
+ }
672
+
673
+ async accept({ candidate: candidateId, reason }, options) {
674
+ const session = this.#caller(options); this.#writes(options);
675
+ const state = this.#candidate(session, candidateId);
676
+ if (this.#busy.has(state.plugin)) throw refusal('REPAIR_BUSY', `A repair of plugin ${state.plugin} is already being accepted.`, 'Wait for it to finish.');
677
+ this.#busy.add(state.plugin);
678
+ const running = this.#accept(session, state, reason, options);
679
+ this.#running.set(state.plugin, running);
680
+ try { return await running; }
681
+ finally {
682
+ this.#busy.delete(state.plugin); this.#running.delete(state.plugin);
683
+ // The session ended while this ran: the candidate goes now, unless the Accept already took it.
684
+ if (state.discardOnEnd && session.open.has(state.candidateId)) await this.#discardOpen(session, state, state.discardOnEnd);
685
+ }
686
+ }
687
+
688
+ // Whether the generation serving `id` is exactly the overlay's code (the runtime committed it).
689
+ #serving(id, code) {
690
+ const item = this.runtime.status().plugins.find(plugin => plugin.id === id);
691
+ return item?.state === 'active' && item.module === code.module && item.code_fingerprint === code.code_fingerprint;
692
+ }
693
+
694
+ async #accept(session, state, reason, options) {
695
+ const { plugin } = state, project = this.runtime.file, prefix = `plugin__${plugin}__`, marker = pendingMarker(this.#state, plugin), token = randomUUID();
696
+ await this.#markerFree(plugin, options);
697
+ // 1. What is reviewed is what loads: the sealed metadata, then containment, then the patch.
698
+ const candidate = await this.#readCandidate(state);
699
+ let verified, diff;
700
+ try { verified = await verifyCandidate(candidate); diff = await candidateDiff(candidate); }
701
+ catch (error) { throw refusal('REPAIR_CANDIDATE_REFUSED', `The candidate was refused: ${error.message}`, 'Fix what the reason names inside the candidate tree, or discard it.', { workbench_code: error.code ?? null }); }
702
+ // What is reviewed is what loads only as far as the house can follow the code by reading it. A changed or
703
+ // added file with a construct static inspection cannot follow (a computed import(), require, eval…), or
704
+ // that imports a package the base did not import, may load code outside the fingerprint that anyone can
705
+ // change later: such a repair is never accepted on the contract alone; it needs the human's act.
706
+ const unfollowed = [...verified.dynamic.map(item => ({ file: item.file, construct: item.construct })), ...(verified.packages ?? []).map(item => ({ file: item.file, construct: `package ${item.specifier}` }))].slice(0, 200);
707
+ // 2. The base is the one the candidate was opened on, and the reproduced cases failed on it.
708
+ const { entry, live } = await this.#configured(plugin);
709
+ const moved = () => refusal('REPAIR_BASE_MOVED', `plugins.json or the live plugin ${plugin} changed since the candidate was opened.`, 'Discard this candidate, reproduce the case again and open a new one.');
710
+ const previousBytes = await readFile(project);
711
+ if (sha256(previousBytes) !== state.pluginsHash || live.code_fingerprint !== state.liveFingerprint) throw moved();
712
+ // A candidate that is the live code, file for file, repairs nothing: no overlay, no publish, no trust.
713
+ if (verified.fingerprint === (candidate.base.logical ?? candidate.base.fingerprint)) throw refusal('REPAIR_NO_CHANGE', `The candidate of plugin ${plugin} is identical to the live code.`, 'Change the plugin inside the candidate tree (fix the code and add the reproduced case), then accept again.');
714
+ // Only the cases known now are this repair's; one reproduced while it runs stays for later.
715
+ const reproduced = [...(session.reproduced.get(plugin)?.values() ?? [])];
716
+ this.#fresh(session, plugin, live, reproduced);
717
+ // 3. The overlay: a copy of the verified bytes, never the candidate tree itself.
718
+ const overlay = await this.#overlay(state, candidate, verified, diff.patch);
719
+ const dropOverlay = async () => { if (overlay.created) await rm(overlay.dir, { recursive: true, force: true }).catch(() => {}); };
720
+ const nextEntry = { ...entry, module: overlay.code.module };
721
+ const expect = { module: overlay.code.module, code_fingerprint: overlay.code.code_fingerprint };
722
+ // 4. A pending marker first (a crash from here on is undone at the next start), then plugins.json.
723
+ // No trust is recorded for this code until the house has run its cases (step 8).
724
+ const raw = JSON.parse(previousBytes.toString('utf8')), index = raw.plugins.findIndex(item => item?.id === plugin);
725
+ // repaired_from always names the human's original: a repair of a repair keeps it, so a revert goes back there.
726
+ const originalRaw = raw.plugins[index].repaired_from ?? raw.plugins[index].module, originalModule = path.resolve(path.dirname(project), originalRaw);
727
+ raw.plugins[index] = { ...raw.plugins[index], module: overlay.code.module, repaired_from: originalRaw };
728
+ let written = sha256(JSON.stringify(raw, null, 2) + '\n');
729
+ // A repair never grants more standing than the code it replaces had. Its trust is recorded only on top of
730
+ // a record for exactly that code (module, fingerprint and configuration: the predicate of codeTrusted in
731
+ // plugin-controls.mjs; a contract_repair record for the previous overlay passes it, so repairs chain). A
732
+ // plugin serving only by --trust-plugins or a session-only enable has none, and its repair gets none.
733
+ const recorded = this.trustStore?.find?.(project, plugin);
734
+ const priorTrust = recorded && recorded.module === live.module && recorded.code_fingerprint === live.code_fingerprint && recorded.config_sha256 === configDigest(entry.config) ? recorded : null;
735
+ const pending = { version: 1, plugin, candidate: state.candidateId, token, plugins_file: project, previous_bytes: previousBytes.toString('base64'), previous_sha256: sha256(previousBytes), overlay: overlay.dir, overlay_fingerprint: overlay.code.code_fingerprint,
736
+ previous_trust: priorTrust, session: options?.sessionId ?? null, owner: processIdentity, at: new Date().toISOString() };
737
+ // The marker is this Accept's while it holds this token and this process's identity: only then is it
738
+ // rewritten or removed, so a marker another process created is never touched.
739
+ const ownsMarker = async () => { try { const now = JSON.parse(await readFile(marker, 'utf8')); return now?.token === token && sameOwner(now.owner, processIdentity); } catch { return false; } };
740
+ const updateMarker = async extra => { if (await ownsMarker()) await writeAtomic(marker, JSON.stringify({ ...pending, written_sha256: written, ...extra })); };
741
+ const removeMarker = async () => { if (await ownsMarker()) await rm(marker, { force: true }).catch(() => {}); };
742
+ try {
743
+ await mkdir(path.dirname(marker), { recursive: true, mode: 0o700 });
744
+ const holder = await lstat(path.dirname(marker));
745
+ if (holder.isSymbolicLink() || !holder.isDirectory()) throw new Error(`${path.dirname(marker)} is not a plain directory`);
746
+ let handle;
747
+ // Created exclusively: two processes can never both hold the plugin.
748
+ try { handle = await openFile(marker, 'wx', 0o600); }
749
+ catch (error) { if (error.code === 'EEXIST') throw refusal('REPAIR_BUSY_ELSEWHERE', `Another Comal process is accepting a repair of plugin ${plugin}.`, 'Wait for it to finish, then reproduce the case again: the plugin may already be repaired.', { overlay_left: overlay.created }); throw error; }
750
+ try { await handle.writeFile(JSON.stringify({ ...pending, written_sha256: written })); await handle.sync(); } finally { await handle.close(); }
751
+ if (sha256(await readFile(project)) !== state.pluginsHash) throw moved();
752
+ const checked = await writePluginConfigRaw(project, raw);
753
+ if (checked.hash !== written) { written = checked.hash; await updateMarker(); }
754
+ } catch (error) {
755
+ let current = null; try { current = await readFile(project); } catch {}
756
+ const mine = await ownsMarker();
757
+ if (mine && current && sha256(current) === written) await writeAtomic(project, previousBytes, 0o644).catch(() => {});
758
+ await removeMarker();
759
+ // Lost the race for the marker: the other Accept may be using this very overlay (the same fix), so it stays.
760
+ if (error.code !== 'REPAIR_BUSY_ELSEWHERE') await dropOverlay();
761
+ throw error.code?.startsWith?.('REPAIR_') ? error : refusal('REPAIR_LOAD_FAILED', `plugins.json could not be prepared: ${error.message}`, 'Report the reason; nothing was changed.');
762
+ }
763
+
764
+ // 5. Prepare, admit by contract, publish.
765
+ let admission = { kind: 'none' }, trust = { recorded: false }, previousTrust;
766
+ const files = [...diff.changed, ...diff.added, ...diff.removed];
767
+ const admit = async (staged, { staged: activation, retiring }) => {
768
+ // A repair publishes exactly its plugin and retires only its previous generation.
769
+ if (activation.some(item => item.id !== plugin) || retiring.some(id => id !== plugin)) { admission = { kind: 'scope', staged: activation.map(item => item.id), retiring: [...retiring] }; return false; }
770
+ // A staged contract that cannot be stated (malformed cases, a surface that is not plain JSON) loads, but
771
+ // there is nothing to compare or to show the human: it is never admitted as a repair.
772
+ if (staged?.invalid) { admission = { kind: 'contract_invalid', reason: String(staged.invalid).slice(0, 2000) }; return false; }
773
+ const before = this.runtime.contract(plugin), comparison = compareContracts(before, staged);
774
+ const digests = new Set(staged.cases.map(item => item.digest)), missing = reproduced.filter(item => !digests.has(item.digest)).map(item => item.case.name);
775
+ if (missing.length) { admission = { kind: 'case_missing', missing, comparison }; return false; }
776
+ const contracts = { before: summary(before), after: summary(staged) };
777
+ if (comparison.kept && !unfollowed.length) { admission = { kind: 'kept', comparison, ...contracts }; return true; }
778
+ // An act answers exactly one change, from this contract to that one, once; when it covers code the house
779
+ // cannot follow, it answers exactly this code.
780
+ const code = unfollowed.length ? verified.fingerprint : null;
781
+ const signed = session.acts.find(act => !act.consumed && act.plugin === plugin && sameContract(act.before, contracts.before) && sameContract(act.after, contracts.after) && (act.code ?? null) === code);
782
+ if (signed) { signed.consumed = true; admission = { kind: 'act', act: signed.row, comparison, unfollowed, ...contracts }; return true; }
783
+ if (typeof this.confirm !== 'function') { admission = { kind: 'needs_act', human: 'absent', comparison, unfollowed, ...contracts }; return false; }
784
+ let card = actCard({ plugin, comparison, reason, files, before, after: staged, unfollowed });
785
+ if (card.truncated) {
786
+ // The full contracts beside the overlay, named on the card.
787
+ const saved = { before: path.join(overlay.dir, 'contract-before.json'), after: path.join(overlay.dir, 'contract-after.json') };
788
+ try { await writeAtomic(saved.before, JSON.stringify(before, null, 2)); await writeAtomic(saved.after, JSON.stringify(staged, null, 2)); card = actCard({ plugin, comparison, reason, files, before, after: staged, saved, unfollowed }); } catch {}
789
+ }
790
+ let yes = false;
791
+ try { yes = (await this.confirm('PluginContractAct', { plugin, surface_changed: comparison.surface_changed, cases_removed: comparison.cases_removed, cases_changed: comparison.cases_changed, cases_added: comparison.cases_added, unfollowed, reason: reason ?? null, files, before: contracts.before, after: contracts.after }, { summary: card.text })) === true; }
792
+ catch { yes = false; }
793
+ if (!yes) { admission = { kind: 'needs_act', human: 'declined', comparison, unfollowed, ...contracts }; return false; }
794
+ // The act is sealed before the generation is admitted; it stays in the journal whatever follows. Its
795
+ // record for the human's view (/plugins acts) is written first and removed if the row cannot be sealed,
796
+ // so the view never shows an act the journal does not hold, nor misses one it does.
797
+ const act = { type: 'plugin_contract_act', plugin, candidate: state.candidateId, before: contracts.before, after: contracts.after, surface_changed: comparison.surface_changed, cases_removed: comparison.cases_removed, cases_changed: comparison.cases_changed, cases_added: comparison.cases_added, unfollowed, ...(code ? { code_fingerprint: code } : {}), reason: reason ?? null, files, signed_by: 'human', signed_at: new Date().toISOString() };
798
+ const actFile = path.join(actsDir(this.#state), plugin, `${act.signed_at.replace(/[:.]/g, '-')}-${randomUUID()}.json`);
799
+ const actRecord = { version: 1, plugin, candidate: state.candidateId, session: options?.sessionId ?? null, signed_at: act.signed_at, signed_by: 'human', reason: act.reason, surface_changed: act.surface_changed, cases_removed: act.cases_removed, cases_changed: act.cases_changed, cases_added: act.cases_added, unfollowed, before: act.before, after: act.after };
800
+ try { await mkdir(path.dirname(actFile), { recursive: true, mode: 0o700 }); await writeAtomic(actFile, JSON.stringify({ ...actRecord, act: null }, null, 2) + '\n'); }
801
+ catch (error) { admission = { kind: 'needs_act', human: 'signed_unrecorded', error: error.message, comparison, unfollowed, ...contracts }; return false; }
802
+ let row;
803
+ try { row = await this.#row(act, options); }
804
+ catch (error) { await rm(actFile, { force: true }).catch(() => {}); admission = { kind: 'needs_act', human: 'signed_unrecorded', error: error.message, comparison, unfollowed, ...contracts }; return false; }
805
+ await writeAtomic(actFile, JSON.stringify({ ...actRecord, act: ref(row) }, null, 2) + '\n').catch(() => {});
806
+ session.acts.push({ plugin, before: contracts.before, after: contracts.after, code, row: ref(row), consumed: true });
807
+ admission = { kind: 'act', act: ref(row), comparison, unfollowed, ...contracts }; return true;
808
+ };
809
+ const previous = { module: live.module, code_fingerprint: live.code_fingerprint };
810
+ let cleanupError = null;
811
+ // The record of this repair for the human's view (repair.json beside repair.patch), written in step 8.
812
+ // A rollback after it puts back what was there (a reused overlay's earlier record) or removes it.
813
+ const recordFile = path.join(overlay.dir, REPAIR_RECORD);
814
+ let recordWritten = false, recordPrevious = null;
815
+ // Undo: plugins.json only if it is still what this repair wrote (never someone else's edit), trust if
816
+ // it was recorded, the previous generation if the repaired one was published, the overlay unless an
817
+ // edit still names it, and the marker once plugins.json is settled.
818
+ const rollback = async ({ published, stage, code, why, failing }) => {
819
+ const restored = { plugins_json: false, trust: true, generation: published ? 'reloaded' : 'kept' };
820
+ let current = null; try { current = await readFile(project); } catch {}
821
+ if (current && sha256(current) === written) { try { await writeAtomic(project, previousBytes, 0o644); restored.plugins_json = true; } catch (error) { restored.plugins_json_error = error.message; } }
822
+ else if (current && sha256(current) === sha256(previousBytes)) restored.plugins_json = true;
823
+ else restored.plugins_json = 'left_as_edited';
824
+ if (trust.recorded) { try { await this.trustStore.restore(project, plugin, previousTrust); } catch (error) { restored.trust = false; restored.trust_error = error.message; } }
825
+ if (published) {
826
+ // Not the turn's signal: an interrupted turn must still get its previous plugin back.
827
+ try { await this.runtime.load(plugin, { expect: previous }); }
828
+ catch (error) { restored.generation_error = error.message; try { await this.runtime.disable(plugin); restored.generation = 'disabled'; } catch (disable) { restored.generation = 'failed'; restored.disable_error = disable.message; } }
829
+ } else { try { await this.runtime.discover(); } catch {} }
830
+ if (recordWritten) { try { if (recordPrevious) await writeAtomic(recordFile, recordPrevious); else await rm(recordFile, { force: true }); } catch (error) { restored.record_error = error.message; } }
831
+ if (restored.plugins_json === 'left_as_edited' && current?.toString('utf8').includes(JSON.stringify(overlay.code.module).slice(1, -1))) restored.overlay = 'kept_referenced';
832
+ else await dropOverlay();
833
+ if (restored.plugins_json === true || restored.plugins_json === 'left_as_edited') await removeMarker();
834
+ else restored.pending_marker = 'kept';
835
+ await this.#row({ type: 'plugin_repair_rolled_back', plugin, candidate: state.candidateId, stage, code, reason: String(why).slice(0, 2000), ...(failing ? { failing_case: failing } : {}), ...(admission.act ? { act: admission.act } : {}), ...(admission.comparison ? { comparison: admission.comparison } : {}), ...(admission.unfollowed?.length ? { unfollowed: admission.unfollowed } : {}), ...(cleanupError ? { cleanup_error: cleanupError } : {}), restored }, options).catch(() => null);
836
+ return restored;
837
+ };
838
+ const settled = restored => restored.plugins_json === 'left_as_edited' ? 'plugins.json was edited by someone else meanwhile and was left as edited; the repaired code is not trusted.' : '';
839
+ try { await this.runtime.load(plugin, { expect, admit, signal: options?.signal }); }
840
+ catch (error) {
841
+ // A failure after the commit (the previous generation's cleanup) leaves the repaired code serving:
842
+ // that is a publish, and its cases decide. Anything else published nothing.
843
+ if (error?.code !== 'PLUGIN_NOT_ADMITTED' && this.#serving(plugin, overlay.code)) cleanupError = [error?.message ?? String(error), ...(Array.isArray(error?.errors) ? error.errors.map(item => item?.message ?? String(item)) : [])].join(': ').slice(0, 2000);
844
+ else {
845
+ const refusedBy = error?.code === 'PLUGIN_NOT_ADMITTED' ? admission.kind : null;
846
+ const code = { needs_act: 'REPAIR_NEEDS_ACT', case_missing: 'REPAIR_CASE_MISSING', scope: 'REPAIR_SCOPE', contract_invalid: 'REPAIR_CONTRACT_INVALID' }[refusedBy] ?? 'REPAIR_LOAD_FAILED';
847
+ const cannotFollow = code === 'REPAIR_NEEDS_ACT' && admission.comparison?.kept;
848
+ const why = cannotFollow ? `The repair of plugin ${plugin} holds code the house cannot follow by reading it (${admission.unfollowed.map(item => `${item.file}: ${item.construct}`).join(', ')}); it needs an act signed by the human (${admission.human}).`
849
+ : code === 'REPAIR_NEEDS_ACT' ? `The repair changes the contract of plugin ${plugin}; it needs an act signed by the human (${admission.human}).`
850
+ : code === 'REPAIR_CASE_MISSING' ? `The candidate does not carry the reproduced case(s): ${admission.missing.join(', ')}.`
851
+ : code === 'REPAIR_SCOPE' ? `Accepting would also publish or retire other plugins (staged: ${admission.staged.join(', ')}; retiring: ${admission.retiring.join(', ')}).`
852
+ : code === 'REPAIR_CONTRACT_INVALID' ? `The contract of the candidate cannot be stated (${admission.reason}), so the house cannot tell whether it keeps the live one.`
853
+ : `The repaired plugin did not load: ${error.message}`;
854
+ const restored = await rollback({ published: false, stage: refusedBy ? 'admit' : 'load', code, why });
855
+ const next = cannotFollow ? (admission.human === 'absent' ? 'No human is present to sign. Write the fix with literal relative imports of the plugin\'s own files and the packages it already imports (no computed import(), require, createRequire, eval or new Function, no new packages), or ask the human to accept it in an interactive session.' : 'The human did not sign. Write the fix without those constructs, or explain why they are needed and try again.')
856
+ : code === 'REPAIR_NEEDS_ACT' ? (admission.human === 'absent' ? 'No human is present to sign. Keep the contract (same operations and schemas, every existing case unchanged; only add cases), or ask the human to accept the change in an interactive session.' : 'The human did not sign the change. Keep the contract, or explain the change and try again.')
857
+ : code === 'REPAIR_CASE_MISSING' ? 'Add each reproduced case to the plugin definition\'s cases exactly as reproduced, then accept again.'
858
+ : code === 'REPAIR_SCOPE' ? 'Repair one plugin at a time; a repair cannot change its providers or consumers.'
859
+ : code === 'REPAIR_CONTRACT_INVALID' ? 'Fix the candidate\'s cases ({ name, operation, input, expect }, each naming one of its operations) and keep its schemas plain JSON, then accept again.'
860
+ : 'Fix the candidate (PluginRepairTest shows import and activation errors) and accept again.';
861
+ throw refusal(code, `${why} ${settled(restored) || 'Nothing was changed.'}`, next, { ...(admission.comparison ? { surface_changed: admission.comparison.surface_changed, cases_removed: admission.comparison.cases_removed, cases_changed: admission.comparison.cases_changed, cases_added: admission.comparison.cases_added } : {}), ...(admission.unfollowed?.length ? { unfollowed: admission.unfollowed } : {}), ...(admission.missing ? { missing: admission.missing } : {}), restored });
862
+ }
863
+ }
864
+ // The repaired generation serves from here on: every way out below either keeps it verified or rolls it back.
865
+ const undo = async (code, why, next, extra = {}) => {
866
+ const restored = await rollback({ published: true, stage: extra.stage ?? 'cases', code, why, failing: extra.failing });
867
+ const after = restored.generation === 'reloaded' ? 'The previous plugin was restored.' : restored.generation === 'disabled' ? 'The previous plugin could not be loaded again, so the plugin was disabled.' : 'The previous plugin could not be restored; ask the human to check /plugins.';
868
+ // A rollback puts the plugin back; it does not undo what the cases that ran did. Their bounded outputs go
869
+ // with the refusal (in its details, shown to the model; never in the journal), and the message says so.
870
+ const ran = extra.cases?.length ? ' The cases that ran before the rollback ran for real: a case whose operation has effects performed them, and what they left behind is yours. Their outputs are in cases; use them to clean up.' : '';
871
+ return refusal(code, `${why}. ${after} ${settled(restored)}`.trim() + ran, next, { ...(extra.failing ? { failing_case: extra.failing } : {}), ...(extra.cases?.length ? { cases: extra.cases } : {}), restored });
872
+ };
873
+ // 6. Every case of the published contract, run by the house through the normal permission path.
874
+ const published = this.runtime.contract(plugin), results = [], shown = [], budget = { left: CASE_OUTPUT_TOTAL };
875
+ let failing = null;
876
+ if (options?.invocation_id) this.#standing.set(plugin, { invocation: options.invocation_id, module: overlay.code.module, code_fingerprint: overlay.code.code_fingerprint, config_sha256: configDigest(entry.config), previous: { module: live.module, code_fingerprint: live.code_fingerprint } });
877
+ try {
878
+ for (const item of published?.cases ?? []) {
879
+ const result = await this.#call(`${prefix}${item.operation}`, item.input, options);
880
+ const outcome = result.status === 'not_run' ? { pass: false, reason: `Not run: ${result.reason}` } : evaluateCase(item, result);
881
+ results.push({ name: item.name, digest: item.digest, pass: outcome.pass, status: result.status, ...(outcome.reason ? { reason: outcome.reason } : {}), receipt: result.receipt });
882
+ shown.push({ name: item.name, pass: outcome.pass, ...caseShown(result, budget) });
883
+ if (!outcome.pass) { failing = results.at(-1); break; }
884
+ }
885
+ } catch (error) { failing = { name: '(interrupted)', pass: false, status: 'not_run', reason: `The case runs were interrupted: ${error?.message ?? error}`, receipt: null }; }
886
+ finally { this.#standing.delete(plugin); }
887
+ if (!published || failing) {
888
+ // 7. The house restores the previous generation by itself; the finding stays open.
889
+ const why = failing ? `Case "${failing.name}" ${failing.status === 'not_run' ? 'could not be run' : 'failed'} on the published plugin: ${failing.reason}` : 'The published plugin has no contract';
890
+ throw await undo('REPAIR_ROLLED_BACK', why, failing?.status === 'not_run' ? 'The house must be able to run every case; a case it cannot run (permission, plan mode) does not prove the repair.' : 'Fix the candidate so every case passes in this process, then accept again.', { failing, cases: shown });
891
+ }
892
+ // 8. Verified: only now is the new code trusted, and the repair stands only once its row is sealed. The
893
+ // journal must never claim a repair plugins.json does not hold: it is checked again on each side.
894
+ const holds = async () => { try { return sha256(await readFile(project)) === written; } catch { return false; } };
895
+ const elsewhere = 'plugins.json changed while the house verified the repair';
896
+ if (!await holds()) throw await undo('REPAIR_BASE_MOVED', elsewhere, 'Look at plugins.json; reproduce the case again on the live plugin if it still fails.', { stage: 'record' });
897
+ previousTrust = priorTrust ?? undefined;
898
+ if (!priorTrust) trust = { recorded: false, reason: 'no_prior_trust' };
899
+ else try { await this.trustStore.trust({ project, entry: nextEntry, code: overlay.code, reason: 'contract_repair' }); trust = { recorded: true }; }
900
+ catch (error) {
901
+ // With --trust-plugins the session needs no remembered trust; it is recorded when it can be.
902
+ if (!this.trustByFlag) throw await undo('REPAIR_TRUST_FAILED', `Trust for the repaired code could not be recorded: ${error.message}`, 'Report the reason.', { stage: 'trust' });
903
+ trust = { recorded: false, error: String(error.message).slice(0, 300) };
904
+ }
905
+ const authority = admission.kind === 'act' ? 'signed_act' : 'contract_kept';
906
+ if (!await holds()) throw await undo('REPAIR_BASE_MOVED', elsewhere, 'Look at plugins.json; reproduce the case again on the live plugin if it still fails.', { stage: 'record' });
907
+ // The human's view of this repair, beside its patch. Informational: the accepted row below is the authority.
908
+ try {
909
+ try { recordPrevious = await readFile(recordFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
910
+ recordWritten = true;
911
+ await writeAtomic(recordFile, JSON.stringify({ version: 1, plugin, accepted_at: new Date().toISOString(), session: options?.sessionId ?? null, candidate: state.candidateId, authority,
912
+ fingerprint_before: live.code_fingerprint, fingerprint_after: verified.fingerprint, contract_before: admission.before, contract_after: admission.after,
913
+ cases: results.map(item => item.name), patch_sha256: sha256(diff.patch), overlay: overlay.code.module, before_module: live.module, original_module: originalModule, act: admission.act ?? null, trust }, null, 2) + '\n');
914
+ } catch (error) {
915
+ throw await undo('REPAIR_NOT_RECORDED', `The record of the accepted repair could not be written: ${error.message}`, 'Report the reason; an unrecorded repair does not stand.', { stage: 'record' });
916
+ }
917
+ let accepted;
918
+ try {
919
+ accepted = await this.#row({ type: 'plugin_repair_accepted', plugin, candidate: state.candidateId, fingerprint_before: live.code_fingerprint, fingerprint_after: verified.fingerprint,
920
+ contract_before: admission.before, contract_after: admission.after, authority, ...(admission.act ? { act: admission.act } : {}),
921
+ cases: results.map(({ name, digest, pass, receipt }) => ({ name, digest, pass, receipt })), patch_sha256: sha256(diff.patch), overlay: overlay.code.module, repaired_from: live.module, original_module: originalModule,
922
+ overlay_fingerprint: overlay.code.code_fingerprint, host_modules: verified.host_modules,
923
+ dynamic: verified.dynamic, packages: verified.packages ?? [], unreviewed: diff.unreviewed, review_complete: diff.review_complete, trust, ...(cleanupError ? { cleanup_error: cleanupError } : {}), ...(reason ? { reason: String(reason).slice(0, 2000) } : {}) }, options);
924
+ } catch (error) {
925
+ throw await undo('REPAIR_NOT_RECORDED', `The accepted repair could not be sealed in the journal: ${error.message}`, 'Report the reason; an unrecorded repair does not stand.', { stage: 'record' });
926
+ }
927
+ // Sealed: a crash from here on must not undo it. The marker says so before it goes.
928
+ await updateMarker({ completed: true }).catch(() => {});
929
+ await removeMarker();
930
+ const recorder = this.#recorder(), proof = { receipts: results.filter(item => item.receipt).map(item => ({ case: item.name, ...item.receipt })), ...(ref(accepted) ? { accepted: ref(accepted) } : {}) };
931
+ const known = session.reproduced.get(plugin);
932
+ for (const item of reproduced) {
933
+ for (const id of item.findings) await recorder?.resolve?.(id, { state: 'repaired_in_run', proof });
934
+ // A case reproduced again while this ran keeps its newer findings.
935
+ const current = known?.get(item.digest);
936
+ if (current) { current.findings = current.findings.filter(id => !item.findings.includes(id)); if (current === item || !current.findings.length) known.delete(item.digest); }
937
+ }
938
+ if (known && !known.size) session.reproduced.delete(plugin);
939
+ this.policy?.removeCandidateRoot?.(state.root);
940
+ session.open.delete(state.candidateId);
941
+ await discardCandidate(candidate, { cwd: this.cwd, stateDir: this.#state }).catch(() => {});
942
+ // A work step that failed on the promise this repair fixed is fenced as uncertain; the accepted repair
943
+ // releases it, and only it. The repair stands whether or not the release could be recorded.
944
+ let released = [];
945
+ if (typeof this.releaseFence === 'function' && ref(accepted)) { try { released = await this.releaseFence({ plugin, accepted: ref(accepted), sessionId: options?.sessionId, invocation_id: options?.invocation_id }) ?? []; } catch { released = []; } }
946
+ const again = released.length ? ` The house released the fence this failure left on your work plan: ${released.map(item => `task ${item.task_id}, step ${item.step_id} (${item.operation})`).join('; ')}. Run that step again with WorkStepRun and the SAME step_id; other work in the task is no longer fenced by it. The house cannot know whether the failed call had an outside effect before it threw.` : '';
947
+ return { accepted: true, fingerprint: verified.fingerprint, authority, cases: shown, overlay: overlay.code.module, ...(released.length ? { released_steps: released } : {}),
948
+ next_action: `Plugin ${plugin} is repaired in this session; call its operations again to continue your task.${again} The cases ran for real on the published plugin: a case whose operation has effects performed them, and what they left behind is yours. Each case's output (or what it threw) is in cases; use those values to continue or to clean up.` };
949
+ }
950
+
951
+ async discard({ candidate: candidateId }, options) {
952
+ const session = this.#caller(options), state = this.#candidate(session, candidateId);
953
+ if (this.#busy.has(state.plugin)) throw refusal('REPAIR_BUSY', `A repair of plugin ${state.plugin} is being accepted.`, 'Wait for it to finish.');
954
+ // Closed for writing first, then removed by the workbench's own checks.
955
+ this.policy?.removeCandidateRoot?.(state.root);
956
+ session.open.delete(candidateId);
957
+ try { await discardCandidate({ id: state.plugin, candidateId, dir: state.dir }, { cwd: this.cwd, stateDir: this.#state }); }
958
+ catch (error) { throw refusal('REPAIR_DISCARD_FAILED', `The candidate directory could not be removed: ${error.message}`, 'It is closed for writing; report the reason.', { workbench_code: error.code ?? null }); }
959
+ // The broken promise is still broken: its finding is released and closes as open, unless a later repair takes it.
960
+ const recorder = this.#recorder();
961
+ for (const item of session.reproduced.get(state.plugin)?.values() ?? []) for (const id of item.findings) recorder?.release?.(id);
962
+ await this.#row({ type: 'plugin_repair_discarded', plugin: state.plugin, candidate: candidateId, reason: 'requested' }, options);
963
+ return { discarded: true, next_action: 'The candidate is gone. Open a new one with PluginRepairOpen to try again; the reproduced case is remembered for this session.' };
964
+ }
965
+
966
+ operations() {
967
+ const caseSchema = { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 120 }, operation: { type: 'string', pattern: '^[A-Za-z][A-Za-z0-9_]{0,31}$' }, input: { type: 'object' }, expect: { type: 'object' } }, required: ['name', 'operation', 'input', 'expect'], additionalProperties: false };
968
+ // Each repair invocation is adopted by the recorder: its refusals and its nested case calls are answers, not friction.
969
+ // Under Reproduce, a refused call is friction the human caused (a permission No, plan mode): kept as a finding.
970
+ const definition = (name, description, properties, required, run, children) => ({ name, description, input_schema: { type: 'object', properties, required, additionalProperties: false }, surfaces: ['agent'], steps: true,
971
+ run: (input, options) => { try { this.#recorder()?.adopt?.(options?.invocation_id, children ? { children } : undefined); } catch {} return run(input, options); } });
972
+ return [
973
+ definition('PluginRepairReproduce', 'Reproduce a broken promise of a local plugin: state it as a case { name, operation, input, expect } where expect is { returns: value the output must contain } | { output_schema: schema } | { throws: { code } }. The house calls the live operation through the normal permission path and evaluates the case itself. A failing case is recorded as a contract_mismatch finding and becomes the case the repair must add.', { plugin: ID, case: caseSchema }, ['plugin', 'case'], (input, options) => this.reproduce(input, options), row => row.status !== 'refused'),
974
+ definition('PluginRepairOpen', 'Open a candidate copy of a local plugin to repair a promise reproduced in this session. Returns the candidate id and the only directory you may write: fix the code there and add the reproduced case to the plugin\'s cases. Only the plugin\'s own files (the directory of its entry module) are copied and can be changed; the other modules it imports are host modules, linked to the installed files and shared with the house. The installed plugin and its original files are never written.', { plugin: ID }, ['plugin'], (input, options) => this.open(input, options)),
975
+ definition('PluginRepairTest', 'Pre-flight an open candidate in a separate process: it is loaded, its cases are replayed (each judged like the live operation) and evaluated by the house, and you get its diff and how its reported contract compares with the live one. The process has no house: the house\'s own services are stood in by their data, a case whose operation declares external writes is skipped (Accept runs it once, for real), and a candidate that needs another plugin\'s service to activate is not_judged. This is the candidate\'s own account, for you; acceptance does not rely on it.', { candidate: UUID }, ['candidate'], (input, options) => this.test(input, options)),
976
+ definition('PluginRepairAccept', 'Ask the house to accept an open candidate. The house verifies it, copies it to an overlay, stages it in this session and compares the staged contract with the live one: if the surface is the same, every existing case is unchanged and the reproduced cases are present, it publishes it, runs every case through the normal permission path and keeps it only if all pass; otherwise it restores the previous plugin by itself. A change of contract needs an act signed by the human (REPAIR_NEEDS_ACT when there is none), and so does changed code the house cannot follow by reading it: a computed import(), require, createRequire, eval, new Function, or a package the plugin did not import before. Give your reason for the change.', { candidate: UUID, reason: { type: 'string', maxLength: 2000 } }, ['candidate'], (input, options) => this.accept(input, options)),
977
+ definition('PluginRepairDiscard', 'Discard an open candidate: its directory and write root are removed and the plugin stays as it is; the broken promise stays recorded as an open finding.', { candidate: UUID }, ['candidate'], (input, options) => this.discard(input, options)),
978
+ ];
979
+ }
980
+ }
981
+