@ouro.bot/cli 0.1.0-alpha.935 → 0.1.0-alpha.937

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/changelog.json +21 -0
  2. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  3. package/deploy/unraid/sanctuary.xml +1 -1
  4. package/dist/heart/daemon/cli-defaults.js +5 -0
  5. package/dist/heart/daemon/cli-exec.js +9 -0
  6. package/dist/heart/daemon/cli-help.js +2 -2
  7. package/dist/heart/daemon/cli-parse.js +2 -1
  8. package/dist/heart/daemon/daemon.js +14 -0
  9. package/dist/heart/daemon/ouro-entry.js +4 -1
  10. package/dist/heart/daemon/runtime-logging.js +1 -1
  11. package/dist/heart/daemon/sense-manager.js +53 -0
  12. package/dist/heart/mailbox/mailbox-http-hooks.js +1 -0
  13. package/dist/heart/mailbox/mailbox-http-routes.js +8 -0
  14. package/dist/heart/mailbox/mailbox-read.js +3 -1
  15. package/dist/heart/mailbox/mailbox-supervision.js +131 -0
  16. package/dist/heart/shepherd/custody-types.js +2 -0
  17. package/dist/heart/shepherd/mandates.js +352 -0
  18. package/dist/heart/shepherd/operation-commands.js +19 -0
  19. package/dist/heart/shepherd/operations.js +135 -0
  20. package/dist/heart/shepherd/supervision.js +114 -0
  21. package/dist/heart/shepherd/types.js +2 -0
  22. package/dist/mailbox-ui/assets/index-Dbska1tZ.js +1 -0
  23. package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
  24. package/dist/mailbox-ui/index.html +2 -2
  25. package/dist/nerves/coverage/file-completeness.js +4 -0
  26. package/dist/senses/shepherd/checkin-store.js +209 -41
  27. package/dist/senses/shepherd/cmux.js +69 -23
  28. package/dist/senses/shepherd/guidance.js +5 -2
  29. package/dist/senses/shepherd/herdr.js +84 -2
  30. package/dist/senses/shepherd/judge-evaluation-command.js +180 -0
  31. package/dist/senses/shepherd/judge-evaluation-entry.js +13 -0
  32. package/dist/senses/shepherd/judge-evaluation.js +150 -0
  33. package/dist/senses/shepherd/judge.js +12 -5
  34. package/dist/senses/shepherd/observation-publisher.js +320 -0
  35. package/dist/senses/shepherd/operations-defaults.js +86 -0
  36. package/dist/senses/shepherd/operations.js +159 -0
  37. package/dist/senses/shepherd/recovery.js +543 -0
  38. package/dist/senses/shepherd/sense.js +14 -9
  39. package/dist/senses/shepherd/shepherd-judge-corpus-v1.json +167 -0
  40. package/npm-shrinkwrap.json +2 -2
  41. package/package.json +2 -1
  42. package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +0 -1
  43. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +0 -1
@@ -0,0 +1,543 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readCheckInSnapshot = readCheckInSnapshot;
37
+ exports.readMaintenanceCheckInSnapshot = readMaintenanceCheckInSnapshot;
38
+ exports.reconcileShepherdDispatch = reconcileShepherdDispatch;
39
+ exports.maintainShepherdCustody = maintainShepherdCustody;
40
+ const node_crypto_1 = require("node:crypto");
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ const file_lock_1 = require("../../heart/file-lock");
44
+ const runtime_1 = require("../../nerves/runtime");
45
+ const checkin_store_1 = require("./checkin-store");
46
+ const redact_1 = require("./redact");
47
+ const refuse = checkin_store_1.refuseCustody;
48
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
49
+ const identity = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && value === value.trim() && !/[\x00-\x1f\x7f-\x9f]/.test(value);
50
+ const timestamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
51
+ const digest = (value) => (0, node_crypto_1.createHash)("sha256").update(value).digest("hex");
52
+ function canonical(value) {
53
+ return JSON.stringify(value, (_name, item) => object(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item);
54
+ }
55
+ function validActor(request) {
56
+ return object(request.actor) && request.actor.kind === "local_owner_administration" && identity(request.actor.actorId)
57
+ && identity(request.requestId) && typeof request.expectedRevision === "number" && Number.isSafeInteger(request.expectedRevision) && request.expectedRevision >= 0;
58
+ }
59
+ function fresh(value) {
60
+ return timestamp(value) && Date.parse(value) <= Date.now() && Date.now() - Date.parse(value) <= 30_000;
61
+ }
62
+ function validRetirementShape(value) {
63
+ return object(value) && value.kind === "host_process_inventory" && identity(value.host) && identity(value.session)
64
+ && (value.instance === null || identity(value.instance)) && timestamp(value.checkedAt) && value.gone === true && typeof value.wholeSessionRetired === "boolean";
65
+ }
66
+ const validRetirement = (value) => validRetirementShape(value) && fresh(value.checkedAt);
67
+ function copyRetirement(proof) {
68
+ const { kind, host, session, instance, checkedAt, gone, wholeSessionRetired } = proof;
69
+ return { kind, host, session, instance, checkedAt, gone, wholeSessionRetired };
70
+ }
71
+ function parseJson(value, maxBytes = 8192) {
72
+ if (Buffer.byteLength(value) > maxBytes)
73
+ refuse("recovery record byte limit");
74
+ return JSON.parse(value);
75
+ }
76
+ function validRecoveryReceipt(value) {
77
+ return object(value) && Number.isSafeInteger(value.revision) && Number(value.revision) > 0 && identity(value.dispatchId)
78
+ && ["acknowledged_delivery", "retired_instance"].includes(String(value.resolution))
79
+ && ["claimed", "sent", "unknown"].includes(String(value.originalState)) && timestamp(value.at) && identity(value.actorId) && identity(value.requestId);
80
+ }
81
+ function auditRow(data) {
82
+ const parsed = parseJson(data);
83
+ if (!object(parsed) || !validRecoveryReceipt(parsed.receipt))
84
+ refuse("invalid recovery audit schema");
85
+ return { receipt: parsed.receipt, evidence: parsed.evidence };
86
+ }
87
+ function reconciliationRows(db, claim) {
88
+ const dispatchId = claim.id;
89
+ const count = db.prepare("SELECT COUNT(*) AS count FROM reconciliations WHERE dispatch_id=?").get(dispatchId);
90
+ const rows = db.prepare("SELECT data FROM reconciliations WHERE dispatch_id=? ORDER BY rowid DESC LIMIT 20").all(dispatchId);
91
+ let receipt = null;
92
+ const receipts = rows.map(row => {
93
+ const audit = auditRow(row.data);
94
+ if (audit.receipt.dispatchId !== dispatchId)
95
+ refuse("recovery dispatch identity mismatch");
96
+ if (audit.receipt.resolution === "acknowledged_delivery") {
97
+ if (!matchesReceipt(claim, audit.evidence, false))
98
+ refuse("invalid recovery host receipt schema");
99
+ receipt ??= (0, checkin_store_1.copyCustodyInputReceipt)(audit.evidence.receipt);
100
+ }
101
+ else if (!validRetirementShape(audit.evidence) || audit.evidence.host !== claim.host || audit.evidence.session !== claim.session
102
+ || audit.evidence.instance !== claim.instance || audit.evidence.instance === null && !audit.evidence.wholeSessionRetired) {
103
+ refuse("invalid recovery retirement proof schema");
104
+ }
105
+ return audit.receipt;
106
+ });
107
+ return { receipts, count: count.count, receipt };
108
+ }
109
+ function readCheckInSnapshot(file, options) {
110
+ return readCustodySnapshot(file, options, "read");
111
+ }
112
+ /** Recovery may inspect a held marker only after trusted daemon quiescence; this never opens a writer. */
113
+ function readMaintenanceCheckInSnapshot(file, options, sense) {
114
+ requireStoppedSense({ sense });
115
+ return readCustodySnapshot(file, options, "maintenance-read");
116
+ }
117
+ function readCustodySnapshot(file, options, mode) {
118
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 200)
119
+ refuse("inspection limit must be 1..200");
120
+ if (options.id !== undefined && (!/^[a-f0-9]{64}$/.test(options.id) || options.cursor !== undefined))
121
+ refuse("invalid inspection ID or conflicting cursor");
122
+ let cursor = null;
123
+ if (options.cursor !== undefined) {
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(Buffer.from(options.cursor, "base64url").toString("utf8"));
127
+ }
128
+ catch {
129
+ refuse("invalid inspection cursor");
130
+ }
131
+ if (options.cursor.length > 200 || !object(parsed) || typeof parsed.after !== "string" || !/^[a-f0-9]{64}$/.test(parsed.after)
132
+ || typeof parsed.revision !== "number" || !Number.isSafeInteger(parsed.revision) || parsed.revision < 0)
133
+ refuse("invalid inspection cursor");
134
+ cursor = { after: parsed.after, revision: parsed.revision };
135
+ }
136
+ const db = (0, checkin_store_1.openCustodyDatabase)(file, mode);
137
+ if (!db)
138
+ return {
139
+ state: "missing", schemaVersion: null, revision: null, bytes: null, capacity: "unknown",
140
+ totalCount: null, noticeCount: null, counts: null, entries: [], nextCursor: null,
141
+ };
142
+ try {
143
+ return db.transaction(() => {
144
+ const schemaVersion = Number(db.pragma("user_version", { simple: true }));
145
+ const revision = schemaVersion === 2 ? (0, checkin_store_1.custodyRevision)(db) : 0;
146
+ if (cursor && cursor.revision !== revision)
147
+ refuse("inspection cursor revision conflict");
148
+ const capacity = (0, checkin_store_1.custodyCapacity)(db);
149
+ const total = db.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
150
+ const notices = db.prepare("SELECT COUNT(*) AS count FROM notices").get();
151
+ const grouped = db.prepare("SELECT json_extract(data,'$.state') AS state,COUNT(*) AS count FROM dispatches GROUP BY json_extract(data,'$.state')").all();
152
+ const counts = { claimed: 0, sent: 0, not_sent: 0, unknown: 0 };
153
+ for (const group of grouped) {
154
+ if (!["claimed", "sent", "not_sent", "unknown"].includes(group.state))
155
+ refuse("invalid custody state schema");
156
+ counts[group.state] = group.count;
157
+ }
158
+ const rows = (options.id !== undefined
159
+ ? db.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id=?").all(options.id)
160
+ : db.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id>? ORDER BY id LIMIT ?").all(cursor?.after ?? "", options.limit + 1));
161
+ const page = rows.slice(0, options.limit);
162
+ const entries = page.map(row => {
163
+ const claim = (0, checkin_store_1.parseCustodyDispatch)(row);
164
+ const audit = schemaVersion === 2 ? reconciliationRows(db, claim) : { receipts: [], count: 0, receipt: null };
165
+ const retired = schemaVersion === 2 && (0, checkin_store_1.hasRetiredCustodyInstance)(db, claim);
166
+ const entry = {
167
+ id: claim.id, host: claim.host, session: claim.session, instance: claim.instance, state: claim.state, trigger: claim.trigger,
168
+ claimedAt: claim.at, reason: claim.reason === undefined ? null : (0, redact_1.redactSecrets)(claim.reason), retired,
169
+ receipt: claim.receipt ? (0, checkin_store_1.copyCustodyInputReceipt)(claim.receipt) : audit.receipt, reconciliations: audit.receipts, reconciliationCount: audit.count,
170
+ };
171
+ return entry;
172
+ });
173
+ return {
174
+ state: "ready", schemaVersion, revision, ...capacity, totalCount: total.count, noticeCount: notices.count, counts, entries,
175
+ nextCursor: rows.length > options.limit ? Buffer.from(JSON.stringify({ after: page.at(-1).id, revision })).toString("base64url") : null,
176
+ };
177
+ })();
178
+ }
179
+ finally {
180
+ db.close();
181
+ }
182
+ }
183
+ function priorRequest(db, request, hash, validate) {
184
+ const row = db.prepare("SELECT hash,receipt FROM custody_requests WHERE actor_id=? AND request_id=?").get(request.actor.actorId, request.requestId);
185
+ if (!row)
186
+ return null;
187
+ if (row.hash !== hash)
188
+ refuse("owner request ID was already used with different arguments");
189
+ const receipt = parseJson(row.receipt);
190
+ if (!validate(receipt))
191
+ refuse("invalid owner request receipt schema");
192
+ return receipt;
193
+ }
194
+ function rememberRequest(db, request, hash, receipt) {
195
+ const serialized = JSON.stringify(receipt);
196
+ db.prepare("INSERT INTO custody_requests(actor_id,request_id,hash,receipt) VALUES(?,?,?,?)").run(request.actor.actorId, request.requestId, hash, serialized);
197
+ }
198
+ function retireInstance(db, proof, provenance, archive) {
199
+ if (proof.instance === null && !proof.wholeSessionRetired)
200
+ refuse("null instance requires whole session retirement proof");
201
+ const prior = (0, checkin_store_1.readRetiredCustodyRecord)(db, proof);
202
+ if (prior && !archive)
203
+ return;
204
+ if (prior?.archive !== undefined && canonical(prior.archive) !== canonical(archive))
205
+ refuse("retired instance archive reference already assigned; manual maintenance required");
206
+ if (Buffer.byteLength(JSON.stringify({ ...(prior ?? { proof, provenance }), ...(archive === undefined ? {} : { archive }) })) > 4096)
207
+ refuse("retired instance index capacity");
208
+ const data = JSON.stringify({ ...(prior ?? { proof: copyRetirement(proof), provenance }), ...(archive === undefined ? {} : { archive }) });
209
+ db.prepare(`INSERT INTO retired_instances(key,host,session,instance,data) VALUES(?,?,?,?,?)
210
+ ON CONFLICT(key) DO UPDATE SET data=excluded.data`).run((0, checkin_store_1.retiredCustodyKey)(proof), proof.host, proof.session, proof.instance, data);
211
+ }
212
+ function matchesReceipt(claim, evidence, requireFresh = true) {
213
+ return object(evidence) && evidence.kind === "guarded_host_receipt_v1" && timestamp(evidence.checkedAt) && (!requireFresh || fresh(evidence.checkedAt))
214
+ && evidence.host === claim.host && (0, checkin_store_1.validCustodyInputReceipt)(evidence.receipt)
215
+ && evidence.receipt.requestId === claim.id && evidence.receipt.surfaceId === claim.session
216
+ && evidence.receipt.processInstance === claim.instance && evidence.receipt.pasteAdmitted && evidence.receipt.submitAdmitted;
217
+ }
218
+ async function reconcileShepherdDispatch(root, request) {
219
+ if (!object(request) || !validActor(request) || !identity(request.id))
220
+ refuse("invalid owner reconciliation request");
221
+ request = structuredClone(request);
222
+ const hash = digest(canonical({ operation: "reconcile", request: { ...request, evidence: { ...request.evidence, checkedAt: undefined } } }));
223
+ const file = (0, checkin_store_1.checkInStorePath)(root);
224
+ // Do not turn a missing ledger into a new source of authority.
225
+ const inspection = readCheckInSnapshot(file, { limit: 1 });
226
+ if (inspection.state === "missing")
227
+ refuse("custody store is missing");
228
+ return (0, file_lock_1.withFileLock)(path.dirname(file), "operations", () => {
229
+ const db = (0, checkin_store_1.openCustodyDatabase)(file, "write");
230
+ try {
231
+ let mutated = false;
232
+ const result = db.transaction(() => {
233
+ (0, checkin_store_1.custodyCapacity)(db, true);
234
+ const prior = priorRequest(db, request, hash, validRecoveryReceipt);
235
+ if (prior)
236
+ return prior;
237
+ if ((0, checkin_store_1.custodyRevision)(db) !== request.expectedRevision)
238
+ refuse("reconciliation revision conflict");
239
+ const row = db.prepare("SELECT id,host,session,instance,data FROM dispatches WHERE id=?").get(request.id);
240
+ if (!row)
241
+ refuse("dispatch not found");
242
+ const claim = (0, checkin_store_1.parseCustodyDispatch)(row);
243
+ if (claim.state === "not_sent")
244
+ refuse("definite not-sent dispatch requires no reconciliation");
245
+ let resolution;
246
+ let retirement = null;
247
+ let verifiedEvidence;
248
+ if (matchesReceipt(claim, request.evidence)) {
249
+ resolution = "acknowledged_delivery";
250
+ verifiedEvidence = {
251
+ kind: request.evidence.kind, host: request.evidence.host, checkedAt: request.evidence.checkedAt,
252
+ receipt: (0, checkin_store_1.copyCustodyInputReceipt)(request.evidence.receipt),
253
+ };
254
+ }
255
+ else if (validRetirement(request.evidence) && request.evidence.host === claim.host
256
+ && request.evidence.session === claim.session && request.evidence.instance === claim.instance) {
257
+ resolution = "retired_instance";
258
+ retirement = request.evidence;
259
+ verifiedEvidence = copyRetirement(request.evidence);
260
+ }
261
+ else
262
+ refuse("matching confirmed host receipt or fresh exact gone-instance proof required");
263
+ const receipt = {
264
+ revision: (0, checkin_store_1.advanceCustodyRevision)(db), dispatchId: claim.id, resolution, originalState: claim.state,
265
+ at: new Date(Date.now()).toISOString(), actorId: request.actor.actorId, requestId: request.requestId,
266
+ };
267
+ if (retirement)
268
+ retireInstance(db, retirement, receipt);
269
+ if (Buffer.byteLength(JSON.stringify({ receipt, evidence: request.evidence })) > 8192)
270
+ refuse("recovery record byte limit");
271
+ const audit = JSON.stringify({ receipt, evidence: verifiedEvidence });
272
+ db.prepare("INSERT INTO reconciliations(id,dispatch_id,data) VALUES(?,?,?)").run(digest(JSON.stringify([request.actor.actorId, request.requestId])), claim.id, audit);
273
+ rememberRequest(db, request, hash, receipt);
274
+ mutated = true;
275
+ return receipt;
276
+ }).immediate();
277
+ if (mutated)
278
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_dispatch_reconciled", message: "appended custody reconciliation without rewriting original outcome", meta: { resolution: result.resolution, revision: result.revision } });
279
+ return result;
280
+ }
281
+ finally {
282
+ db.close();
283
+ }
284
+ });
285
+ }
286
+ function syncFile(file) {
287
+ const handle = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
288
+ try {
289
+ fs.fsyncSync(handle);
290
+ }
291
+ finally {
292
+ fs.closeSync(handle);
293
+ }
294
+ }
295
+ function syncDirectory(dir) {
296
+ const handle = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
297
+ try {
298
+ fs.fsyncSync(handle);
299
+ }
300
+ finally {
301
+ fs.closeSync(handle);
302
+ }
303
+ }
304
+ function privateRegular(file, maxBytes) {
305
+ const stat = fs.lstatSync(file);
306
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & 0o7777) !== 0o600 || stat.size > maxBytes)
307
+ refuse("maintenance private file ownership, mode, link or byte limit");
308
+ }
309
+ function fileDigest(file) {
310
+ privateRegular(file, 64 * 1024 * 1024);
311
+ const handle = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
312
+ try {
313
+ const hash = (0, node_crypto_1.createHash)("sha256");
314
+ const buffer = Buffer.alloc(65536);
315
+ for (;;) {
316
+ const count = fs.readSync(handle, buffer, 0, buffer.length, null);
317
+ if (!count)
318
+ break;
319
+ hash.update(buffer.subarray(0, count));
320
+ }
321
+ return hash.digest("hex");
322
+ }
323
+ finally {
324
+ fs.closeSync(handle);
325
+ }
326
+ }
327
+ function writePrivateJson(file, value) {
328
+ const temp = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
329
+ fs.writeFileSync(temp, JSON.stringify(value), { flag: "wx", mode: 0o600 });
330
+ syncFile(temp);
331
+ fs.renameSync(temp, file);
332
+ syncDirectory(path.dirname(file));
333
+ }
334
+ function maintenanceDirectory(dir, create) {
335
+ if (!path.isAbsolute(dir) || path.normalize(dir) !== dir)
336
+ refuse("maintenance destination must be an exact absolute path");
337
+ (0, checkin_store_1.validateCustodyDirectory)(dir, create);
338
+ }
339
+ function validMaintenanceReceipt(value) {
340
+ return object(value) && Number.isSafeInteger(value.revision) && Number(value.revision) > 0 && identity(value.requestId) && identity(value.actorId)
341
+ && Number.isSafeInteger(value.archivedCount) && Number(value.archivedCount) >= 0 && Number.isSafeInteger(value.retainedCount) && Number(value.retainedCount) >= 0
342
+ && typeof value.archive === "string" && path.isAbsolute(value.archive) && typeof value.archiveDigest === "string" && /^[a-f0-9]{64}$/.test(value.archiveDigest) && timestamp(value.at);
343
+ }
344
+ function readIntent(file, hash, archive, replacementPrefix) {
345
+ if (!fs.lstatSync(file, { throwIfNoEntry: false }))
346
+ return null;
347
+ privateRegular(file, 8192);
348
+ const value = parseJson(fs.readFileSync(file, "utf8"));
349
+ if (!object(value) || value.version !== 1 || value.hash !== hash || typeof value.sourceRevision !== "number" || !Number.isSafeInteger(value.sourceRevision) || value.sourceRevision < 0
350
+ || typeof value.sourceDigest !== "string" || !/^[a-f0-9]{64}$/.test(value.sourceDigest) || value.archive !== archive
351
+ || typeof value.replacement !== "string" || !value.replacement.startsWith(replacementPrefix)
352
+ || path.dirname(value.replacement) !== path.dirname(replacementPrefix)
353
+ || !["copying", "prepared"].includes(String(value.stage)))
354
+ refuse("interrupted maintenance belongs to a different request or has invalid schema");
355
+ const base = { version: 1, hash, sourceDigest: value.sourceDigest, sourceRevision: value.sourceRevision, archive, replacement: value.replacement };
356
+ if (value.stage === "prepared") {
357
+ if (!validMaintenanceReceipt(value.receipt) || value.archiveDigest !== value.receipt.archiveDigest
358
+ || typeof value.replacementDigest !== "string" || !/^[a-f0-9]{64}$/.test(value.replacementDigest))
359
+ refuse("invalid prepared maintenance schema");
360
+ return { ...base, stage: "prepared", archiveDigest: value.receipt.archiveDigest, replacementDigest: value.replacementDigest, receipt: value.receipt };
361
+ }
362
+ if (value.archiveDigest !== null || value.replacementDigest !== null || value.receipt !== null)
363
+ refuse("invalid copying maintenance schema");
364
+ return { ...base, stage: "copying", archiveDigest: null, replacementDigest: null, receipt: null };
365
+ }
366
+ function targetsClaim(proof, claim) {
367
+ return proof.host === claim.host && proof.session === claim.session && (proof.instance === null && proof.wholeSessionRetired || proof.instance === claim.instance);
368
+ }
369
+ function archiveCandidates(db, proofs) {
370
+ const ids = [];
371
+ for (const row of db.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate()) {
372
+ const claim = (0, checkin_store_1.parseCustodyDispatch)(row);
373
+ if (!proofs.some(proof => targetsClaim(proof, claim)))
374
+ continue;
375
+ const retired = (0, checkin_store_1.hasRetiredCustodyInstance)(db, claim);
376
+ if ((claim.state === "claimed" || claim.state === "unknown") && !retired)
377
+ refuse("unresolved attempt must be reconciled before archive");
378
+ ids.push(claim.id);
379
+ }
380
+ return ids;
381
+ }
382
+ function validateArchive(file, expectedCount) {
383
+ const db = (0, checkin_store_1.openCustodyDatabase)(file, "read");
384
+ try {
385
+ if (db.pragma("integrity_check", { simple: true }) !== "ok")
386
+ refuse("archive integrity check failed");
387
+ const count = db.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
388
+ if (count.count !== expectedCount)
389
+ refuse("archive dispatch count mismatch");
390
+ for (const row of db.prepare("SELECT id,host,session,instance,data FROM dispatches").iterate())
391
+ (0, checkin_store_1.parseCustodyDispatch)(row);
392
+ }
393
+ finally {
394
+ db.close();
395
+ }
396
+ }
397
+ function verifyPrepared(intent) {
398
+ if (intent.stage !== "prepared" || !intent.receipt || fileDigest(intent.archive) !== intent.archiveDigest
399
+ || fileDigest(intent.replacement) !== intent.replacementDigest)
400
+ refuse("prepared maintenance payload digest mismatch");
401
+ const manifest = `${intent.archive}.json`;
402
+ privateRegular(manifest, 8192);
403
+ const value = parseJson(fs.readFileSync(manifest, "utf8"));
404
+ if (!object(value) || value.archiveDigest !== intent.archiveDigest || value.requestHash !== intent.hash
405
+ || canonical(value.receipt) !== canonical(intent.receipt))
406
+ refuse("archive manifest mismatch");
407
+ }
408
+ function requireStoppedSense(request) {
409
+ if (!object(request.sense) || request.sense.kind !== "daemon_lifecycle" || request.sense.enabled !== false || request.sense.processState !== "stopped" || !fresh(request.sense.checkedAt)) {
410
+ refuse("maintenance requires a fresh disabled and stopped sense proof");
411
+ }
412
+ }
413
+ /** Fresh lifecycle observations are preconditions, not a change to the operator's archival intent. */
414
+ async function maintainShepherdCustody(root, request) {
415
+ if (!object(request) || !validActor(request) || typeof request.destination !== "string" || request.destination.length > 2000
416
+ || !Array.isArray(request.retiredInstances) || request.retiredInstances.length > 200
417
+ || request.requestFingerprint !== undefined && !/^[a-f0-9]{64}$/.test(request.requestFingerprint))
418
+ refuse("invalid owner maintenance request");
419
+ request = structuredClone(request);
420
+ const hash = request.requestFingerprint ?? digest(canonical({ operation: "maintenance", request: {
421
+ ...request, sense: undefined, retiredInstances: request.retiredInstances.map(proof => ({ ...proof, checkedAt: undefined })),
422
+ } }));
423
+ const file = (0, checkin_store_1.checkInStorePath)(root);
424
+ const marker = `${file}.maintenance.json`;
425
+ const archive = path.join(request.destination, `custody-${hash}.sqlite`);
426
+ const replacementPrefix = `${file}.replacement-${hash}-`;
427
+ maintenanceDirectory(request.destination, false);
428
+ if (!fs.lstatSync(file, { throwIfNoEntry: false }))
429
+ refuse("custody store is missing");
430
+ return (0, file_lock_1.withFileLock)(path.dirname(file), "operations", async () => {
431
+ let intent = readIntent(marker, hash, archive, replacementPrefix);
432
+ const db = (0, checkin_store_1.openCustodyDatabase)(file, "maintenance");
433
+ try {
434
+ const prior = priorRequest(db, request, hash, validMaintenanceReceipt);
435
+ if (prior) {
436
+ if (intent) {
437
+ if (intent.stage !== "prepared" || fileDigest(file) !== intent.replacementDigest || fileDigest(archive) !== prior.archiveDigest)
438
+ refuse("completed maintenance does not match recovery marker");
439
+ fs.unlinkSync(marker);
440
+ syncDirectory(path.dirname(file));
441
+ }
442
+ return prior;
443
+ }
444
+ requireStoppedSense(request);
445
+ if (!request.retiredInstances.length || !request.retiredInstances.every(validRetirement)
446
+ || request.retiredInstances.some(proof => proof.instance === null && !proof.wholeSessionRetired))
447
+ refuse("maintenance requires fresh exact retired-instance proofs");
448
+ if ((0, checkin_store_1.custodyRevision)(db) !== request.expectedRevision)
449
+ refuse("maintenance revision conflict");
450
+ if (intent) {
451
+ if (intent.sourceRevision !== (0, checkin_store_1.custodyRevision)(db) || fileDigest(file) !== intent.sourceDigest)
452
+ refuse("maintenance source changed; manual recovery required");
453
+ if (intent.stage === "prepared") {
454
+ verifyPrepared(intent);
455
+ requireStoppedSense(request);
456
+ db.close();
457
+ fs.renameSync(intent.replacement, file);
458
+ syncDirectory(path.dirname(file));
459
+ fs.unlinkSync(marker);
460
+ syncDirectory(path.dirname(file));
461
+ return intent.receipt;
462
+ }
463
+ }
464
+ const ids = archiveCandidates(db, request.retiredInstances);
465
+ if (!ids.length)
466
+ refuse("no settled retired-instance payloads are eligible for archive");
467
+ const total = db.prepare("SELECT COUNT(*) AS count FROM dispatches").get();
468
+ maintenanceDirectory(request.destination, true);
469
+ if (fs.lstatSync(archive, { throwIfNoEntry: false })) {
470
+ if (!intent || intent.stage !== "copying")
471
+ refuse("archive destination already exists; resume its exact prepared request");
472
+ privateRegular(archive, 64 * 1024 * 1024);
473
+ fs.renameSync(archive, `${archive}.interrupted-${(0, node_crypto_1.randomUUID)()}`);
474
+ if (fs.lstatSync(`${archive}.json`, { throwIfNoEntry: false })) {
475
+ privateRegular(`${archive}.json`, 8192);
476
+ fs.renameSync(`${archive}.json`, `${archive}.json.interrupted-${(0, node_crypto_1.randomUUID)()}`);
477
+ }
478
+ syncDirectory(request.destination);
479
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_custody_archive_resumed", message: "preserved an interrupted archive publication before rebuilding from unchanged custody", meta: { stage: "copying" } });
480
+ }
481
+ intent = {
482
+ version: 1, hash, sourceDigest: fileDigest(file), sourceRevision: (0, checkin_store_1.custodyRevision)(db), stage: "copying", archive,
483
+ replacement: `${replacementPrefix}${(0, node_crypto_1.randomUUID)()}.sqlite`, archiveDigest: null, replacementDigest: null, receipt: null,
484
+ };
485
+ writePrivateJson(marker, intent);
486
+ const archiveTemp = `${archive}.${(0, node_crypto_1.randomUUID)()}.tmp`;
487
+ fs.writeFileSync(archiveTemp, "", { flag: "wx", mode: 0o600 });
488
+ await db.backup(archiveTemp);
489
+ syncFile(archiveTemp);
490
+ validateArchive(archiveTemp, total.count);
491
+ const archiveDigest = fileDigest(archiveTemp);
492
+ fs.writeFileSync(intent.replacement, "", { flag: "wx", mode: 0o600 });
493
+ await db.backup(intent.replacement);
494
+ const replacement = (0, checkin_store_1.openCustodyDatabase)(intent.replacement, "write");
495
+ let receipt;
496
+ try {
497
+ receipt = replacement.transaction(() => {
498
+ for (const id of ids) {
499
+ replacement.prepare("DELETE FROM reconciliations WHERE dispatch_id=?").run(id);
500
+ replacement.prepare("DELETE FROM dispatches WHERE id=?").run(id);
501
+ }
502
+ for (const proof of request.retiredInstances)
503
+ retireInstance(replacement, proof, { actorId: request.actor.actorId, requestId: request.requestId }, { path: archive, digest: archiveDigest });
504
+ const result = {
505
+ revision: (0, checkin_store_1.advanceCustodyRevision)(replacement), requestId: request.requestId, actorId: request.actor.actorId,
506
+ archivedCount: ids.length, retainedCount: total.count - ids.length, archive, archiveDigest, at: new Date(Date.now()).toISOString(),
507
+ };
508
+ rememberRequest(replacement, request, hash, result);
509
+ return result;
510
+ }).immediate();
511
+ replacement.exec("VACUUM");
512
+ (0, checkin_store_1.custodyCapacity)(replacement, true);
513
+ if (replacement.pragma("integrity_check", { simple: true }) !== "ok")
514
+ refuse("replacement custody integrity check failed");
515
+ }
516
+ finally {
517
+ replacement.close();
518
+ }
519
+ syncFile(intent.replacement);
520
+ validateArchive(intent.replacement, receipt.retainedCount);
521
+ fs.renameSync(archiveTemp, archive);
522
+ syncDirectory(request.destination);
523
+ writePrivateJson(`${archive}.json`, { version: 1, requestHash: hash, archiveDigest, receipt });
524
+ intent = { ...intent, stage: "prepared", archiveDigest, replacementDigest: fileDigest(intent.replacement), receipt };
525
+ writePrivateJson(marker, intent);
526
+ verifyPrepared(intent);
527
+ requireStoppedSense(request);
528
+ if (fileDigest(file) !== intent.sourceDigest)
529
+ refuse("maintenance source changed before swap");
530
+ db.close();
531
+ fs.renameSync(intent.replacement, file);
532
+ syncDirectory(path.dirname(file));
533
+ fs.unlinkSync(marker);
534
+ syncDirectory(path.dirname(file));
535
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_custody_archived", message: "archived settled retired custody and retained replay tombstones", meta: { archivedCount: receipt.archivedCount, retainedCount: receipt.retainedCount, revision: receipt.revision } });
536
+ return receipt;
537
+ }
538
+ finally {
539
+ if (db.open)
540
+ db.close();
541
+ }
542
+ });
543
+ }
@@ -65,10 +65,11 @@ const agent_presentation_1 = require("./agent-presentation");
65
65
  const feedback_worker_1 = require("./feedback-worker");
66
66
  const checkin_config_1 = require("./checkin-config");
67
67
  const checkin_store_1 = require("./checkin-store");
68
+ const observation_publisher_1 = require("./observation-publisher");
68
69
  /**
69
70
  * Ouro Shepherd: every time a coding agent in a terminal hands control back, a cheap judge decides
70
71
  * whether it should have. A premature return is answered in the terminal, visibly, as
71
- * `[Ouro for <human>] ...` (or one menu key), with the status line saying so. A gate or a finished
72
+ * `[Ouro for <human>] ...`, with the status line saying so. A gate or a finished
72
73
  * delivery is let through and brought to the Ouro agent, as is a session the loop guard stopped.
73
74
  * Every judgment is one line in `state/senses/shepherd/returns.jsonl`, and the owner hears about it in a plain message
74
75
  * on their own channel as `senses.shepherd.notify` says (see `owner-notice.ts`).
@@ -212,6 +213,8 @@ async function startShepherdSenseApp(options) {
212
213
  const notifier = options.notifyOwner ? null : (0, owner_notice_1.createOwnerNotifier)({ agent, agentRoot, policy: noticePolicy(agent, agentRoot), schedule, listSessions: () => host.list() });
213
214
  const notifyOwner = options.notifyOwner ?? ((record) => notifier.notify(record));
214
215
  const custody = (options.openCheckInStore ?? checkin_store_1.openCheckInStore)((0, checkin_store_1.checkInStorePath)(agentRoot));
216
+ const observations = (0, observation_publisher_1.createShepherdObservationPublisher)({ agentRoot, agentName: agent, host: host.name, now });
217
+ observations.refresh();
215
218
  const dispatchOwner = (0, node_crypto_1.randomUUID)();
216
219
  const feedback = (options.feedbackWorker ?? feedback_worker_1.startFeedbackWorker)({ agent, agentRoot, now, schedule });
217
220
  /**
@@ -365,7 +368,7 @@ async function startShepherdSenseApp(options) {
365
368
  };
366
369
  async function dispatch(record, reply, instance, screenFingerprint, expected, canSend) {
367
370
  const claim = custody.claimDispatch({ host: host.name, session: record.session, instance, screenFingerprint,
368
- guidanceRevision: String(record.guidanceRevision), reply: "key" in reply ? `key:${reply.key}` : reply.text,
371
+ guidanceRevision: String(record.guidanceRevision), reply: reply.text,
369
372
  trigger: record.trigger, transition: record.transition, at: record.at }, dispatchOwner);
370
373
  if (!claim) {
371
374
  record.actionBlockReason = "custody";
@@ -378,10 +381,7 @@ async function startShepherdSenseApp(options) {
378
381
  if (expected)
379
382
  result = await host.promptIfUnchanged(record.session, `[${presentation.name} for ${human}] ${exports.PERIODIC_RESUME}`, expected, canSend);
380
383
  else {
381
- if ("key" in reply)
382
- await host.key(record.session, reply.key);
383
- else
384
- await host.prompt(record.session, `[${presentation.name} for ${human}] ${reply.text}`);
384
+ await host.prompt(record.session, `[${presentation.name} for ${human}] ${reply.text}`);
385
385
  result = { state: "sent" };
386
386
  }
387
387
  }
@@ -531,9 +531,9 @@ async function startShepherdSenseApp(options) {
531
531
  if (kind === "premature" && !actionableReply)
532
532
  record.actionBlockReason = "reply_invalid";
533
533
  if (kind === "premature" && actionableReply) {
534
- if ((recovery || periodic) && "key" in actionableReply) {
534
+ if ("key" in actionableReply) {
535
535
  record.actionBlockReason = "menu_disallowed";
536
- record.reason = `${periodic ? "periodic check-in" : "connectivity recovery"} never presses approval or menu keys: ${verdict.reason}`;
536
+ record.reason = `${periodic ? "periodic check-in" : recovery ? "connectivity recovery" : "automatic continuation"} never presses approval or menu keys: ${verdict.reason}`;
537
537
  }
538
538
  else if (periodic && (!current.instance || current.activity !== "idle" || current.composer !== "empty" ||
539
539
  current.backgroundWork !== false || !current.session.agent || !host.promptIfUnchanged)) {
@@ -593,7 +593,7 @@ async function startShepherdSenseApp(options) {
593
593
  return !live.paused && live.revision === guidance.revision;
594
594
  } : undefined;
595
595
  if (await dispatch(record, reply, current?.instance ?? session?.instance ?? null, fingerprint(screen), periodic ? current : undefined, canSend)) {
596
- Object.assign(record, { action: "respond", reply: "key" in reply ? `key:${reply.key}` : reply.text });
596
+ Object.assign(record, { action: "respond", reply: reply.text });
597
597
  state.streak += 1;
598
598
  state.answers.push(now());
599
599
  status = `answered for ${human}: ${verdict.reason}`;
@@ -684,9 +684,13 @@ async function startShepherdSenseApp(options) {
684
684
  catch (error) {
685
685
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_log_error", message: "could not log a Shepherd judgment", meta: { agent, error: error.message } });
686
686
  }
687
+ if (!stopped)
688
+ observations.refresh();
687
689
  (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_return", message: "handled a returned control", meta: { agent, host: host.name, kind: record.kind, action: record.action } });
688
690
  }
689
691
  const watcher = host.watch({
692
+ observed: observation => { if (!stopped)
693
+ observations.observe(observation); },
690
694
  returned: (event) => {
691
695
  if (stopped)
692
696
  return;
@@ -712,6 +716,7 @@ async function startShepherdSenseApp(options) {
712
716
  if (stopped)
713
717
  return;
714
718
  stopped = true;
719
+ observations.stop();
715
720
  networkWaiting.clear();
716
721
  watcher.close();
717
722
  cancelWake?.();