@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
@@ -16,9 +16,10 @@
16
16
  // snapshots, queue operations, mode flips) yields no docs — but its
17
17
  // native line is still stored verbatim in the turn, so nothing is lost.
18
18
 
19
- import { existsSync, readdirSync } from "node:fs";
20
- import { basename, join } from "node:path";
19
+ import { lstatSync, readdirSync, statSync } from "node:fs";
20
+ import { basename, dirname, join } from "node:path";
21
21
  import { homedir } from "node:os";
22
+ import { nativeDirectory } from "./session-roots.mjs";
22
23
 
23
24
  // Iterate JSONL lines of a buffer without materializing the whole file as
24
25
  // one string — real transcripts reach hundreds of MB (a 789 MB Codex
@@ -53,21 +54,24 @@ function* parsedLines(bytes) {
53
54
  }
54
55
  }
55
56
 
56
- function listJsonlFiles(root, maxDepth) {
57
+ function listJsonlFiles(root, maxDepth, { strict = false } = {}) {
57
58
  const out = [];
58
59
  const walk = (dir, depth) => {
59
60
  let names;
60
61
  try {
61
62
  names = readdirSync(dir, { withFileTypes: true });
62
- } catch {
63
- return;
63
+ } catch (err) {
64
+ // An optional, absent root is normal; an unreadable directory or a
65
+ // subtree that disappeared during discovery is not an empty scan.
66
+ if (!strict && depth === 0 && err.code === "ENOENT") return;
67
+ throw err;
64
68
  }
65
69
  for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) {
66
70
  const path = join(dir, entry.name);
67
- if (entry.isDirectory()) {
71
+ if (entry.name.endsWith(".jsonl") && !entry.isDirectory()) {
72
+ out.push(path); // privacy rules precede opening/statting source links
73
+ } else if (entry.isDirectory() || (entry.isSymbolicLink() && statSync(path).isDirectory())) {
68
74
  if (depth < maxDepth) walk(path, depth + 1);
69
- } else if (entry.name.endsWith(".jsonl")) {
70
- out.push(path);
71
75
  }
72
76
  }
73
77
  };
@@ -75,18 +79,97 @@ function listJsonlFiles(root, maxDepth) {
75
79
  return out;
76
80
  }
77
81
 
82
+ // Only absence makes a default root optional. existsSync also hides access
83
+ // failures, which would turn an unperformed scan into a false empty result.
84
+ function directoryExists(path) {
85
+ try {
86
+ if (!statSync(path).isDirectory()) throw new Error(`session root is not a directory: ${path}`);
87
+ return true;
88
+ } catch (err) {
89
+ if (err.code === "ENOENT") {
90
+ // ENOENT through a dangling directory link is not an absent runtime.
91
+ for (let part = path; ; part = dirname(part)) {
92
+ try {
93
+ if (lstatSync(part).isSymbolicLink()) throw new Error(`unresolvable session root: ${path}`);
94
+ break;
95
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
96
+ if (dirname(part) === part) break;
97
+ }
98
+ return false;
99
+ }
100
+ throw err;
101
+ }
102
+ }
103
+
78
104
  // ------------------------------------------------------------ claude code
79
105
 
80
- function ccRoots(home = homedir()) {
106
+ function configuredRoot(value, suffix, options) {
107
+ const root = join(nativeDirectory(value, options), suffix);
108
+ // Explicitly relocated storage is not an optional absent default. A
109
+ // missing/unreadable root means we cannot certify its evidence inventory.
110
+ if (!directoryExists(root)) throw new Error(`configured session root does not exist: ${root}`);
111
+ return [root];
112
+ }
113
+
114
+ function ccRoots(home = homedir(), env = process.env, options = {}) {
115
+ if (env.CLAUDE_CONFIG_DIR) return configuredRoot(env.CLAUDE_CONFIG_DIR, "projects", { home, ...options });
81
116
  const roots = [];
82
- for (const name of readdirSync(home).sort()) {
83
- if (!name.startsWith(".claude")) continue;
84
- const projects = join(home, name, "projects");
85
- if (existsSync(projects)) roots.push(projects);
117
+ for (const entry of readdirSync(home, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
118
+ if (!entry.name.startsWith(".claude")) continue;
119
+ // ~/.claude.json and its backups are normal config FILES, not roots.
120
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
121
+ if (entry.isSymbolicLink() && !statSync(join(home, entry.name)).isDirectory()) continue;
122
+ const projects = join(home, entry.name, "projects");
123
+ if (directoryExists(projects)) roots.push(projects);
86
124
  }
87
125
  return roots;
88
126
  }
89
127
 
128
+ // Native Claude child transcripts live at
129
+ // projects/<project>/<sessionId>/subagents/agent-*.jsonl, not beside the
130
+ // parent's file. Enumerate only this layout, never arbitrary project files.
131
+ function listCcFiles(root, { strict = false } = {}) {
132
+ const out = [];
133
+ const entries = (dir, optional = false) => {
134
+ try { return readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
135
+ catch (err) { if (optional && err.code === "ENOENT") return []; throw err; }
136
+ };
137
+ const directory = (entry, path) => entry.isDirectory() || (entry.isSymbolicLink() && !entry.name.endsWith(".jsonl") && statSync(path).isDirectory());
138
+ for (const project of entries(root, !strict)) {
139
+ const projectPath = join(root, project.name);
140
+ if (!directory(project, projectPath)) {
141
+ if (project.name.endsWith(".jsonl")) out.push(projectPath);
142
+ continue;
143
+ }
144
+ for (const entry of entries(projectPath)) {
145
+ const path = join(projectPath, entry.name);
146
+ if (!directory(entry, path)) {
147
+ if (entry.name.endsWith(".jsonl")) out.push(path);
148
+ continue;
149
+ }
150
+ // Listing the session directory (rather than treating ENOENT at an
151
+ // assumed subagents path as optional) preserves disappearance errors.
152
+ for (const childDir of entries(path)) {
153
+ if (childDir.name !== "subagents") continue;
154
+ const children = join(path, childDir.name);
155
+ if (!directory(childDir, children)) throw new Error(`session subagents root is not a directory: ${children}`);
156
+ for (const child of entries(children)) {
157
+ if (child.name.endsWith(".jsonl")) out.push(join(children, child.name));
158
+ }
159
+ }
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function ccParentId(path) {
166
+ return basename(dirname(path)) === "subagents" ? basename(dirname(dirname(path))) : null;
167
+ }
168
+ function ccSessionId(path) {
169
+ const parent = ccParentId(path), id = basename(path, ".jsonl");
170
+ return parent ? `${parent}.${id}` : id;
171
+ }
172
+
90
173
  // Cap for the unknown-part fallback: prefix stays searchable, the full
91
174
  // bytes are always in the verbatim blob — this bounds the index, it does
92
175
  // not strip the record.
@@ -178,9 +261,11 @@ export function extractCcText(bytes) {
178
261
 
179
262
  // --------------------------------------------------------------------- pi
180
263
 
181
- function piRoots(home = homedir()) {
264
+ function piRoots(home = homedir(), env = process.env, options = {}) {
265
+ if (env.PI_CODING_AGENT_SESSION_DIR) return configuredRoot(env.PI_CODING_AGENT_SESSION_DIR, "", { home, ...options, tilde: true });
266
+ if (env.PI_CODING_AGENT_DIR) return configuredRoot(env.PI_CODING_AGENT_DIR, "sessions", { home, ...options, tilde: true });
182
267
  const root = join(home, ".pi", "agent", "sessions");
183
- return existsSync(root) ? [root] : [];
268
+ return directoryExists(root) ? [root] : [];
184
269
  }
185
270
 
186
271
  export function extractPiText(bytes) {
@@ -214,9 +299,10 @@ function piSessionId(path) {
214
299
 
215
300
  // ------------------------------------------------------------------ codex
216
301
 
217
- function codexRoots(home = homedir()) {
302
+ function codexRoots(home = homedir(), env = process.env, options = {}) {
303
+ if (env.CODEX_HOME) return configuredRoot(env.CODEX_HOME, "sessions", { home, ...options });
218
304
  const root = join(home, ".codex", "sessions");
219
- return existsSync(root) ? [root] : [];
305
+ return directoryExists(root) ? [root] : [];
220
306
  }
221
307
 
222
308
  export function extractCodexText(bytes) {
@@ -268,21 +354,22 @@ export const SESSION_FORMATS = {
268
354
  cc: {
269
355
  source: "cc",
270
356
  defaultRoots: ccRoots,
271
- listFiles: (roots) => roots.flatMap((r) => listJsonlFiles(r, 1)),
272
- sessionId: (path) => basename(path, ".jsonl"),
357
+ listFiles: (roots, options) => roots.flatMap((r) => listCcFiles(r, options)),
358
+ sessionId: ccSessionId,
359
+ ignoreKeys: (path) => [basename(path, ".jsonl"), ccParentId(path)].filter(Boolean),
273
360
  extractText: extractCcText,
274
361
  },
275
362
  pi: {
276
363
  source: "pi",
277
364
  defaultRoots: piRoots,
278
- listFiles: (roots) => roots.flatMap((r) => listJsonlFiles(r, 1)),
365
+ listFiles: (roots, options) => roots.flatMap((r) => listJsonlFiles(r, 1, options)),
279
366
  sessionId: piSessionId,
280
367
  extractText: extractPiText,
281
368
  },
282
369
  codex: {
283
370
  source: "codex",
284
371
  defaultRoots: codexRoots,
285
- listFiles: (roots) => roots.flatMap((r) => listJsonlFiles(r, 3)),
372
+ listFiles: (roots, options) => roots.flatMap((r) => listJsonlFiles(r, 3, options)),
286
373
  sessionId: codexSessionId,
287
374
  extractText: extractCodexText,
288
375
  },
@@ -0,0 +1,87 @@
1
+ // Independent native record custody. Recipes are relaunch templates, not proof
2
+ // of where a past process wrote. Only the execution-side recorder resolves env.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { nativeLaunchLocations } from "./session-roots.mjs";
7
+
8
+ function canonical(home) {
9
+ try { return realpathSync(home); }
10
+ catch (e) {
11
+ if (e.code !== "ENOENT") throw e;
12
+ home = resolve(home);
13
+ return dirname(home) === home ? home : join(canonical(dirname(home)), basename(home));
14
+ }
15
+ }
16
+ // The source-home leaf is an identity, not a redirectable lookup hint. Resolve
17
+ // parent aliases (e.g. /tmp), but never select another home's authority by
18
+ // following a substituted leaf. Retired homes may legitimately be absent.
19
+ function sourceHome(home) {
20
+ const absolute = resolve(home);
21
+ try {
22
+ const stat = lstatSync(absolute);
23
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("native record source home was substituted; refusing redirected history authority");
24
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
25
+ return join(canonical(dirname(absolute)), basename(absolute));
26
+ }
27
+ export function nativeHistoryPath(home) {
28
+ home = sourceHome(home);
29
+ return join(dirname(home), ".oats-native-record", createHash("sha256").update(home).digest("hex"));
30
+ }
31
+ function atomic(path, value) {
32
+ const tmp = `${path}.${randomUUID()}.tmp`;
33
+ writeFileSync(tmp, JSON.stringify(value) + "\n", { mode: 0o600, flag: "wx" });
34
+ renameSync(tmp, path);
35
+ }
36
+ function manifest(home) {
37
+ const value = JSON.parse(readFileSync(join(nativeHistoryPath(home), "history.json"), "utf8"));
38
+ if (value.version !== 1 || value.home !== sourceHome(home) || typeof value.completeHistory !== "boolean") throw new Error("invalid native record history authority");
39
+ return value;
40
+ }
41
+ // Called only for a newly scaffolded home, before capability hooks. A legacy
42
+ // start can record new locations but cannot invent authority for earlier starts.
43
+ export function initializeNativeHistory(home, { completeHistory = true } = {}) {
44
+ const dir = nativeHistoryPath(home);
45
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
46
+ try { writeFileSync(join(dir, "history.json"), JSON.stringify({ version: 1, home: sourceHome(home), completeHistory }) + "\n", { mode: 0o600, flag: "wx" }); }
47
+ catch (e) { if (e.code !== "EEXIST") throw e; manifest(home); } // retain earlier launches if a name is reused
48
+ }
49
+ export function prepareNativeStart(home, runtime) {
50
+ try { manifest(home); }
51
+ catch (e) {
52
+ if (e.code !== "ENOENT") throw e;
53
+ initializeNativeHistory(home, { completeHistory: false });
54
+ }
55
+ const id = randomUUID();
56
+ atomic(join(nativeHistoryPath(home), `${id}.json`), { version: 1, id, home: sourceHome(home), runtime, state: "pending" });
57
+ return id;
58
+ }
59
+ // Executed under the SAME environment prefix, cwd and native argv as the
60
+ // harness, after the backend shell's startup. No environment map or argv is
61
+ // serialized. Failure leaves pending evidence and prevents the native exec.
62
+ export function recordNativeStart(home, id, runtime, args, env = process.env) {
63
+ manifest(home);
64
+ if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("invalid native record start id");
65
+ const path = join(nativeHistoryPath(home), `${id}.json`);
66
+ const pending = JSON.parse(readFileSync(path, "utf8"));
67
+ if (pending.version !== 1 || pending.id !== id || pending.home !== sourceHome(home) || pending.runtime !== runtime || pending.state !== "pending") throw new Error("invalid native record start receipt");
68
+ const locations = nativeLaunchLocations(runtime, { cwd: home, env, args }).map(canonical);
69
+ atomic(path, { version: 1, id, home: sourceHome(home), runtime, state: "started", startedAt: new Date().toISOString(), locations });
70
+ }
71
+ export function historicalSessionRoots(home) {
72
+ const authority = manifest(home);
73
+ if (!authority.completeHistory) throw new Error("native record roots for earlier launches are unknown; legacy history cannot certify complete capture");
74
+ const roots = { cc: [], pi: [], codex: [] };
75
+ for (const name of readdirSync(nativeHistoryPath(home)).sort()) {
76
+ if (name === "history.json") continue;
77
+ if (!/^[a-f0-9-]{36}\.json$/.test(name)) throw new Error("unrecognized or unfinished native record receipt");
78
+ const row = JSON.parse(readFileSync(join(nativeHistoryPath(home), name), "utf8"));
79
+ const source = { claude: "cc", pi: "pi", codex: "codex" }[row.runtime];
80
+ if (row.version !== 1 || row.id !== basename(name, ".json") || row.home !== authority.home || !source || row.state !== "started" || !Array.isArray(row.locations) || row.locations.length === 0) throw new Error("native record launch is pending or its location receipt is invalid");
81
+ for (const path of row.locations) {
82
+ if (typeof path !== "string" || !path.startsWith("/") || path.includes("\0") || resolve(path) !== path) throw new Error("invalid historical native record location");
83
+ if (!roots[source].includes(path)) roots[source].push(path);
84
+ }
85
+ }
86
+ return roots;
87
+ }
@@ -0,0 +1,90 @@
1
+ // Explicit observer-time fallback only; managed capture uses native-history.
2
+ // Resolve only transcript-location inputs. Never execute recorded launch
3
+ // commands or disclose unrelated environment values (which may be secrets).
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { isAbsolute, join, resolve } from "node:path";
7
+
8
+ const LOCATION_ENV = ["HOME", "CLAUDE_CONFIG_DIR", "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "CODEX_HOME"];
9
+
10
+ export function sourceSessionEnvironment(home, base = process.env) {
11
+ const env = { ...base };
12
+ let meta;
13
+ try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); }
14
+ catch (err) {
15
+ if (err.code !== "ENOENT") throw new Error(`cannot resolve source transcript roots from instance.json (${err.code || "invalid JSON"})`);
16
+ }
17
+ const recipe = meta?.launch;
18
+ if (recipe !== undefined) {
19
+ if (!recipe || recipe.version !== 1 || !["claude", "pi", "codex"].includes(recipe.runtime)) {
20
+ throw new Error("cannot resolve source transcript roots: unsupported recorded launch recipe");
21
+ }
22
+ for (const layer of [recipe.hooks?.env, recipe.env]) {
23
+ if (layer === undefined) continue;
24
+ if (!layer || typeof layer !== "object" || Array.isArray(layer)) throw new Error("cannot resolve source transcript roots: invalid launch environment");
25
+ for (const name of LOCATION_ENV) {
26
+ if (!Object.hasOwn(layer, name)) continue;
27
+ const value = layer[name];
28
+ if (typeof value === "string") env[name] = value;
29
+ else if (value && typeof value.fromEnv === "string" && typeof base[value.fromEnv] === "string") env[name] = base[value.fromEnv];
30
+ else throw new Error(`cannot resolve source transcript roots: unresolved recorded ${name}`);
31
+ }
32
+ }
33
+ // Pi also supports a direct session-directory override. Do not certify
34
+ // default roots when native options direct evidence somewhere else.
35
+ if (recipe.runtime === "pi") {
36
+ const args = recipe.args ?? [];
37
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new Error("cannot resolve source transcript roots: invalid launch arguments");
38
+ for (let i = 0; i < args.length; i++) {
39
+ if (args[i] === "--session-dir" || args[i].startsWith("--session-dir=")) {
40
+ const value = args[i] === "--session-dir" ? args[++i] : args[i].slice("--session-dir=".length);
41
+ if (!value || value.startsWith("--")) throw new Error("cannot resolve source transcript roots: invalid --session-dir");
42
+ env.PI_CODING_AGENT_SESSION_DIR = value;
43
+ } else if (args[i] === "--session" || args[i].startsWith("--session=")) {
44
+ throw new Error("cannot certify transcript roots for a recorded explicit --session; capture its source directory explicitly");
45
+ }
46
+ }
47
+ if (/--session(?:-dir)?(?:[=\s]|$)/.test(recipe.hooks?.launch?.pi ?? "")) {
48
+ throw new Error("cannot resolve source transcript roots from shell-form session options in launch hooks");
49
+ }
50
+ }
51
+ } else if (typeof meta?.command === "string" && /(?:^|\s)(?:CLAUDE_CONFIG_DIR|PI_CODING_AGENT_(?:SESSION_)?DIR|CODEX_HOME|HOME)=|--session(?:-dir)?(?:[=\s]|$)/.test(meta.command)) {
52
+ // Old command strings cannot safely be interpreted as environment maps.
53
+ throw new Error("cannot resolve source transcript roots from a legacy launch command; a recorded launch environment is required");
54
+ }
55
+ const userHome = env.HOME ?? homedir();
56
+ if (!userHome || !isAbsolute(userHome)) throw new Error("cannot resolve source transcript roots: HOME must be absolute");
57
+ return { env, home: userHome };
58
+ }
59
+
60
+ export function nativeDirectory(value, { home = homedir(), cwd = process.cwd(), tilde = false } = {}) {
61
+ if (typeof value !== "string" || !value || value.includes("\0")) throw new Error("cannot resolve native transcript directory");
62
+ if (value.startsWith("~")) {
63
+ if (!tilde || !(value === "~" || value.startsWith("~/"))) throw new Error("cannot resolve unexpanded native transcript directory");
64
+ value = join(home, value.slice(2));
65
+ }
66
+ return resolve(cwd, value);
67
+ }
68
+
69
+ /** Native execution-side locations, without existence filtering. Unlike a
70
+ * background observer scan, Claude's native default is exactly ~/.claude,
71
+ * not every .claude* profile found under an observer's HOME. */
72
+ export function nativeLaunchLocations(runtime, { cwd, env = process.env, args = [] } = {}) {
73
+ const home = env.HOME || homedir();
74
+ if (!isAbsolute(home)) throw new Error("native launch HOME must be absolute");
75
+ if (!Array.isArray(args) || args.some(a => typeof a !== "string")) throw new Error("invalid native launch arguments");
76
+ if (runtime === "claude") return [join(nativeDirectory(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), { home, cwd }), "projects")];
77
+ if (runtime === "codex") return [join(nativeDirectory(env.CODEX_HOME || join(home, ".codex"), { home, cwd }), "sessions")];
78
+ if (runtime !== "pi") throw new Error("unsupported native record runtime");
79
+ let sessionDir = env.PI_CODING_AGENT_SESSION_DIR;
80
+ for (let i = 0; i < args.length; i++) {
81
+ const arg = args[i];
82
+ if (arg === "--session" || arg.startsWith("--session=")) throw new Error("explicit Pi --session has no supported directory custody; capture explicit roots instead");
83
+ if (arg === "--session-dir" || arg.startsWith("--session-dir=")) {
84
+ sessionDir = arg === "--session-dir" ? args[++i] : arg.slice("--session-dir=".length);
85
+ if (!sessionDir || sessionDir.startsWith("--")) throw new Error("invalid native --session-dir");
86
+ }
87
+ }
88
+ return [sessionDir ? nativeDirectory(sessionDir, { home, cwd, tilde: true })
89
+ : join(nativeDirectory(env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent"), { home, cwd, tilde: true }), "sessions")];
90
+ }
@@ -0,0 +1,61 @@
1
+ // File witnesses cross the discovery -> capture-lock boundary. Paths alone
2
+ // are not attribution: a renamed/replaced source must never donate bytes to
3
+ // the previous source's stream. Reads and validation use the SAME descriptor.
4
+ import { createHash } from "node:crypto";
5
+ import { fstatSync, readSync, statSync } from "node:fs";
6
+
7
+ export function identity(stat) {
8
+ if (!stat.isFile()) throw new Error("session source is not a regular file");
9
+ return { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs };
10
+ }
11
+
12
+ export function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino; }
13
+ export function sameVersion(a, b) {
14
+ return sameFile(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
15
+ }
16
+ export function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
17
+
18
+ export function readRange(fd, start, size, path) {
19
+ const buf = Buffer.alloc(size - start);
20
+ let done = 0;
21
+ while (done < buf.length) {
22
+ const n = readSync(fd, buf, done, buf.length - done, start + done);
23
+ if (n === 0) throw new Error(`short read of session source: ${path}`);
24
+ done += n;
25
+ }
26
+ return buf;
27
+ }
28
+
29
+ export function hashPrefix(fd, size, path) {
30
+ const hash = createHash("sha256"), buf = Buffer.alloc(64 * 1024);
31
+ for (let pos = 0; pos < size;) {
32
+ const n = readSync(fd, buf, 0, Math.min(buf.length, size - pos), pos);
33
+ if (n === 0) throw new Error(`short read of session source: ${path}`);
34
+ hash.update(buf.subarray(0, n)); pos += n;
35
+ }
36
+ return hash.digest("hex");
37
+ }
38
+
39
+ export function assertIdentity(stat, expected, path) {
40
+ if (!stat.isFile() || !sameFile(stat, expected) || stat.size < expected.size) {
41
+ throw new Error(`session source identity changed or shrank since attribution: ${path}`);
42
+ }
43
+ }
44
+
45
+ // Append growth is allowed, but only with an identical witnessed prefix.
46
+ // A changed same-size file is not append growth. Verification happens before
47
+ // journal writes; afterwards the buffer to append is independent of the path.
48
+ export function verifySnapshot(fd, path, snapshot) {
49
+ const after = fstatSync(fd), named = statSync(path);
50
+ assertIdentity(after, snapshot, path);
51
+ assertIdentity(named, snapshot, path);
52
+ if (!sameVersion(after, named)) throw new Error(`session source changed during capture: ${path}`);
53
+ if (sameVersion(after, snapshot)) return;
54
+ if (after.size <= snapshot.size || hashPrefix(fd, snapshot.size, path) !== snapshot.hash) {
55
+ throw new Error(`session source changed during capture: ${path}`);
56
+ }
57
+ // A second change while validating is uncertain, not absence of evidence.
58
+ if (!sameVersion(after, fstatSync(fd)) || !sameVersion(after, statSync(path))) {
59
+ throw new Error(`session source changed during verification: ${path}`);
60
+ }
61
+ }
@@ -12,11 +12,14 @@
12
12
  // instance's own, and nothing outside it — not the parent workspace, not a
13
13
  // sibling home — is ever swept in.
14
14
 
15
- import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
16
- import { resolve, sep } from "node:path";
17
- import { StringDecoder } from "node:string_decoder";
15
+ import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
16
+ import { basename, resolve, sep } from "node:path";
17
+ import { isUtf8 } from "node:buffer";
18
18
 
19
19
  import { SESSION_FORMATS } from "./formats.mjs";
20
+ import { hashPrefix, identity, sameVersion, verifySnapshot } from "./session-snapshot.mjs";
21
+ import { sourceSessionEnvironment } from "./session-roots.mjs";
22
+ import { historicalSessionRoots } from "./native-history.mjs";
20
23
 
21
24
  // A session's first lines can be large (Claude Code queue operations and
22
25
  // file-history snapshots run to 100 KB and more) and the first cwd-bearing
@@ -27,64 +30,82 @@ import { SESSION_FORMATS } from "./formats.mjs";
27
30
  const CHUNK_BYTES = 64 * 1024;
28
31
  export const CWD_SCAN_BOUND_BYTES = 8 * 1024 * 1024;
29
32
 
30
- function* wholeLines(path, bound) {
31
- const fd = openSync(path, "r");
32
- try {
33
- const buf = Buffer.alloc(CHUNK_BYTES);
34
- const decoder = new StringDecoder("utf8"); // a multi-byte character may straddle two chunks
35
- let carry = "";
36
- let offset = 0;
37
- while (offset < bound) {
38
- const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
39
- if (n === 0) break;
40
- offset += n;
41
- carry += decoder.write(buf.subarray(0, n));
42
- let nl;
43
- while ((nl = carry.indexOf("\n")) >= 0) {
44
- yield carry.slice(0, nl);
45
- carry = carry.slice(nl + 1);
46
- }
33
+ function* wholeLines(fd, bound) {
34
+ const buf = Buffer.alloc(CHUNK_BYTES);
35
+ let pieces = [], size = 0; // keep raw bytes across UTF-8/chunk boundaries
36
+ let offset = 0;
37
+ while (offset < bound) {
38
+ const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
39
+ if (n === 0) break;
40
+ offset += n;
41
+ let from = 0;
42
+ for (let nl = buf.indexOf(10, from); nl >= 0 && nl < n; nl = buf.indexOf(10, from)) {
43
+ const tail = buf.subarray(from, nl);
44
+ const bytes = pieces.length ? Buffer.concat([...pieces, tail], size + tail.length) : tail;
45
+ // Replacement decoding could fabricate a cwd. Leave corrupt lines
46
+ // unattributed; capture will report incomplete bytes if a later
47
+ // valid line supplies the attribution.
48
+ yield isUtf8(bytes) ? bytes.toString("utf8") : null;
49
+ pieces = []; size = 0; from = nl + 1;
50
+ }
51
+ if (from < n) {
52
+ const piece = Buffer.from(buf.subarray(from, n)); // buf is reused
53
+ pieces.push(piece); size += piece.length;
47
54
  }
48
- // The remainder is a fragment unless the file ended exactly there.
49
- if (carry && offset < bound) yield carry;
50
- } finally {
51
- closeSync(fd);
52
55
  }
56
+ // A JSON-shaped EOF fragment is still an uncommitted native record.
57
+ // Never attribute a source using a line its writer has not terminated.
53
58
  }
54
59
 
55
60
  function cwdOfLine(source, line) {
56
- if (!line.trim()) return undefined;
61
+ if (line === null || !line.trim()) return undefined;
57
62
  let d;
58
63
  try {
59
64
  d = JSON.parse(line);
60
65
  } catch {
61
66
  return undefined; // a non-JSON native line
62
67
  }
63
- if (source === "cc" && typeof d.cwd === "string") return d.cwd;
64
- if (source === "pi" && d.type === "session" && typeof d.cwd === "string") return d.cwd;
65
- if (source === "codex" && d.type === "session_meta" && typeof d.payload?.cwd === "string") return d.payload.cwd;
68
+ if (source === "cc" && typeof d?.cwd === "string") return d.cwd;
69
+ if (source === "pi" && d?.type === "session" && typeof d?.cwd === "string") return d.cwd;
70
+ if (source === "codex" && d?.type === "session_meta" && typeof d.payload?.cwd === "string") return d.payload.cwd;
66
71
  return undefined;
67
72
  }
68
73
 
69
- /** The working directory a session file records, scanning whole lines from
70
- * the start until the first one that carries it, or undefined when none
71
- * does within `bound` bytes (unknown format, torn file, no cwd at all). */
72
- export function sessionCwd(source, path, { bound = CWD_SCAN_BOUND_BYTES } = {}) {
74
+ /** Descriptor-derived attribution and (for accepted cwd) content witness.
75
+ * No cwd within `bound` means unknown format, torn input or no attribution. */
76
+ export function sessionAttribution(source, path, { bound = CWD_SCAN_BOUND_BYTES, acceptCwd = () => true } = {}) {
77
+ const fd = openSync(path, "r");
73
78
  try {
74
- for (const line of wholeLines(path, bound)) {
75
- const cwd = cwdOfLine(source, line);
76
- if (cwd) return cwd;
79
+ const snapshot = identity(fstatSync(fd));
80
+ let cwd;
81
+ for (const line of wholeLines(fd, Math.min(bound, snapshot.size))) {
82
+ cwd = cwdOfLine(source, line);
83
+ if (cwd) break;
77
84
  }
78
- } catch {
79
- return undefined;
80
- }
81
- return undefined;
85
+ // Never hash/read the body of an unrelated or unattributable session.
86
+ if (cwd && acceptCwd(cwd)) snapshot.hash = hashPrefix(fd, snapshot.size, path);
87
+ // Never combine attribution read from an old prefix with a new witness.
88
+ // Discovery requires a stable read; append growth between discovery and
89
+ // capture is supported by the witness's prefix hash.
90
+ const after = identity(fstatSync(fd));
91
+ if (!sameVersion(after, snapshot)) {
92
+ throw new Error(`session source changed during attribution: ${path}`);
93
+ }
94
+ verifySnapshot(fd, path, snapshot);
95
+ return { cwd, snapshot };
96
+ } finally { closeSync(fd); }
97
+ }
98
+
99
+ /** Scan complete lines for the first native cwd; never attribute EOF fragments. */
100
+ export function sessionCwd(source, path, options) {
101
+ return sessionAttribution(source, path, { ...options, acceptCwd: () => false }).cwd;
82
102
  }
83
103
 
84
104
  function canonical(p) {
85
105
  try {
86
106
  return realpathSync(p);
87
- } catch {
107
+ } catch (err) {
108
+ if (err.code !== "ENOENT") throw err;
88
109
  return resolve(p); // a retired home's cwd no longer exists; compare the lexical path
89
110
  }
90
111
  }
@@ -94,35 +115,46 @@ function within(child, parent) {
94
115
  }
95
116
 
96
117
  /** Session files whose recorded cwd is `home` or below it, oldest first.
97
- * `roots` may override the per-format search roots ({ cc, pi, codex }),
98
- * otherwise each format's default roots under the current HOME are used.
118
+ * By default uses independent managed-launch history, never observer env.
119
+ * `roots` is an explicit inventory ({ cc, pi, codex }; omitted formats are
120
+ * excluded). `fallback: "current-env"` explicitly chooses observer-time
121
+ * recipe/env discovery for standalone or legacy sources. All supplied or
122
+ * historical roots must be readable; missing roots are not optional.
99
123
  * `onUnattributed(source, path)` is called for a file that carries no cwd
100
124
  * within the scan bound, so a caller can report it instead of losing it.
101
- * Each entry: { source, sessionId, thread, path, cwd, bytes, mtime }. */
102
- export function sessionsForHome(home, { roots, onUnattributed, bound } = {}) {
125
+ * Read/scan failures throw; they are not unattributed or empty scans.
126
+ * `ignore` excludes files BEFORE reading, with optional `onIgnored`.
127
+ * Each entry includes a descriptor-derived `snapshot` witness; pass the
128
+ * entries intact as captureSessions({ files }) to retain attribution. */
129
+ export function sessionsForHome(home, { roots, onUnattributed, bound, ignore, onIgnored, env = process.env, fallback } = {}) {
103
130
  const target = canonical(home);
104
131
  const out = [];
132
+ // Explicit roots are a caller-owned inventory; unspecified formats are
133
+ // excluded, not filled from an unrelated observer. The opt-in fallback is
134
+ // for standalone/legacy inventories, never proof of historical completeness.
135
+ let context;
136
+ if (!roots && fallback !== "current-env") roots = historicalSessionRoots(home);
137
+ if (!roots) context = sourceSessionEnvironment(home, env);
105
138
  for (const fmt of Object.values(SESSION_FORMATS)) {
106
- const rs = roots?.[fmt.source] ?? fmt.defaultRoots();
107
- for (const path of fmt.listFiles(rs)) {
108
- const cwd = sessionCwd(fmt.source, path, bound ? { bound } : {});
139
+ const rs = roots ? (roots[fmt.source] ?? []) : fmt.defaultRoots(context.home, context.env, { cwd: home });
140
+ for (const path of fmt.listFiles(rs, { strict: true })) {
141
+ const sessionId = fmt.sessionId(path);
142
+ if (ignore?.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
143
+ onIgnored?.(fmt.source, path);
144
+ continue;
145
+ }
146
+ const { cwd, snapshot } = sessionAttribution(fmt.source, path, { bound, acceptCwd: cwd => within(canonical(cwd), target) });
109
147
  if (!cwd) { if (onUnattributed) onUnattributed(fmt.source, path); continue; }
110
148
  if (!within(canonical(cwd), target)) continue;
111
- let stat;
112
- try {
113
- stat = statSync(path);
114
- } catch {
115
- continue; // vanished between listing and stat
116
- }
117
- const sessionId = fmt.sessionId(path);
118
149
  out.push({
119
150
  source: fmt.source,
120
151
  sessionId,
121
152
  thread: `${fmt.source}:session:${sessionId}`,
122
153
  path,
123
154
  cwd,
124
- bytes: stat.size,
125
- mtime: stat.mtime.toISOString(),
155
+ bytes: snapshot.size,
156
+ mtime: new Date(snapshot.mtimeMs).toISOString(),
157
+ snapshot,
126
158
  });
127
159
  }
128
160
  }
@@ -38,7 +38,9 @@ oats status [--json]
38
38
  oats status --team [--json] # whole-team roster when config declares team: (all repos in the team scope)
39
39
  # with the aweb messaging integration active, `oats aweb roster` adds the
40
40
  # cross-machine view: aweb team members, where OATS aliases are instance names
41
- oats create <name> [--description ...] [--type <agent-type>] [--repo ...] [--work worktree|checkout|attached|workspace]
41
+ oats create <name> [--description ...] [--type <agent-type>] [--repo ...] [--work worktree|checkout|attached|workspace|directory]
42
+ # directory mode = owned execution directory; repo is config context (no Git
43
+ # required); --work-dir and --branch are rejected; retirement preserves work.
42
44
  # workspace mode = cross-repo coordinator: ./work is the whole team scope; read
43
45
  # all member repos, edit none; if a knowledge layer is active, IT defines how
44
46
  # soul updates are delivered (see that capability's own instructions)