@awebai/oats 0.22.19 → 0.23.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 (38) hide show
  1. package/README.md +6 -2
  2. package/bin/oats.mjs +24 -10
  3. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
  4. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
  5. package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
  6. package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
  7. package/docs/design/package-runtime-api.md +177 -3
  8. package/docs/desktop-cli-api.md +1 -1
  9. package/docs/execution-targets.md +16 -0
  10. package/docs/knowledge-capability-authoring.md +98 -0
  11. package/docs/knowledge-reference/acceptance.md +108 -0
  12. package/docs/knowledge-reference/adoption.md +61 -0
  13. package/docs/knowledge-reference/harvester.md +107 -0
  14. package/docs/knowledge-reference/model.md +84 -0
  15. package/docs/knowledge-reference/package-craft.md +126 -0
  16. package/docs/knowledge-reference/provider-mapping.md +77 -0
  17. package/docs/knowledge-reference/reader-capture.md +87 -0
  18. package/docs/knowledge-theory.md +20 -6
  19. package/docs/layers.md +8 -7
  20. package/docs/oats-config.schema.json +5 -2
  21. package/docs/release-notes/v0.23.0.md +93 -0
  22. package/docs/souls-and-instances.md +17 -1
  23. package/injects/work-directory.md +18 -0
  24. package/lib/core.mjs +279 -56
  25. package/lib/schedule.mjs +12 -2
  26. package/package.json +2 -2
  27. package/packages/record/README.md +19 -0
  28. package/packages/record/bin/capture.mjs +96 -48
  29. package/packages/record/bin/recall.mjs +17 -11
  30. package/packages/record/bin/record-native-start.mjs +11 -0
  31. package/packages/record/lib/capture-cc.mjs +82 -27
  32. package/packages/record/lib/capture-lock.mjs +15 -2
  33. package/packages/record/lib/formats.mjs +108 -21
  34. package/packages/record/lib/native-history.mjs +87 -0
  35. package/packages/record/lib/session-roots.mjs +90 -0
  36. package/packages/record/lib/session-snapshot.mjs +61 -0
  37. package/packages/record/lib/sessions-for-home.mjs +88 -56
  38. package/skills/oats/SKILL.md +3 -1
package/lib/schedule.mjs CHANGED
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
24
24
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { Cron } from "croner";
27
- import { ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
27
+ import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
28
28
 
29
29
  export const SCHEDULE_FILE = "oats-schedules.json";
30
30
  export const SCHEDULE_API = 1;
@@ -355,7 +355,17 @@ function launchCommand(ws, def, io) {
355
355
  let envelope, timedOut = false, raw = "";
356
356
  if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
357
357
  else {
358
- const env = { ...process.env }; delete env.OATS_INSTANCE; delete env.OATS_INSTANCE_HOME; delete env.PI_AGENT_INSTANCE; delete env.PI_AGENT_HOME;
358
+ const env = { ...process.env };
359
+ // A schedule may be ticked/run-now from inside an unrelated instance.
360
+ // Dispatch belongs to the job's cwd/explicit selectors, never that
361
+ // caller's frozen capability snapshot (including legacy OATS_HOME).
362
+ // Keep host configuration such as OATS_HOME_DIR and credentials intact.
363
+ for (const key of [...RESERVED_LAUNCH_ENV,
364
+ "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
365
+ "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
366
+ "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
367
+ "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
368
+ ]) delete env[key];
359
369
  const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env });
360
370
  timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
361
371
  raw = String(r.stdout || "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.19",
3
+ "version": "0.23.0",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -15,7 +15,7 @@
15
15
  "type": "module",
16
16
  "scripts": {
17
17
  "test": "node scripts/run-tests.mjs",
18
- "check": "node --check lib/core.mjs && node --check lib/packages.mjs && node --check bin/oats.mjs",
18
+ "check": "node scripts/check-package-dry-runs.mjs --syntax-only",
19
19
  "check:pi": "node --experimental-strip-types --check packages/pi/extension/index.ts",
20
20
  "validate": "node scripts/validate-project.mjs",
21
21
  "validate:okf": "node scripts/validate-okf.mjs",
@@ -209,3 +209,22 @@ into an orphaned inode until it restarts.
209
209
 
210
210
  - Deletion via tombstone is eventual: an offline replica retains bytes until
211
211
  it reconnects. The SOT says this plainly; so do we.
212
+
213
+ ## Per-home source authority
214
+
215
+ `capture --home <dir>` defaults to the kernel's independent managed-launch
216
+ record-location history, retained beside the home under `.oats-native-record/`.
217
+ It does not re-resolve old `fromEnv` references using the capturing process.
218
+ Missing history (legacy/standalone), pending launches, and missing historical
219
+ roots fail closed instead of certifying empty observer storage. Runtime switches
220
+ and resumed starts retain earlier roots. A managed scaffold with no starts has
221
+ an empty managed-launch inventory; executing a saved recipe by hand is not a
222
+ managed start.
223
+
224
+ For a deliberately observer-time inventory, explicitly pass `--current-roots`.
225
+ The JSON labels this `sourceRoots: "current-env"`, rather than `"launch-history"`;
226
+ completion then applies only to the chosen current inventory, not historical
227
+ source custody. The library alternative is `sessionsForHome(home, { roots })`:
228
+ unspecified formats are excluded, and missing supplied roots fail. Synthetic
229
+ standalone tests must choose one of these explicitly, not masquerade as a
230
+ managed native launch. Background capture without `--home` is unchanged.
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { watch } from "node:fs";
16
16
  import { homedir, hostname } from "node:os";
17
- import { dirname, join } from "node:path";
17
+ import { join } from "node:path";
18
18
  import process from "node:process";
19
19
 
20
20
  import { RecordStore } from "../lib/store.mjs";
@@ -53,6 +53,7 @@ const BOOL_FLAGS = new Set([
53
53
  "sessions-only",
54
54
  "aw-only",
55
55
  "no-index",
56
+ "current-roots",
56
57
  ]);
57
58
 
58
59
  const USAGE = `capture — land sessions and aw client logs in the turn record.
@@ -65,10 +66,21 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
65
66
  capture --status show store/stream summary, capture nothing
66
67
  capture --home <dir> capture the sessions that ran inside <dir> (an
67
68
  OATS instance home) and print them as JSON:
68
- thread, stream, turn count, first/last turn id.
69
- Tombstoned turns are never a boundary. Codex
70
- keeps a day's rollouts in one directory, so the
71
- pass captures that day; the list is filtered.
69
+ thread, stream, turn count, first/last turn id;
70
+ status, complete, skipped, held, incomplete,
71
+ failed, ignored (explicit privacy exclusions).
72
+ Only complete:true confirms a performed pass
73
+ with no holds, incomplete tails, unattributed
74
+ sources or errors. Lock skips/holds exit 0 but
75
+ report complete:false; failures exit 1.
76
+ Only attributed files are captured, even in
77
+ shared directories. Unattributed non-ignored
78
+ sources conservatively block completion.
79
+ Tombstoned turns are never a boundary.
80
+ capture --current-roots --home <dir>
81
+ explicit observer-time inventory for standalone
82
+ or legacy sources lacking launch history. Not a
83
+ certificate of all historical source locations.
72
84
  capture --install-hint print the Claude Code hook snippet
73
85
  capture --help this text
74
86
  capture --quiet suppress per-pass progress
@@ -188,8 +200,9 @@ function withCaptureLock(fn) {
188
200
  // Never quiet: a stale lock after a killed pass needs the operator, and
189
201
  // the line says exactly what to check and what to remove.
190
202
  const line = `capture: another pass holds ${root}: ${lock.held.recovery}; skipping this pass`;
191
- if (lock.held.liveness === "alive") log(line); else console.error(line);
192
- return { appended: 0, skipped: true };
203
+ if (args.home) { if (!quiet || lock.held.liveness !== "alive") console.error(line); }
204
+ else if (lock.held.liveness === "alive") log(line); else console.error(line);
205
+ return { appended: 0, skipped: true, lock: lock.held };
193
206
  }
194
207
  try {
195
208
  return fn();
@@ -217,7 +230,7 @@ function pass() {
217
230
  if (!args["aw-only"]) {
218
231
  for (const r of captureAllSessions(store, { owner, ignore })) {
219
232
  out.appended += r.appended;
220
- const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : ""]
233
+ const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : "", r.incomplete ? `${r.incomplete} incomplete` : ""]
221
234
  .filter(Boolean)
222
235
  .join(", ");
223
236
  log(
@@ -300,52 +313,85 @@ if (args.status) {
300
313
  // exists for programs.
301
314
  if (args.home) {
302
315
  warnOnStrangerOwner();
303
- const ignore = loadIgnoreOrExit(root);
304
316
  const unattributed = [];
305
- const found = sessionsForHome(args.home, { onUnattributed: (source, path) => unattributed.push({ source, path }) });
317
+ let found = [];
306
318
  const sessions = [];
307
- const dirs = new Map(); // one capture pass per (format, directory)
308
- for (const s of found) dirs.set(`${s.source}\0${dirname(s.path)}`, { format: s.source, dir: dirname(s.path) });
309
- let appended = 0;
310
- const homePass = withCaptureLock(() => {
311
- let n = 0;
312
- for (const { format, dir } of dirs.values()) {
313
- n += captureSessions(store, { owner, roots: [dir], format, ignore }).appended;
319
+ const outcome = { appended: 0, skipped: false, held: 0, incomplete: 0, failed: 0, ignored: 0 };
320
+ const issues = [];
321
+ let error;
322
+ try {
323
+ // Unlike background passes, --home always answers JSON, including errors.
324
+ // Do not exit from inside the lock callback: its finally owns release.
325
+ const ignore = loadIgnore(root);
326
+ found = sessionsForHome(args.home, {
327
+ ignore,
328
+ ...(args["current-roots"] ? { fallback: "current-env" } : {}),
329
+ onIgnored: () => outcome.ignored++,
330
+ onUnattributed: (source, path) => unattributed.push({ source, path }),
331
+ });
332
+ const formats = new Map(); // exact files, not their shared directories
333
+ for (const s of found) {
334
+ if (!formats.has(s.source)) formats.set(s.source, []);
335
+ formats.get(s.source).push(s);
314
336
  }
315
- if (!args["no-index"]) { // same as pass(): an earlier append-only pass may have left unindexed turns
316
- const index = new RecordIndex(store);
317
- try {
318
- index.update();
319
- } finally {
320
- index.close();
337
+ Object.assign(outcome, withCaptureLock(() => {
338
+ for (const [format, files] of formats) {
339
+ const r = captureSessions(store, { owner, files, format, ignore, final: true });
340
+ outcome.appended += r.appended;
341
+ outcome.held += r.held;
342
+ outcome.incomplete += r.incomplete;
343
+ outcome.ignored += r.ignored;
344
+ issues.push(...r.issues);
345
+ }
346
+ if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
347
+ const index = new RecordIndex(store);
348
+ try {
349
+ index.update();
350
+ } finally {
351
+ index.close();
352
+ }
321
353
  }
354
+ return outcome;
355
+ }));
356
+ // A tombstoned turn is hidden everywhere; a boundary naming one would be
357
+ // refused by recall, so boundaries come from the visible turns only.
358
+ const claims = store.tombstoneClaims();
359
+ for (const s of found) {
360
+ const stream = `${owner}~${s.source}.${s.sessionId}`;
361
+ const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
362
+ if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
363
+ sessions.push({
364
+ thread: s.thread,
365
+ source: s.source,
366
+ sessionId: s.sessionId,
367
+ path: s.path,
368
+ cwd: s.cwd,
369
+ stream,
370
+ turns: turns.length,
371
+ firstTurnId: turns[0].id,
372
+ lastTurnId: turns[turns.length - 1].id,
373
+ lastTs: turns[turns.length - 1].ts,
374
+ });
322
375
  }
323
- return { appended: n };
324
- });
325
- appended = homePass.appended;
326
- // A tombstoned turn is hidden everywhere; a boundary naming one would be
327
- // refused by recall, so boundaries come from the visible turns only.
328
- const claims = store.tombstoneClaims();
329
- for (const s of found) {
330
- const stream = `${owner}~${s.source}.${s.sessionId}`;
331
- const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
332
- if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
333
- sessions.push({
334
- thread: s.thread,
335
- source: s.source,
336
- sessionId: s.sessionId,
337
- path: s.path,
338
- cwd: s.cwd,
339
- stream,
340
- turns: turns.length,
341
- firstTurnId: turns[0].id,
342
- lastTurnId: turns[turns.length - 1].id,
343
- lastTs: turns[turns.length - 1].ts,
344
- });
376
+ } catch (err) {
377
+ error = err.message || String(err);
378
+ console.error(`capture pass failed: ${error}`);
379
+ outcome.failed++;
380
+ // captureSessions can throw after appending part of a directory. Do not
381
+ // claim zero (or a complete count) for a partially performed failed pass.
382
+ outcome.appended = null;
383
+ process.exitCode = 1;
345
384
  }
346
- console.log(JSON.stringify({ home: args.home, owner, appended, sessions, ...(unattributed.length ? { unattributed } : {}) }, null, 2));
347
- process.exit(process.exitCode ?? 0); // nonzero when the lock release had to be reported
348
- }
385
+ if (process.exitCode && !outcome.failed) {
386
+ outcome.failed++;
387
+ error = "capture lock release failed; see stderr for recovery";
388
+ }
389
+ const status = outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed.length ? "incomplete" : "complete";
390
+ console.log(JSON.stringify({ home: args.home, owner, ...outcome, status, complete: status === "complete", sessions, sourceRoots: args["current-roots"] ? "current-env" : "launch-history",
391
+ ...(error ? { error } : {}), ...(issues.length ? { issues } : {}), ...(unattributed.length ? { unattributed } : {}),
392
+ }, null, 2));
393
+ // Let stdout drain naturally, including large session-boundary receipts.
394
+ } else {
349
395
 
350
396
  warnOnStrangerOwner();
351
397
  try {
@@ -383,3 +429,5 @@ if (args.watch) {
383
429
  }
384
430
  setInterval(schedule, 15 * 60 * 1000); // reconcile even if events were missed
385
431
  }
432
+
433
+ }
@@ -43,12 +43,13 @@ const store = new RecordStore(root, {});
43
43
  let index;
44
44
  const getIndex = () => (index ??= new RecordIndex(store));
45
45
 
46
+ async function main() {
46
47
  try {
47
48
  if (args.reindex) {
48
49
  const index = getIndex();
49
50
  index.rebuild();
50
51
  console.log(JSON.stringify(index.counts()));
51
- process.exit(0);
52
+ return;
52
53
  }
53
54
 
54
55
  if (args.show) {
@@ -58,21 +59,21 @@ try {
58
59
  const turn = resolveTurn(store, index, args.show, byId);
59
60
  if (!turn) {
60
61
  console.error(`no turn ${args.show}`);
61
- process.exit(1);
62
+ process.exitCode = 1; return;
62
63
  }
63
64
  // Tombstoned turns are hidden from tool output, id lookup included.
64
65
  if (store.hiddenIds(byId).has(args.show)) {
65
66
  console.error(`turn ${args.show} is tombstoned`);
66
- process.exit(1);
67
+ process.exitCode = 1; return;
67
68
  }
68
69
  console.log(JSON.stringify(turn, null, 2));
69
- process.exit(0);
70
+ return;
70
71
  }
71
72
 
72
73
  const query = args._.join(" ").trim();
73
74
  if (!query && !args.thread) {
74
75
  console.error("usage: recall [--kind k] [--thread t] [--from f] [--role r] [--limit n] <query>");
75
- process.exit(2);
76
+ process.exitCode = 2; return;
76
77
  }
77
78
 
78
79
  // Thread turns straight from the journal, in capture SEQUENCE: the order a
@@ -100,12 +101,12 @@ try {
100
101
  let end = turns.length;
101
102
  if (args.after) {
102
103
  const i = ids.indexOf(args.after);
103
- if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exit(1); }
104
+ if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exitCode = 1; return; }
104
105
  start = i + 1;
105
106
  }
106
107
  if (args.until) {
107
108
  const i = ids.indexOf(args.until);
108
- if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exit(1); }
109
+ if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exitCode = 1; return; }
109
110
  end = i + 1;
110
111
  }
111
112
  const cap = args.limit ? Number(args.limit) : Infinity;
@@ -123,7 +124,7 @@ try {
123
124
  return full;
124
125
  });
125
126
  console.log(JSON.stringify({ thread: args.thread, total: turns.length, from: start, to: stop, remaining: end - stop, turns: out }, null, 2));
126
- process.exit(0);
127
+ return;
127
128
  }
128
129
 
129
130
  const index = getIndex();
@@ -150,15 +151,15 @@ try {
150
151
 
151
152
  if (rows.length === 0 && args.json) {
152
153
  console.log("[]");
153
- process.exit(0);
154
+ return;
154
155
  }
155
156
  if (rows.length === 0) {
156
157
  console.error("no matches");
157
- process.exit(1);
158
+ process.exitCode = 1; return;
158
159
  }
159
160
  if (args.json) {
160
161
  console.log(JSON.stringify(rows, null, 2));
161
- process.exit(0);
162
+ return;
162
163
  }
163
164
  for (const r of rows) {
164
165
  const where = r.loc ? ` @${r.loc}` : "";
@@ -170,3 +171,8 @@ try {
170
171
  } finally {
171
172
  index?.close();
172
173
  }
174
+
175
+ }
176
+ // Natural process termination drains piped stdout; process.exit after a JSON
177
+ // write truncates large recall windows even though it reports exit status 0.
178
+ await main();
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // The environment/argv stays in this process, never in the custody receipt.
3
+ import { recordNativeStart } from "../lib/native-history.mjs";
4
+ try {
5
+ const [home, id, runtime, args] = process.argv.slice(2);
6
+ recordNativeStart(home, id, runtime, JSON.parse(args));
7
+ } catch {
8
+ // Native paths can contain user-chosen data; never echo argv or env on errors.
9
+ console.error("native record location receipt failed; launch refused and pending custody retained");
10
+ process.exitCode = 1;
11
+ }
@@ -20,12 +20,14 @@
20
20
  // ignore.mjs) are skipped before being opened: no turn, no offset entry —
21
21
  // un-ignoring a file later makes the next pass capture it normally.
22
22
 
23
- import { closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
23
+ import { isUtf8 } from "node:buffer";
24
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
24
25
  import { basename, dirname, join } from "node:path";
25
26
 
26
27
  import { finishTurn } from "./canonical.mjs";
27
28
  import { jsonlLines, SESSION_FORMATS } from "./formats.mjs";
28
29
  import { loadIgnore } from "./ignore.mjs";
30
+ import { assertIdentity, digest, identity, readRange, verifySnapshot } from "./session-snapshot.mjs";
29
31
 
30
32
  export const SESSION_STREAM_SOURCE = "cc";
31
33
 
@@ -49,7 +51,7 @@ export function scanTranscript(bytes) {
49
51
  if (text === null) continue;
50
52
  try {
51
53
  const d = JSON.parse(text);
52
- if (typeof d.timestamp === "string") ts = d.timestamp;
54
+ if (typeof d?.timestamp === "string") ts = d.timestamp;
53
55
  } catch {
54
56
  /* verbatim content; nothing to extract */
55
57
  }
@@ -83,8 +85,11 @@ function offsetsPath(store) {
83
85
  function loadOffsets(store) {
84
86
  try {
85
87
  return JSON.parse(readFileSync(offsetsPath(store), "utf8"));
86
- } catch {
87
- return {};
88
+ } catch (err) {
89
+ // This cache is derived, so malformed JSON can be rebuilt. An I/O
90
+ // failure is different: never hide an unreadable cache as missing.
91
+ if (err.code === "ENOENT" || err instanceof SyntaxError) return {};
92
+ throw err;
88
93
  }
89
94
  }
90
95
 
@@ -102,7 +107,7 @@ function readFrom(path, start, size) {
102
107
  let done = 0;
103
108
  while (done < buf.length) {
104
109
  const n = readSync(fd, buf, done, buf.length - done, start + done);
105
- if (n === 0) break;
110
+ if (n === 0) throw new Error(`short read of session/journal source: ${path}`);
106
111
  done += n;
107
112
  }
108
113
  return buf.subarray(0, done);
@@ -119,8 +124,9 @@ function lastJournalLine(store, streamId) {
119
124
  let size;
120
125
  try {
121
126
  size = statSync(path).size;
122
- } catch {
123
- return 0;
127
+ } catch (err) {
128
+ if (err.code === "ENOENT") return 0;
129
+ throw err;
124
130
  }
125
131
  let window = 64 * 1024;
126
132
  while (true) {
@@ -152,12 +158,12 @@ function lastJournalLine(store, streamId) {
152
158
  // Honest limit: an in-place REWRITE of already-captured lines is not
153
159
  // detected (only growth is; a shrink triggers a rescan via the size
154
160
  // check in the caller). Transcript writers are append-only in practice.
155
- function offsetFromJournal(store, streamId, sourcePath) {
161
+ function offsetFromJournal(store, streamId, sourcePath, final, sourceBytes) {
156
162
  const turns = store.readStream(streamId);
157
163
  if (turns.length === 0) return { bytes: 0, line: 0, lastTs: "" };
158
164
  const last = turns[turns.length - 1];
159
165
  const lastLine = last.provenance?.origin?.line ?? 0;
160
- const bytes = readFileSync(sourcePath);
166
+ const bytes = sourceBytes ?? readFileSync(sourcePath);
161
167
  let line = 0;
162
168
  let offset = 0;
163
169
  while (line < lastLine && offset < bytes.length) {
@@ -166,6 +172,7 @@ function offsetFromJournal(store, streamId, sourcePath) {
166
172
  line++;
167
173
  offset = nl + 1;
168
174
  }
175
+ if (final && line < lastLine) throw new Error(`session source is shorter than its captured journal: ${sourcePath}`);
169
176
  return { bytes: offset, line, lastTs: last.ts ?? "" };
170
177
  }
171
178
 
@@ -173,7 +180,14 @@ function offsetFromJournal(store, streamId, sourcePath) {
173
180
  // of every session file under `roots`. Unstamped leading lines are held
174
181
  // until the file shows its first timestamp (then they carry it forward),
175
182
  // so every turn is stamped and ts stays a pure function of the source.
176
- export function captureSessions(store, { owner, roots, format = "cc", ignore = null }) {
183
+ // `files` accepts discovery entries (including their attribution snapshot) or
184
+ // explicit paths for callers not making home-attribution claims. It pins a set,
185
+ // rather than rescanning directories
186
+ // and silently losing disappeared sources (or sweeping in other homes).
187
+ // `final` also verifies unchanged offsets against journals and checks source
188
+ // stability through the pass. The caller must quiesce writers for retirement;
189
+ // a performed pass is a snapshot, not a promise about future writes.
190
+ export function captureSessions(store, { owner, roots, files, format = "cc", ignore = null, final = false }) {
177
191
  const fmt = SESSION_FORMATS[format];
178
192
  if (!fmt) throw new Error(`unknown session format ${format}`);
179
193
  const ign = ignore ?? loadIgnore(store.root);
@@ -183,21 +197,35 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
183
197
  let unchanged = 0;
184
198
  let held = 0;
185
199
  let ignored = 0;
200
+ let incomplete = 0;
201
+ const issues = []; // source metadata only, never native record contents
186
202
  const streams = new Set();
187
203
 
188
- for (const path of fmt.listFiles(roots)) {
204
+ for (const file of files ?? fmt.listFiles(roots)) {
205
+ const path = typeof file === "string" ? file : file.path;
206
+ const expected = typeof file === "string" ? null : file.snapshot;
189
207
  sessions++;
190
208
  const sessionId = fmt.sessionId(path);
191
- if (ign.ignores(path, [basename(path), sessionId])) {
209
+ if (ign.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
192
210
  ignored++;
193
211
  continue; // never opened: nothing stored, nothing remembered
194
212
  }
195
- let stat;
213
+ const fd = openSync(path, "r");
196
214
  try {
197
- stat = statSync(path);
198
- } catch {
199
- continue; // vanished between listing and stat; next pass catches it
215
+ const stat = fstatSync(fd);
216
+ const snapshot = identity(stat);
217
+ if (expected) assertIdentity(stat, expected, path); // BEFORE reading bytes
218
+ // Final home capture stages a descriptor-pinned snapshot. All attribution
219
+ // and stability checks precede the first append, never a post-write alarm.
220
+ const sourceBytes = final || expected ? readRange(fd, 0, stat.size, path) : undefined;
221
+ if (expected && digest(sourceBytes.subarray(0, expected.size)) !== expected.hash) {
222
+ throw new Error(`session source content changed since attribution: ${path}`);
200
223
  }
224
+ if (sourceBytes) snapshot.hash = digest(sourceBytes);
225
+ const verifySource = () => {
226
+ if (sourceBytes) verifySnapshot(fd, path, snapshot);
227
+ };
228
+ verifySource();
201
229
  const streamId = `${owner}~${fmt.source}.${sessionId}`;
202
230
  // Keyed by stream, not by source path: the same source captured under
203
231
  // two owners must not share offset state (owner is part of the stream).
@@ -210,31 +238,44 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
210
238
  // saved offsets for appends that landed in an unlinked inode (skipping
211
239
  // would lose lines — this happened during the live migration). The
212
240
  // journal is the truth; before appending anything, any disagreement
213
- // rebuilds the offset from it (checked only when the source grew:
214
- // an unchanged file appends nothing, so its cache cannot mislead).
215
- if (state && stat.size > state.bytes && state.line !== lastJournalLine(store, streamId)) {
241
+ // rebuilds the offset from it. Background passes check on growth;
242
+ // final passes also verify unchanged files before confirming capture.
243
+ if (state && (final || stat.size > state.bytes) && state.line !== lastJournalLine(store, streamId)) {
216
244
  state = null;
217
245
  }
218
- if (!state) state = offsetFromJournal(store, streamId, path);
246
+ if (!state) state = offsetFromJournal(store, streamId, path, final, sourceBytes);
219
247
  if (stat.size <= state.bytes) {
220
248
  unchanged++;
221
249
  offsets[offKey] = state;
250
+ verifySource();
222
251
  continue;
223
252
  }
224
253
 
225
- const chunk = readFrom(path, state.bytes, stat.size);
254
+ const chunk = sourceBytes ? sourceBytes.subarray(state.bytes) : readRange(fd, state.bytes, stat.size, path);
226
255
  // Phase 1: collect the COMPLETE lines of the chunk with their stamps.
227
256
  const lines = [];
228
257
  let scanned = 0;
229
- for (const { text } of jsonlLines(chunk)) {
230
- if (text === null) break; // over-string-limit line: retry later
231
- const lineBytes = Buffer.byteLength(text, "utf8") + 1;
232
- if (scanned + lineBytes > chunk.length) break; // no trailing newline yet
258
+ let reason;
259
+ while (scanned < chunk.length) {
260
+ const nl = chunk.indexOf(10, scanned);
261
+ if (nl === -1) { reason = "torn-tail"; break; }
262
+ const bytes = chunk.subarray(scanned, nl);
263
+ // Decoding replacement characters would change both the verbatim line
264
+ // and its byte offset, possibly treating a later fragment as a record.
265
+ if (!isUtf8(bytes)) { reason = "invalid-utf8"; break; }
266
+ let text;
267
+ try { text = bytes.toString("utf8"); }
268
+ catch (err) {
269
+ if (err.code !== "ERR_STRING_TOO_LONG") throw err;
270
+ reason = "oversized-line";
271
+ break;
272
+ }
273
+ const lineBytes = nl - scanned + 1;
233
274
  let ts = "";
234
275
  if (text.trim() !== "") {
235
276
  try {
236
277
  const d = JSON.parse(text);
237
- if (typeof d.timestamp === "string") ts = d.timestamp;
278
+ if (typeof d?.timestamp === "string") ts = d.timestamp;
238
279
  } catch {
239
280
  /* unparseable native line: captured verbatim below */
240
281
  }
@@ -242,6 +283,10 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
242
283
  lines.push({ text, ts, bytes: lineBytes });
243
284
  scanned += lineBytes;
244
285
  }
286
+ if (reason) {
287
+ incomplete++;
288
+ issues.push({ source: fmt.source, path, reason, offset: state.bytes + scanned });
289
+ }
245
290
  // Phase 2: every turn needs a stamp. Leading lines before the file's
246
291
  // first stamp carry it backward (deterministic: the file's first
247
292
  // stamp is invariant however capture is scheduled); if the file has
@@ -250,11 +295,16 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
250
295
  if (!lastTs) {
251
296
  const first = lines.find((l) => l.ts);
252
297
  if (!first) {
253
- if (lines.length > 0) held++;
298
+ if (lines.length > 0) {
299
+ held++;
300
+ issues.push({ source: fmt.source, path, reason: "unstamped", offset: state.bytes });
301
+ }
302
+ verifySource();
254
303
  continue; // do not advance; retry when a stamp exists
255
304
  }
256
305
  lastTs = first.ts;
257
306
  }
307
+ verifySource(); // parsing/recovery may take time; still no journal write yet
258
308
  // Turns flush to the journal in bounded batches, so memory stays flat
259
309
  // however large the backlog (a first capture of a huge transcript is
260
310
  // one file's worth of NEW lines). A crash between flushes cannot
@@ -295,6 +345,8 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
295
345
  // re-appends duplicate turn lines — logically deduped by id, but
296
346
  // wasted append-only bytes).
297
347
  if (grew) saveOffsets(store, offsets);
348
+ verifySource();
349
+ } finally { closeSync(fd); }
298
350
  }
299
351
  saveOffsets(store, offsets);
300
352
  return {
@@ -303,6 +355,9 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
303
355
  unchanged,
304
356
  held,
305
357
  ignored,
358
+ incomplete,
359
+ complete: held === 0 && incomplete === 0,
360
+ issues,
306
361
  streams: streams.size,
307
362
  stream: `${owner}~${fmt.source}.*`,
308
363
  };
@@ -63,7 +63,7 @@ export function recoveryInstruction(dir, owner, liveness) {
63
63
  *
64
64
  * `io` exists for fault injection in tests only. */
65
65
  export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, io = {} } = {}) {
66
- const fs = { writeFileSync, rmSync, openSync, closeSync, ...io };
66
+ const fs = { writeFileSync, rmSync, openSync, closeSync, lstatSync, ...io };
67
67
  const dir = captureLockPath(root);
68
68
  mkdirSync(root, { recursive: true }); // the store creates the root lazily; the lock may come first
69
69
  try {
@@ -116,7 +116,20 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
116
116
  if (cur.pid !== pid || cur.nonce !== nonce) return { released: false, reason: "not-owner", owner: cur };
117
117
  let error;
118
118
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { error = e; }
119
- if (!existsSync(dir)) return { released: true };
119
+ let remains;
120
+ try { fs.lstatSync(dir); remains = true; }
121
+ catch (err) {
122
+ if (err.code === "ENOENT") remains = false;
123
+ else return { released: false, reason: "remove-failed", error: err.message,
124
+ recovery: "could not verify capture lock removal; inspect the filesystem error and rerun capture" };
125
+ }
126
+ if (!remains) {
127
+ // A removal may throw after changing the filesystem. Absence does
128
+ // not erase that failure from a final-capture receipt.
129
+ if (error) return { released: false, reason: "remove-failed", error: error.message,
130
+ recovery: "the lock is now absent, but removal reported an error; inspect the failure and rerun capture" };
131
+ return { released: true };
132
+ }
120
133
  // Our own lock could not be removed. We are the holder and, as far as
121
134
  // this process can tell, alive; the operator gets a conditional line.
122
135
  const live = pid === process.pid ? "alive" : liveness(pid);