@awebai/oats 0.22.19 → 0.23.1
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.
- package/README.md +54 -20
- package/bin/oats.mjs +24 -10
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +18 -24
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +2 -2
- package/capabilities/oats-okf/bin/oats-okf.mjs +105 -517
- package/capabilities/oats-okf/injects/okf.md +32 -67
- package/capabilities/oats-okf/lib/config.mjs +112 -0
- package/capabilities/oats-okf/lib/inspection.mjs +96 -0
- package/capabilities/oats-okf/lib/io.mjs +103 -0
- package/capabilities/oats-okf/lib/migration.mjs +116 -0
- package/capabilities/oats-okf/lib/sources.mjs +238 -0
- package/capabilities/oats-okf/lib/stores.mjs +331 -0
- package/capabilities/oats-okf/lib/worker.mjs +352 -0
- package/capabilities/oats-okf/oats.json +23 -7
- package/capabilities/oats-okf/schemas/okf-base.schema.json +46 -0
- package/capabilities/oats-okf/schemas/okf-bindings.schema.json +112 -0
- package/capabilities/oats-okf/schemas/okf-soul.schema.json +37 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +263 -140
- package/capabilities/oats-okf/skills/okf/SKILL.md +13 -4
- package/docs/capabilities.md +14 -3
- package/docs/configuration.md +11 -1
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
- package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
- package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
- package/docs/design/okf-mirror-provenance.md +105 -0
- package/docs/design/package-runtime-api.md +177 -3
- package/docs/desktop-cli-api.md +60 -11
- package/docs/execution-targets.md +16 -0
- package/docs/first-team-demo.md +6 -1
- package/docs/first-team.md +151 -115
- package/docs/integrations.md +42 -42
- package/docs/knowledge-capability-authoring.md +101 -0
- package/docs/knowledge-migration.md +138 -0
- package/docs/knowledge-reference/acceptance.md +108 -0
- package/docs/knowledge-reference/adoption.md +61 -0
- package/docs/knowledge-reference/harvester.md +107 -0
- package/docs/knowledge-reference/model.md +84 -0
- package/docs/knowledge-reference/package-craft.md +126 -0
- package/docs/knowledge-reference/provider-mapping.md +77 -0
- package/docs/knowledge-reference/reader-capture.md +87 -0
- package/docs/knowledge-theory.md +20 -6
- package/docs/knowledge.md +316 -129
- package/docs/layers.md +65 -69
- package/docs/migration-from-oas.md +7 -1
- package/docs/oats-config.schema.json +5 -2
- package/docs/packages.md +26 -2
- package/docs/release-notes/v0.23.0.md +93 -0
- package/docs/release-notes/v0.23.1.md +97 -0
- package/docs/schedules.md +42 -3
- package/docs/souls-and-instances.md +72 -49
- package/injects/work-directory.md +18 -0
- package/lib/core.mjs +279 -56
- package/lib/schedule.mjs +12 -2
- package/package-catalog.json +6 -1
- package/package.json +2 -2
- package/packages/record/README.md +19 -0
- package/packages/record/bin/capture.mjs +96 -48
- package/packages/record/bin/recall.mjs +17 -11
- package/packages/record/bin/record-native-start.mjs +11 -0
- package/packages/record/lib/capture-cc.mjs +82 -27
- package/packages/record/lib/capture-lock.mjs +15 -2
- package/packages/record/lib/formats.mjs +108 -21
- package/packages/record/lib/native-history.mjs +87 -0
- package/packages/record/lib/session-roots.mjs +90 -0
- package/packages/record/lib/session-snapshot.mjs +61 -0
- package/packages/record/lib/sessions-for-home.mjs +88 -56
- package/skills/oats/SKILL.md +3 -1
- package/capabilities/oats-okf/lib/harvest-branch.mjs +0 -43
|
@@ -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
|
-
|
|
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);
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
83
|
-
if (!name.startsWith(".claude")) continue;
|
|
84
|
-
|
|
85
|
-
if (
|
|
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
|
|
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
|
|
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) =>
|
|
272
|
-
sessionId:
|
|
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
|
+
}
|