seisin 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +151 -67
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -30
  8. package/src/commands/log.js +55 -10
  9. package/src/commands/requests.js +124 -39
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +174 -39
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +261 -27
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +24 -16
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +312 -55
  30. package/src/mcp.js +96 -43
  31. package/src/notify.js +15 -10
  32. package/src/owners.js +323 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +81 -12
  36. package/src/requests.js +250 -72
  37. package/src/review.js +42 -21
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +395 -205
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +203 -42
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1366 -529
package/src/log.js CHANGED
@@ -11,9 +11,9 @@
11
11
  * what an agent tried last night reads the same today — and since the change in
12
12
  * spool.js, nothing inside the sandbox can rewrite one either. append() below
13
13
  * tries the parent's socket first for exactly that reason; writing the file
14
- * directly is what happens when there is no parent, outside the box.
14
+ * directly is what happens when there is no parent, outside the sandbox.
15
15
  */
16
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync } from "node:fs";
16
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
18
  import { basename, dirname, join } from "node:path";
19
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
@@ -44,16 +44,80 @@ export function append(file, entry) {
44
44
  // segment. Done here so the size check and the append never race.
45
45
  maybeRotate(file);
46
46
  // `prev` goes last and is computed from the previous line exactly as it
47
- // sits on disk, so verifying needs nothing but the file.
48
- const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
47
+ // sits on disk, so verifying needs nothing but the file. `at` is this
48
+ // process's clock, whatever the entry says: named again after the spread
49
+ // so a caller's `at` cannot replace it (the key keeps its first place).
50
+ const now = new Date().toISOString();
51
+ const line = JSON.stringify({ at: now, ...entry, at: now, prev: hashOf(lastLine(file)) });
49
52
  appendFileSync(file, line + "\n");
50
- });
53
+ // Once per log: pin where the chain begins. See recordGenesis.
54
+ if (!existsSync(genesisPath(file))) { try { recordGenesis(file); } catch {} }
55
+ }, { waitMs: envInt("SEISIN_LOG_LOCK_WAIT_MS", 6000) });
51
56
  return true;
52
- } catch {
57
+ } catch (e) {
58
+ // A line that could not get the lock is gone, and a missing line does not
59
+ // break the chain. Say so beside the log, so `verify` can.
60
+ if (e?.code === "ELOCKED") {
61
+ try { appendFileSync(droppedPath(file), JSON.stringify({ at: new Date().toISOString(), role: entry?.role, event: entry?.event }) + "\n"); } catch {}
62
+ }
53
63
  return false;
54
64
  }
55
65
  }
56
66
 
67
+ /** Where entries that could not be written are counted. */
68
+ export function droppedPath(file) {
69
+ return file + ".dropped";
70
+ }
71
+
72
+ function countLines(file) {
73
+ return readLines(file).length;
74
+ }
75
+
76
+ /**
77
+ * The complete lines of a JSON-lines file, raw and non-blank, oldest first.
78
+ *
79
+ * The one reader the JSONL files here share (the log, its segments, the
80
+ * request queue, the tail the hook counts from). With `tail`, only the last
81
+ * that many bytes are read, and the line the cut lands in is dropped — unless
82
+ * the cut falls exactly on a line boundary, which is checked by reading one
83
+ * byte earlier rather than guessed from string lengths (a byte count and a
84
+ * UTF-16 length disagree on every "ñ"). A missing file is no lines.
85
+ */
86
+ export function readLines(file, { tail = 0 } = {}) {
87
+ let fd;
88
+ try { fd = openSync(file, "r"); } catch { return []; }
89
+ try {
90
+ const { size } = fstatSync(fd);
91
+ const from = tail > 0 ? Math.max(0, size - tail) : 0;
92
+ const start = from > 0 ? from - 1 : 0;
93
+ const buf = Buffer.alloc(size - start);
94
+ let got = 0;
95
+ while (got < buf.length) {
96
+ const n = readSync(fd, buf, got, buf.length - got, start + got);
97
+ if (n === 0) break;
98
+ got += n;
99
+ }
100
+ const lines = buf.subarray(0, got).toString("utf8").split("\n");
101
+ // Started one byte early: the first piece is either empty (the cut was on
102
+ // a newline) or the fragment the cut landed in. Dropped either way.
103
+ if (from > 0) lines.shift();
104
+ return lines.filter((l) => l.trim());
105
+ } finally {
106
+ closeSync(fd);
107
+ }
108
+ }
109
+
110
+ /** `readLines`, parsed. A malformed line — a half-written one from a killed process — is skipped. */
111
+ export function readEntries(file, opts) {
112
+ const out = [];
113
+ for (const line of readLines(file, opts)) {
114
+ let e;
115
+ try { e = JSON.parse(line); } catch { continue; }
116
+ if (e && typeof e === "object") out.push(e);
117
+ }
118
+ return out;
119
+ }
120
+
57
121
  /**
58
122
  * When the current segment fills, retire it and start a fresh one that still
59
123
  * chains.
@@ -126,9 +190,9 @@ function maybeRotate(file) {
126
190
  * from nono's audit trail; the Merkle root and the signature it adds on top
127
191
  * are left for when someone asks for them.
128
192
  *
129
- * The first chained line of a file points at GENESIS. A file started by
130
- * rotation should point at the last line of the one before it; there is no
131
- * rotation yet, and when there is, that is what it has to do.
193
+ * The first chained line of a log points at GENESIS (or, after a legacy
194
+ * prefix, at the last legacy line). A segment started by rotation points at
195
+ * the last line of the one before it — see maybeRotate and verifyLog.
132
196
  */
133
197
  export const GENESIS = "0".repeat(32);
134
198
 
@@ -136,18 +200,34 @@ export function hashOf(line) {
136
200
  return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
137
201
  }
138
202
 
139
- /** The last complete line of the file, or null when there is none. */
203
+ /**
204
+ * The last complete line of the file, or null when there is none.
205
+ *
206
+ * Read backwards a chunk at a time until a newline is found before it. It
207
+ * read a fixed 64 KB, so a single line longer than that came back as its own
208
+ * tail, the next `prev` hashed a fragment, and the chain broke with nobody
209
+ * having touched the file.
210
+ */
140
211
  function lastLine(file) {
141
- if (!existsSync(file)) return null;
142
- const { size } = statSync(file);
143
- if (size === 0) return null;
144
- const from = Math.max(0, size - 64 * 1024);
145
- const fd = openSync(file, "r");
212
+ let fd;
213
+ try { fd = openSync(file, "r"); } catch { return null; }
146
214
  try {
147
- const buf = Buffer.alloc(size - from);
148
- readSync(fd, buf, 0, buf.length, from);
149
- const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
150
- return lines.length ? lines[lines.length - 1] : null;
215
+ const CHUNK = 64 * 1024;
216
+ let pos = fstatSync(fd).size;
217
+ let acc = Buffer.alloc(0);
218
+ while (pos > 0) {
219
+ const from = Math.max(0, pos - CHUNK);
220
+ const buf = Buffer.alloc(pos - from);
221
+ readSync(fd, buf, 0, buf.length, from);
222
+ acc = Buffer.concat([buf, acc]);
223
+ pos = from;
224
+ const lines = acc.toString("utf8").split("\n");
225
+ let i = lines.length - 1;
226
+ while (i >= 0 && !lines[i].trim()) i--;
227
+ // Whole once something precedes it in the buffer, or the file starts here.
228
+ if (i > 0 || (i === 0 && pos === 0)) return lines[i];
229
+ }
230
+ return null;
151
231
  } finally {
152
232
  closeSync(fd);
153
233
  }
@@ -156,44 +236,116 @@ function lastLine(file) {
156
236
  /**
157
237
  * One writer at a time. Several roles of a round run at once and write this
158
238
  * same file; two of them reading the same last line would fork the chain.
159
- * A lock left by a killed process is taken over after a few seconds.
239
+ *
240
+ * The lock names its holder (pid and a nonce). A lock whose holder is gone is
241
+ * taken over at once; one whose holder cannot be read is taken over once it is
242
+ * older than `staleMs`. The wait outlasts that age on purpose: a writer that
243
+ * died holding the lock can never make the next one give up, so the only entry
244
+ * ever dropped is one that waited on a writer still alive — and that drop is
245
+ * counted beside the log (see `append`), because a missing line does not break
246
+ * the chain and would otherwise not show.
160
247
  */
161
- function withLock(file, fn) {
248
+ export function withLock(file, fn, { waitMs = 6000, staleMs = 5000 } = {}) {
162
249
  const lock = file + ".lock";
163
- const deadline = Date.now() + 2000;
250
+ const deadline = Date.now() + waitMs;
251
+ const mine = `${process.pid} ${createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 12)}\n`;
252
+ let acquired = false;
164
253
  for (;;) {
165
254
  try {
166
- closeSync(openSync(lock, "wx"));
255
+ const fd = openSync(lock, "wx");
256
+ try { writeSync(fd, mine); } finally { closeSync(fd); }
257
+ acquired = true;
167
258
  break;
168
259
  } catch (e) {
169
260
  if (e.code !== "EEXIST") throw e;
170
- try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
171
- if (Date.now() > deadline) break; // never block the run over bookkeeping
261
+ if (reapable(lock, staleMs)) continue;
262
+ // Never write without the lock. Doing so forks the hash chain; deleting
263
+ // the lock in `finally` then admits still more writers while its owner is
264
+ // active. The log is bookkeeping and append() may fail without taking the
265
+ // agent down, so dropping this entry is safer than fabricating evidence.
266
+ if (Date.now() > deadline) {
267
+ const error = new Error(`could not acquire log lock within ${waitMs} ms`);
268
+ error.code = "ELOCKED";
269
+ throw error;
270
+ }
172
271
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
173
272
  }
174
273
  }
175
- try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
274
+ try { return fn(); }
275
+ finally {
276
+ // Remove it only while it is still ours: if it was taken over, it now
277
+ // belongs to someone else.
278
+ if (acquired) try { if (readFileSync(lock, "utf8") === mine) unlinkSync(lock); } catch {}
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Is this process still there? False only when the kernel says there is no
284
+ * such process (ESRCH).
285
+ *
286
+ * The one answer both callers need, and it leans the same way for both: an
287
+ * answer that is not "gone" — EPERM (there, and not ours to signal), anything
288
+ * unexpected, a pid that is not a positive integer — counts as alive. For a
289
+ * lock that means waiting on a holder that might be done; for a run directory
290
+ * it means leaving one that might be stale. Both are cheaper than the other
291
+ * mistake: breaking a lock someone holds, or deleting a live run's socket and
292
+ * keys from under it. rundir.js used to call every error but EPERM "dead" and
293
+ * swept on it.
294
+ */
295
+ export function alive(pid) {
296
+ if (!Number.isInteger(pid) || pid <= 0) return true; // not a pid we can ask about; kill(-n) is a group
297
+ try { process.kill(pid, 0); return true; }
298
+ catch (e) { return e?.code !== "ESRCH"; }
299
+ }
300
+
301
+ /**
302
+ * Removes a lock whose holder is gone, and says whether it did.
303
+ *
304
+ * The content is read again just before removing it, so a waiter that decided
305
+ * on one lock does not remove a newer one another waiter has just taken.
306
+ */
307
+ function reapable(lock, staleMs) {
308
+ let seen, mtime;
309
+ try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
310
+ const pid = Number(seen.split(" ")[0]);
311
+ const named = Number.isInteger(pid) && pid > 0;
312
+ // A named holder that is alive is waited on, however long it takes: its age
313
+ // says nothing about whether it is done. An unnamed one (an older seisin, or
314
+ // killed between creating the file and writing its name) goes by age.
315
+ const gone = named ? !alive(pid) : Date.now() - mtime > staleMs;
316
+ if (!gone) return false;
317
+ try { if (readFileSync(lock, "utf8") !== seen) return true; unlinkSync(lock); } catch {}
318
+ return true;
176
319
  }
177
320
 
178
321
  /**
179
322
  * Walk the file and say where the chain breaks.
180
323
  *
181
324
  * Lines written before the chain existed have no `prev`; they are counted as an
182
- * unchained prefix, not reported as tampering. After the first chained line,
183
- * a line without `prev` is itself a break.
325
+ * unchained prefix, not reported as tampering — but only where such a prefix
326
+ * can be: at the head of the oldest segment (`legacy`). After the first chained
327
+ * line, and anywhere in a segment that rotation started, a line without `prev`
328
+ * is itself a break. So is a rotation marker without one: seisin never wrote
329
+ * such a marker.
330
+ *
331
+ * `digest` hashes the unchained prefix and `first` is the hash of the first
332
+ * chained line: what the genesis record pins (see recordGenesis).
184
333
  */
185
- function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
186
- const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
187
- if (!existsSync(file)) return out;
334
+ function walk(file, { seed = GENESIS, startLine = 0, legacy = true } = {}) {
335
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null, digest: null, first: null, rotated: false };
336
+ const prefix = createHash("sha256");
188
337
  let previous = null;
189
338
  let started = false;
190
- for (const line of readFileSync(file, "utf8").split("\n")) {
191
- if (!line.trim()) continue;
339
+ for (const line of readLines(file)) {
192
340
  out.lines++;
193
341
  let e = null;
194
342
  try { e = JSON.parse(line); } catch {}
195
343
  const prev = e?.prev;
196
- if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
344
+ if (out.lines === 1 && e?.event === "rotated") out.rotated = true;
345
+ if (!started && prev === undefined && legacy && !out.rotated) {
346
+ out.unchained++; prefix.update(line + "\n"); previous = line; continue;
347
+ }
348
+ if (!started) out.first = hashOf(line);
197
349
  started = true;
198
350
  const first = out.chained === 0 && out.unchained === 0;
199
351
  // A rotation marker leading a segment we have nothing to check it against —
@@ -202,14 +354,15 @@ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
202
354
  // called a break. For any later segment the seed is the real previous tail,
203
355
  // so the marker's `prev` IS checked, and a whole segment deleted or
204
356
  // reordered in the middle shows.
205
- const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
357
+ const rotationStart = first && e?.event === "rotated" && prev !== undefined && seed === GENESIS;
206
358
  const expected = first ? seed : hashOf(previous);
207
- const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
359
+ const ok = prev !== undefined && (rotationStart || prev === expected || (first && prev === hashOf(previous)));
208
360
  if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
209
361
  out.chained++;
210
362
  previous = line;
211
363
  }
212
364
  out.tail = previous;
365
+ out.digest = out.unchained ? prefix.digest("hex").slice(0, 32) : null;
213
366
  return out;
214
367
  }
215
368
 
@@ -218,6 +371,54 @@ export function verifyChain(file, seed = GENESIS) {
218
371
  return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
219
372
  }
220
373
 
374
+ /**
375
+ * Where the chain begins, written once beside the log.
376
+ *
377
+ * The unchained prefix was taken on trust, and that was the hole: strip `prev`
378
+ * from every line and the whole log becomes "from before the chain", so an
379
+ * edit or a deletion anywhere in it verified as intact. The record pins the
380
+ * boundary the first time a chained line is written by a seisin that knows to
381
+ * — how many legacy lines there were, a digest of them, and the hash of the
382
+ * first chained line — so a log that no longer matches it is broken, not old.
383
+ *
384
+ * Written under the log lock, by `append`, and never rewritten. A log whose
385
+ * record is missing (written by an older seisin and not appended to since, or
386
+ * the record deleted) verifies as before and says the genesis is unrecorded.
387
+ *
388
+ * ── Recorded late ──
389
+ * The record is only as good as the moment it was taken. Written with the
390
+ * first chained line, it pins a start nobody could have edited yet. Written
391
+ * later — a log chained by an older seisin, or a record deleted and re-made by
392
+ * the next append — it pins whatever the log says by then, tampered or not.
393
+ * That is still worth pinning (nothing after it can be rewritten unseen), but
394
+ * it must not read as the same guarantee: such a record says `late`, how many
395
+ * chained lines already stood before it, and `verify` repeats both.
396
+ */
397
+ export function genesisPath(file) {
398
+ return file + ".genesis";
399
+ }
400
+
401
+ function recordGenesis(file) {
402
+ const segs = logSegments(file);
403
+ if (!segs.length) return;
404
+ const r = walk(segs[0]);
405
+ if (!r.first) return; // nothing chained yet: nothing to pin
406
+ // Chained lines that stood before the one `append` just wrote. Any at all
407
+ // means the start is being pinned after the fact.
408
+ let before = r.chained - 1;
409
+ segs.slice(1).forEach((seg) => { before += walk(seg, { legacy: false }).chained; });
410
+ const record = { at: new Date().toISOString(), legacy: r.unchained, digest: r.digest, first: r.first };
411
+ if (before > 0) Object.assign(record, { late: true, after: before });
412
+ writeFileSync(genesisPath(file), JSON.stringify(record) + "\n", { flag: "wx" });
413
+ }
414
+
415
+ function readGenesis(file) {
416
+ try {
417
+ const g = JSON.parse(readFileSync(genesisPath(file), "utf8"));
418
+ return g && Number.isInteger(g.legacy) && typeof g.first === "string" ? g : null;
419
+ } catch { return null; }
420
+ }
421
+
221
422
  /**
222
423
  * Verify the whole log, across every rotated segment, as one chain.
223
424
  *
@@ -227,40 +428,88 @@ export function verifyChain(file, seed = GENESIS) {
227
428
  * single segment and this is exactly `verifyChain`. What it cannot see is a
228
429
  * segment dropped off the *oldest* end by the keep limit: that is retention, not
229
430
  * tampering, and the count of segments is reported so the drop is not silent.
431
+ *
432
+ * `genesis` says whether the chain's starting point was checked against its
433
+ * record: `recorded`, `unrecorded` (no record yet — see recordGenesis), or
434
+ * `pruned` (the segment it described was dropped for retention). `late` is
435
+ * `{ at, after }` when the record was made after chained lines already stood.
436
+ *
437
+ * A record whose first chained hash differs, over a chain that otherwise
438
+ * holds, is not an edit inside this log: it describes a different start.
439
+ * That happens when the operator archives the log by hand and leaves the
440
+ * record behind — or when the log was rewritten from its first line. Both are
441
+ * reported as a break (the second is exactly what the record exists to catch),
442
+ * but marked `foreign`, so `verify` can say which file to move or remove.
230
443
  */
231
444
  export function verifyLog(file) {
232
445
  const segs = logSegments(file);
233
- const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length };
446
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length, dropped: countLines(droppedPath(file)), genesis: "unrecorded", late: null };
447
+ const pinned = readGenesis(file);
234
448
  let seed = GENESIS;
235
- for (const seg of segs) {
236
- const r = walk(seg, { seed, startLine: agg.lines });
449
+ segs.forEach((seg, i) => {
450
+ const r = walk(seg, { seed, startLine: agg.lines, legacy: i === 0 });
451
+ if (i === 0 && pinned) {
452
+ if (r.rotated) agg.genesis = "pruned";
453
+ else {
454
+ agg.genesis = "recorded";
455
+ if (pinned.late) agg.late = { at: pinned.at ?? null, after: Number.isInteger(pinned.after) ? pinned.after : null };
456
+ // Where the first chained line should be, and what it should hash to.
457
+ const at = pinned.legacy + 1;
458
+ // A different first line in a chain that otherwise holds is a
459
+ // different start, not an edit — an edit to the first line would also
460
+ // break the line after it. A log with no chain left at all was not
461
+ // replaced by another seisin log (those chain from their first line):
462
+ // it was stripped.
463
+ if (r.first && r.first !== pinned.first && r.breaks.length === 0)
464
+ r.breaks.unshift({ line: at, expected: pinned.first, found: r.first, genesis: true, foreign: true });
465
+ else if (r.unchained !== pinned.legacy || r.first !== pinned.first)
466
+ r.breaks.unshift({ line: at, expected: pinned.first, found: r.unchained !== pinned.legacy ? null : r.first, genesis: true });
467
+ else if ((r.digest ?? null) !== (pinned.digest ?? null))
468
+ r.breaks.unshift({ line: 1, expected: pinned.digest, found: r.digest, genesis: true });
469
+ }
470
+ }
237
471
  agg.lines += r.lines;
238
472
  agg.unchained += r.unchained;
239
473
  agg.chained += r.chained;
240
474
  agg.breaks.push(...r.breaks);
241
475
  seed = hashOf(r.tail);
242
- }
476
+ });
243
477
  return agg;
244
478
  }
245
479
 
246
- /** Reads entries, newest last. A malformed line is skipped, never fatal. */
480
+ /**
481
+ * Reads entries, newest last, across every segment. A malformed line is
482
+ * skipped, never fatal.
483
+ *
484
+ * It read the current segment only, so the first rotation (8 MiB) took the
485
+ * history out from under everything that reads a window — the console, causes,
486
+ * walls, the staleness of a request — without a word. Segments are read newest
487
+ * first and the walk stops at the first one that ends before `since`, or once
488
+ * `limit` entries are in hand.
489
+ */
247
490
  export function read(file, { role, verdict, since, limit = 0 } = {}) {
248
- if (!existsSync(file)) return [];
249
- let out = [];
250
- for (const line of readFileSync(file, "utf8").split("\n")) {
251
- if (!line.trim()) continue;
252
- let e;
253
- try {
254
- e = JSON.parse(line);
255
- } catch {
256
- continue; // a half-written line from a killed process is not an error
491
+ const parts = [];
492
+ let have = 0;
493
+ for (const seg of logSegments(file).reverse()) {
494
+ if (since && seg !== file) {
495
+ // A retired segment that ended before the window has nothing in it.
496
+ let last = null;
497
+ try { last = JSON.parse(lastLine(seg)); } catch {}
498
+ if (typeof last?.at === "string" && last.at < since) break;
499
+ }
500
+ const got = [];
501
+ for (const e of readEntries(seg)) {
502
+ if (e.event === "rotated") continue; // a structural marker, not a decision
503
+ if (role && e.role !== role) continue;
504
+ if (verdict && e.verdict !== verdict) continue;
505
+ if (since && e.at < since) continue;
506
+ got.push(e);
257
507
  }
258
- if (e.event === "rotated") continue; // a structural marker, not a decision
259
- if (role && e.role !== role) continue;
260
- if (verdict && e.verdict !== verdict) continue;
261
- if (since && e.at < since) continue;
262
- out.push(e);
508
+ parts.unshift(got);
509
+ have += got.length;
510
+ if (limit > 0 && have >= limit) break;
263
511
  }
512
+ let out = parts.flat();
264
513
  if (limit > 0 && out.length > limit) out = out.slice(-limit);
265
514
  return out;
266
515
  }
@@ -285,6 +534,14 @@ export function observed(entries) {
285
534
  const roles = new Map();
286
535
  for (const e of entries) {
287
536
  if (!e.role || !e.target) continue;
537
+ // A line the parent flagged as `disputed` is one whose verdict did not
538
+ // match the policy the run started with — either the config changed under
539
+ // it, or the line was not written by the hook at all. Either way it is not
540
+ // evidence of what the role legitimately reached for, so it must not become
541
+ // policy: `init --from-observations` builds `writes`/`keys` from here, and a
542
+ // process inside the sandbox could otherwise send `verdict:"observed"` lines for
543
+ // paths outside its territory and have them proposed as its own.
544
+ if (e.disputed) continue;
288
545
  const r = roles.get(e.role) ?? { writes: new Set(), keys: new Set() };
289
546
  if (e.action === "read" && e.kind === "key") r.keys.add(e.target);
290
547
  else if (e.action === "write") r.writes.add(e.target);