@awebai/oats 0.23.2 → 0.24.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.
Files changed (172) hide show
  1. package/README.md +224 -391
  2. package/bin/oats-pi-sdk-host.mjs +17 -0
  3. package/bin/oats.mjs +470 -51
  4. package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
  5. package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
  6. package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
  7. package/capabilities/oats-okf/lib/captured-worker.mjs +109 -0
  8. package/capabilities/oats-okf/lib/config.mjs +2 -1
  9. package/capabilities/oats-okf/lib/inspection.mjs +16 -1
  10. package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
  11. package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
  12. package/capabilities/oats-okf/lib/io.mjs +1 -1
  13. package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
  14. package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
  15. package/capabilities/oats-okf/lib/sources.mjs +123 -3
  16. package/capabilities/oats-okf/lib/stores.mjs +104 -25
  17. package/capabilities/oats-okf/lib/worker.mjs +69 -10
  18. package/capabilities/oats-okf/oats.json +35 -7
  19. package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
  20. package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
  21. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
  22. package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
  23. package/docs/artifact-approvals.schema.json +7 -0
  24. package/docs/capabilities.md +4 -0
  25. package/docs/capability-manifest.schema.json +37 -66
  26. package/docs/captured-invocation-context.schema.json +7 -0
  27. package/docs/captured-resolution.schema.json +7 -0
  28. package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
  29. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
  30. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
  31. package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
  32. package/docs/design/2026-09-15-captured-dispatch.md +127 -0
  33. package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
  34. package/docs/design/2026-09-15-package-preparation.md +100 -0
  35. package/docs/design/2026-09-15-portable-data-contract.md +121 -0
  36. package/docs/design/2026-09-15-portable-declarations.md +189 -0
  37. package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
  38. package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
  39. package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
  40. package/docs/design/2026-09-15-source-observation.md +119 -0
  41. package/docs/design/2026-09-16-captured-admission.md +77 -0
  42. package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
  43. package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
  44. package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
  45. package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
  46. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
  47. package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
  48. package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
  49. package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
  50. package/docs/design/2026-09-16-portable-onboarding.md +177 -0
  51. package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
  52. package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
  53. package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
  54. package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
  55. package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
  56. package/docs/design/2026-09-17-captured-native-start.md +58 -0
  57. package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
  58. package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
  59. package/docs/design/2026-09-17-public-captured-start.md +108 -0
  60. package/docs/design/2026-09-17-public-prepare-request.md +90 -0
  61. package/docs/design/2026-09-18-captured-pi-host.md +205 -0
  62. package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
  63. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
  64. package/docs/design/2026-09-20-redesign-program-board.md +70 -0
  65. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +287 -0
  66. package/docs/design/2026-09-20-workspace-onboarding-public.md +124 -0
  67. package/docs/design/README.md +42 -0
  68. package/docs/desktop-cli-api.md +5 -2
  69. package/docs/execution-capsule.schema.json +108 -0
  70. package/docs/execution-targets.md +20 -7
  71. package/docs/first-team.md +1 -1
  72. package/docs/knowledge-theory.md +353 -111
  73. package/docs/knowledge.md +10 -1
  74. package/docs/layers.md +89 -354
  75. package/docs/oats-lock-v3.schema.json +7 -0
  76. package/docs/oats-member.schema.json +38 -0
  77. package/docs/oats-workspace.schema.json +68 -0
  78. package/docs/official-marketplace.md +79 -0
  79. package/docs/packages.md +4 -0
  80. package/docs/portable.schema.json +2512 -0
  81. package/docs/provider-check-input.schema.json +7 -0
  82. package/docs/release-notes/v0.24.0.md +104 -0
  83. package/docs/release-notes/v0.24.1.md +17 -0
  84. package/docs/schedules.md +126 -14
  85. package/docs/soul.schema.json +82 -0
  86. package/docs/souls-and-instances.md +20 -7
  87. package/docs/workspace-adoption.md +285 -0
  88. package/docs/workspaces.md +154 -0
  89. package/injects/oats-portable.md +16 -0
  90. package/injects/portable-instance-boundary.md +39 -0
  91. package/injects/portable-work-directory.md +29 -0
  92. package/lib/artifact-approvals.mjs +120 -0
  93. package/lib/artifact-tree.mjs +141 -0
  94. package/lib/capability-artifacts.mjs +179 -0
  95. package/lib/capability-execution.mjs +15 -0
  96. package/lib/capability-inputs.mjs +39 -0
  97. package/lib/capability-provenance.mjs +231 -0
  98. package/lib/captured-action-shape.mjs +21 -0
  99. package/lib/captured-admission-shape.mjs +20 -0
  100. package/lib/captured-binding-file.mjs +36 -0
  101. package/lib/captured-dispatch.mjs +66 -0
  102. package/lib/captured-instance-index.mjs +277 -0
  103. package/lib/captured-invocation-context.mjs +130 -0
  104. package/lib/captured-launch-request.mjs +46 -0
  105. package/lib/captured-operation-process.mjs +15 -0
  106. package/lib/captured-pi-custody.mjs +29 -0
  107. package/lib/captured-pi-host.mjs +167 -0
  108. package/lib/captured-pi-outcome.mjs +172 -0
  109. package/lib/captured-resolutions.mjs +275 -0
  110. package/lib/captured-scaffold.mjs +87 -0
  111. package/lib/captured-selector.mjs +28 -0
  112. package/lib/captured-session-backend.mjs +52 -0
  113. package/lib/captured-source-receipt-file.mjs +72 -0
  114. package/lib/config-data.mjs +104 -0
  115. package/lib/core.mjs +961 -566
  116. package/lib/errors.mjs +7 -0
  117. package/lib/helper-injection-policy.mjs +98 -0
  118. package/lib/herdr.mjs +18 -7
  119. package/lib/instruction-composition.mjs +31 -0
  120. package/lib/legacy-lock-codec.mjs +106 -0
  121. package/lib/manifest-settings.mjs +84 -0
  122. package/lib/package-closure.mjs +48 -0
  123. package/lib/package-materialization.mjs +83 -0
  124. package/lib/pi-sdk-host.mjs +229 -0
  125. package/lib/portable-artifacts.mjs +115 -0
  126. package/lib/portable-choices.mjs +82 -0
  127. package/lib/portable-composition.mjs +136 -0
  128. package/lib/portable-digest.mjs +105 -0
  129. package/lib/portable-files.mjs +26 -0
  130. package/lib/portable-identity.mjs +40 -0
  131. package/lib/portable-lock.mjs +117 -0
  132. package/lib/portable-migration-artifacts.mjs +135 -0
  133. package/lib/portable-migration-evidence.mjs +305 -0
  134. package/lib/portable-migration-store.mjs +199 -0
  135. package/lib/portable-migration.mjs +104 -0
  136. package/lib/portable-onboarding-acceptance.mjs +66 -0
  137. package/lib/portable-onboarding-request.mjs +49 -0
  138. package/lib/portable-onboarding.mjs +249 -0
  139. package/lib/portable-package-preparation.mjs +188 -0
  140. package/lib/portable-policy.mjs +44 -0
  141. package/lib/portable-shape.mjs +35 -0
  142. package/lib/portable-soul.mjs +38 -0
  143. package/lib/portable-state.mjs +80 -0
  144. package/lib/portable-values.mjs +181 -0
  145. package/lib/prepare-composition.mjs +151 -0
  146. package/lib/prepared-bindings.mjs +78 -0
  147. package/lib/prepared-resources.mjs +127 -0
  148. package/lib/provider-binding-broker.mjs +59 -0
  149. package/lib/provider-binding-wire.mjs +110 -0
  150. package/lib/provider-binding.mjs +22 -0
  151. package/lib/repository-observation.mjs +226 -0
  152. package/lib/resolution-shape.mjs +393 -0
  153. package/lib/schedule-capsule.mjs +206 -0
  154. package/lib/schedule.mjs +259 -38
  155. package/lib/servers.mjs +15 -0
  156. package/lib/soul-constraints.mjs +40 -0
  157. package/lib/source-projection.mjs +84 -0
  158. package/lib/source-spec.mjs +189 -0
  159. package/lib/workspace-definition.mjs +126 -0
  160. package/lib/workspace-discovery.mjs +146 -0
  161. package/package-catalog.json +2 -1
  162. package/package.json +3 -2
  163. package/packages/record/lib/capture-cc.mjs +14 -6
  164. package/packages/record/lib/formats.mjs +14 -3
  165. package/packages/record/lib/native-history.mjs +277 -7
  166. package/packages/record/lib/session-snapshot.mjs +25 -5
  167. package/packages/record/lib/sessions-for-home.mjs +30 -13
  168. package/skills/oats/SKILL.md +12 -7
  169. package/skills/oats-config/SKILL.md +11 -9
  170. package/skills/oats-packages/SKILL.md +12 -8
  171. package/skills/oats-portable/SKILL.md +115 -0
  172. package/skills/oats-portable-artifacts/SKILL.md +63 -0
@@ -20,6 +20,7 @@ import { lstatSync, readdirSync, statSync } from "node:fs";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
  import { nativeDirectory } from "./session-roots.mjs";
23
+ import { guardCapturedPath } from "./native-history.mjs";
23
24
 
24
25
  // Iterate JSONL lines of a buffer without materializing the whole file as
25
26
  // one string — real transcripts reach hundreds of MB (a 789 MB Codex
@@ -54,9 +55,16 @@ function* parsedLines(bytes) {
54
55
  }
55
56
  }
56
57
 
57
- function listJsonlFiles(root, maxDepth, { strict = false } = {}) {
58
+ function listJsonlFiles(root, maxDepth, { strict = false } = {}, allowCaptured = false) {
59
+ const capturedPi = typeof root === "object" && root !== null ? root.capturedPi : undefined;
60
+ if (typeof root !== "string") {
61
+ if (!allowCaptured || !capturedPi || Object.keys(root).some(k => !["path", "capturedPi"].includes(k))) throw new Error("unsupported protected session inventory");
62
+ root = root.path;
63
+ }
64
+ guardCapturedPath(root, capturedPi);
58
65
  const out = [];
59
66
  const walk = (dir, depth) => {
67
+ guardCapturedPath(dir, capturedPi);
60
68
  let names;
61
69
  try {
62
70
  names = readdirSync(dir, { withFileTypes: true });
@@ -68,14 +76,16 @@ function listJsonlFiles(root, maxDepth, { strict = false } = {}) {
68
76
  }
69
77
  for (const entry of names.sort((a, b) => a.name.localeCompare(b.name))) {
70
78
  const path = join(dir, entry.name);
79
+ guardCapturedPath(path, capturedPi); // protected links refuse before traversal or native reads
71
80
  if (entry.name.endsWith(".jsonl") && !entry.isDirectory()) {
72
- out.push(path); // privacy rules precede opening/statting source links
81
+ out.push(capturedPi ? { path, capturedPi } : path); // privacy rules precede opening/statting source links
73
82
  } else if (entry.isDirectory() || (entry.isSymbolicLink() && statSync(path).isDirectory())) {
74
83
  if (depth < maxDepth) walk(path, depth + 1);
75
84
  }
76
85
  }
77
86
  };
78
87
  walk(root, 0);
88
+ guardCapturedPath(root, capturedPi);
79
89
  return out;
80
90
  }
81
91
 
@@ -131,6 +141,7 @@ function ccRoots(home = homedir(), env = process.env, options = {}) {
131
141
  function listCcFiles(root, { strict = false } = {}) {
132
142
  const out = [];
133
143
  const entries = (dir, optional = false) => {
144
+ guardCapturedPath(dir);
134
145
  try { return readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
135
146
  catch (err) { if (optional && err.code === "ENOENT") return []; throw err; }
136
147
  };
@@ -362,7 +373,7 @@ export const SESSION_FORMATS = {
362
373
  pi: {
363
374
  source: "pi",
364
375
  defaultRoots: piRoots,
365
- listFiles: (roots, options) => roots.flatMap((r) => listJsonlFiles(r, 1, options)),
376
+ listFiles: (roots, options) => roots.flatMap((r) => listJsonlFiles(r, 1, options, true)),
366
377
  sessionId: piSessionId,
367
378
  extractText: extractPiText,
368
379
  },
@@ -1,8 +1,10 @@
1
1
  // Independent native record custody. Recipes are relaunch templates, not proof
2
2
  // of where a past process wrote. Only the execution-side recorder resolves env.
3
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";
4
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { isUtf8 } from "node:buffer";
6
8
  import { nativeLaunchLocations } from "./session-roots.mjs";
7
9
 
8
10
  function canonical(home) {
@@ -34,7 +36,8 @@ function atomic(path, value) {
34
36
  renameSync(tmp, path);
35
37
  }
36
38
  function manifest(home) {
37
- const value = JSON.parse(readFileSync(join(nativeHistoryPath(home), "history.json"), "utf8"));
39
+ const value = privateJson(join(nativeHistoryPath(home), "history.json"));
40
+ if (value.version === 2) return validateManifest(value, sourceHome(home));
38
41
  if (value.version !== 1 || value.home !== sourceHome(home) || typeof value.completeHistory !== "boolean") throw new Error("invalid native record history authority");
39
42
  return value;
40
43
  }
@@ -42,12 +45,15 @@ function manifest(home) {
42
45
  // start can record new locations but cannot invent authority for earlier starts.
43
46
  export function initializeNativeHistory(home, { completeHistory = true } = {}) {
44
47
  const dir = nativeHistoryPath(home);
48
+ // An existing custody directory with a lost manifest is not a new home.
49
+ // Do not repair it to an empty inventory (including through legacy callers).
50
+ if (lstatSync(dir, { throwIfNoEntry: false }) || lstatSync(rootFor(home), { throwIfNoEntry: false })) { manifest(home); return; }
45
51
  mkdirSync(dir, { recursive: true, mode: 0o700 });
46
52
  try { writeFileSync(join(dir, "history.json"), JSON.stringify({ version: 1, home: sourceHome(home), completeHistory }) + "\n", { mode: 0o600, flag: "wx" }); }
47
53
  catch (e) { if (e.code !== "EEXIST") throw e; manifest(home); } // retain earlier launches if a name is reused
48
54
  }
49
55
  export function prepareNativeStart(home, runtime) {
50
- try { manifest(home); }
56
+ try { if (manifest(home).version !== 1) custodyError("protected history requires captured native preparation"); }
51
57
  catch (e) {
52
58
  if (e.code !== "ENOENT") throw e;
53
59
  initializeNativeHistory(home, { completeHistory: false });
@@ -60,23 +66,36 @@ export function prepareNativeStart(home, runtime) {
60
66
  // harness, after the backend shell's startup. No environment map or argv is
61
67
  // serialized. Failure leaves pending evidence and prevents the native exec.
62
68
  export function recordNativeStart(home, id, runtime, args, env = process.env) {
63
- manifest(home);
69
+ const header = manifest(home);
64
70
  if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("invalid native record start id");
65
71
  const path = join(nativeHistoryPath(home), `${id}.json`);
66
- const pending = JSON.parse(readFileSync(path, "utf8"));
72
+ const pending = header.version === 2 ? privateJson(path) : JSON.parse(readFileSync(path, "utf8"));
73
+ if (header.version === 2) {
74
+ if (pending.version !== 2) custodyError("protected history cannot use a v1 start receipt");
75
+ return recordCapturedPiStart(home, id, runtime, args, env);
76
+ }
77
+ if (pending.version === 2) custodyError("captured receipt lacks its expected-ID manifest");
67
78
  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
79
  const locations = nativeLaunchLocations(runtime, { cwd: home, env, args }).map(canonical);
69
80
  atomic(path, { version: 1, id, home: sourceHome(home), runtime, state: "started", startedAt: new Date().toISOString(), locations });
70
81
  }
71
- export function historicalSessionRoots(home) {
82
+ export function historicalSessionRoots(home, { withProof = false } = {}) {
72
83
  const authority = manifest(home);
73
84
  if (!authority.completeHistory) throw new Error("native record roots for earlier launches are unknown; legacy history cannot certify complete capture");
74
85
  const roots = { cc: [], pi: [], codex: [] };
86
+ if (authority.version === 2) {
87
+ if (!withProof) custodyError("protected native roots require proof-bearing inventory");
88
+ const capturedPi = { home: authority.home, nativeRecordId: authority.capturedPi.nativeRecordIds[0] };
89
+ const row = proofReceipt(capturedPi); // validates the ENTIRE expected inventory, not surviving rows alone
90
+ roots.pi.push({ path: row.proposedRoot, capturedPi });
91
+ return roots;
92
+ }
75
93
  for (const name of readdirSync(nativeHistoryPath(home)).sort()) {
76
94
  if (name === "history.json") continue;
77
95
  if (!/^[a-f0-9-]{36}\.json$/.test(name)) throw new Error("unrecognized or unfinished native record receipt");
78
96
  const row = JSON.parse(readFileSync(join(nativeHistoryPath(home), name), "utf8"));
79
97
  const source = { claude: "cc", pi: "pi", codex: "codex" }[row.runtime];
98
+ if (row.version === 2) custodyError("captured receipt lacks its expected-ID manifest");
80
99
  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
100
  for (const path of row.locations) {
82
101
  if (typeof path !== "string" || !path.startsWith("/") || path.includes("\0") || resolve(path) !== path) throw new Error("invalid historical native record location");
@@ -85,3 +104,254 @@ export function historicalSessionRoots(home) {
85
104
  }
86
105
  return roots;
87
106
  }
107
+
108
+ // This advertises the COMPLETE protected discovery/read/append chain, not only
109
+ // this writer. The five libraries ship together. Existing v1 receipts are never
110
+ // upgraded; only genuinely unused, complete scaffold history may move forward.
111
+ export const CAPTURED_PI_RECORD_VERSION = 2;
112
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
113
+ function custodyError(message) { throw Object.assign(new Error(message), { code: "E_CAPTURED_PI_CUSTODY" }); }
114
+ function closed(value, required, optional = []) {
115
+ if (!value || typeof value !== "object" || Array.isArray(value) || required.some(k => !Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) custodyError("invalid captured Pi custody shape");
116
+ }
117
+ function absolutePath(path) { return typeof path === "string" && isAbsolute(path) && resolve(path) === path && !/[\0\r\n]/.test(path); }
118
+ function validIntent(intent, incarnationId) {
119
+ closed(intent, ["schemaVersion", "executionId", "incarnationId", "attempt"]);
120
+ if (intent.schemaVersion !== 1 || !UUID.test(incarnationId) || intent.incarnationId !== incarnationId || typeof intent.executionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(intent.executionId) || !Number.isSafeInteger(intent.attempt) || intent.attempt < 1) custodyError("invalid original captured Pi intent");
121
+ }
122
+ function rootFor(home) { const history = nativeHistoryPath(home); return join(dirname(history), basename(history) + ".pi"); }
123
+ function physical(path, directory = false) {
124
+ if (!absolutePath(path) || realpathSync(path) !== path) custodyError("captured Pi custody path is not physical");
125
+ const stat = lstatSync(path);
126
+ if (stat.isSymbolicLink() || (directory && !stat.isDirectory())) custodyError("captured Pi custody path was substituted");
127
+ return stat;
128
+ }
129
+ function directoryIdentity(path) {
130
+ const stat = physical(path, true);
131
+ if (!Number.isSafeInteger(stat.dev) || !Number.isSafeInteger(stat.ino)) custodyError("unrepresentable captured directory identity");
132
+ return { dev: stat.dev, ino: stat.ino };
133
+ }
134
+ function privateJson(path) {
135
+ physical(dirname(path), true);
136
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
137
+ try {
138
+ const before = fstatSync(fd);
139
+ if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || (process.getuid && before.uid !== process.getuid()) || before.size > 256 * 1024) custodyError("invalid private captured receipt");
140
+ const assertNamedDescriptor = () => {
141
+ // Ancestor-link ABA during open may return a different regular/private FD.
142
+ // Check the actual physical named authority BEFORE reading that descriptor.
143
+ const named = physical(path), opened = fstatSync(fd);
144
+ for (const stat of [opened, named]) if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || stat.nlink !== 1 || stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs) custodyError("captured receipt descriptor differs from named authority");
145
+ physical(dirname(path), true);
146
+ };
147
+ assertNamedDescriptor();
148
+ const bytes = Buffer.alloc(before.size + 1); let size = 0;
149
+ while (size < bytes.length) {
150
+ assertNamedDescriptor();
151
+ const n = readSync(fd, bytes, size, bytes.length - size, null);
152
+ if (n === 0) break;
153
+ size += n;
154
+ }
155
+ assertNamedDescriptor();
156
+ if (size !== before.size) custodyError("captured receipt size changed");
157
+ const data = bytes.subarray(0, size);
158
+ if (!isUtf8(data)) custodyError("captured receipt is not UTF-8");
159
+ const text = data.toString("utf8"), value = JSON.parse(text);
160
+ // New private v2 receipts use our compact serializer. Reject duplicate keys
161
+ // and alternate encodings rather than letting parsers disagree on authority.
162
+ if (value?.version === 2 && text.trim() !== JSON.stringify(value)) custodyError("captured receipt is not canonical writer output");
163
+ return value;
164
+ } finally { closeSync(fd); }
165
+ }
166
+ function validateManifest(header, home) {
167
+ closed(header, ["version", "home", "completeHistory", ...(header?.version === 2 ? ["capturedPi"] : [])]);
168
+ if (![1, 2].includes(header.version) || header.home !== home || header.completeHistory !== true) custodyError("captured history is missing or incomplete");
169
+ if (header.version === 2) {
170
+ closed(header.capturedPi, ["incarnationId", "nativeRecordIds"]);
171
+ const { incarnationId, nativeRecordIds: ids } = header.capturedPi;
172
+ if (!UUID.test(incarnationId) || !Array.isArray(ids) || ids.length === 0 || ids.length > 4096 || ids.some(id => typeof id !== "string" || !UUID.test(id)) || new Set(ids).size !== ids.length) custodyError("invalid captured expected-ID inventory");
173
+ }
174
+ return header;
175
+ }
176
+ function protectedManifest(home) {
177
+ return validateManifest(privateJson(join(nativeHistoryPath(home), "history.json")), home);
178
+ }
179
+ function inventory(home) {
180
+ if (!absolutePath(home) || sourceHome(home) !== home) custodyError("captured home identity is not canonical");
181
+ const history = nativeHistoryPath(home); physical(history, true);
182
+ const header = protectedManifest(home);
183
+ const names = readdirSync(history).sort();
184
+ if (names.length > 16384) custodyError("captured receipt inventory exceeds bound");
185
+ const rows = new Map();
186
+ for (const name of names) {
187
+ if (name === "history.json") continue;
188
+ const id = basename(name, ".json");
189
+ if (name !== id + ".json" || !UUID.test(id)) custodyError("unfinished or unknown captured native receipt");
190
+ const row = privateJson(join(history, name));
191
+ if (row.version === 2) {
192
+ validateReceipt(row, home, id);
193
+ if (header.version !== 2 || !header.capturedPi.nativeRecordIds.includes(id) || row.incarnationId !== header.capturedPi.incarnationId || (row.rootWitness && row.rootWitness.establishedBy.nativeRecordId !== header.capturedPi.nativeRecordIds[0])) custodyError("unlisted or foreign captured native receipt");
194
+ } else if (header.version === 2 || row.version !== 1 || row.id !== id || row.home !== home || !["claude", "pi", "codex"].includes(row.runtime) || row.state !== "started" || !Array.isArray(row.locations) || !row.locations.length || row.locations.some(p => !absolutePath(p))) custodyError("unknown or pending historical native receipt");
195
+ rows.set(id, row);
196
+ }
197
+ if (header.version === 2 && (rows.size !== header.capturedPi.nativeRecordIds.length || header.capturedPi.nativeRecordIds.some(id => !rows.has(id)))) custodyError("expected native receipt is missing; history is not fresh or complete");
198
+ if (!isDeepStrictEqual(privateJson(join(history, "history.json")), header) || !isDeepStrictEqual(readdirSync(history).sort(), names)) custodyError("native custody inventory changed during validation");
199
+ return { header, rows };
200
+ }
201
+ function validateReceipt(row, home, id) {
202
+ closed(row, ["version", "id", "home", "runtime", "incarnationId", "intent", "state", "proposedRoot", "rootWitness"], ["startedAt", "locations"]);
203
+ if (row.version !== 2 || row.id !== id || !UUID.test(id) || row.home !== home || row.runtime !== "pi" || row.proposedRoot !== rootFor(home) || !["pending", "root-established", "started"].includes(row.state)) custodyError("invalid captured native receipt");
204
+ validIntent(row.intent, row.incarnationId);
205
+ if (row.state === "started") {
206
+ if (typeof row.startedAt !== "string" || !Number.isFinite(Date.parse(row.startedAt)) || new Date(row.startedAt).toISOString() !== row.startedAt || !isDeepStrictEqual(row.locations, [row.proposedRoot])) custodyError("invalid captured native start fields");
207
+ } else if (Object.hasOwn(row, "startedAt") || Object.hasOwn(row, "locations")) custodyError("unstarted captured receipt has execution fields");
208
+ if (row.rootWitness === null) { if (row.state !== "pending") custodyError("captured root is unproven"); return; }
209
+ const w = row.rootWitness;
210
+ closed(w, ["schemaVersion", "profile", "path", "identity", "establishedBy"]);
211
+ closed(w.identity, ["dev", "ino"]); closed(w.establishedBy, ["nativeRecordId", "intent"]);
212
+ if (w.schemaVersion !== 1 || w.profile !== "pi-zero-print-1" || w.path !== row.proposedRoot || !UUID.test(w.establishedBy.nativeRecordId) || !Number.isSafeInteger(w.identity.dev) || w.identity.dev < 0 || !Number.isSafeInteger(w.identity.ino) || w.identity.ino < 0) custodyError("invalid original Pi root witness");
213
+ validIntent(w.establishedBy.intent, row.incarnationId);
214
+ }
215
+ function validateWitness(row, rows) {
216
+ const witness = row.rootWitness, establishing = witness && rows.get(witness.establishedBy.nativeRecordId);
217
+ if (!establishing || establishing.version !== 2 || !["root-established", "started"].includes(establishing.state) || establishing.home !== row.home || establishing.incarnationId !== row.incarnationId || establishing.rootWitness?.establishedBy.nativeRecordId !== establishing.id || !isDeepStrictEqual(establishing.intent, witness.establishedBy.intent) || !isDeepStrictEqual(establishing.rootWitness, witness)) custodyError("original establishing receipt is missing or contradictory");
218
+ if (!isDeepStrictEqual(directoryIdentity(row.proposedRoot), witness.identity)) custodyError("original Pi session directory was replaced");
219
+ }
220
+ function selection(home, authority, withIntent = false, creating = false) {
221
+ closed(authority, ["incarnationId", "sessionDir", ...(withIntent ? ["intent"] : []), ...(creating ? ["assertAuthority"] : [])]);
222
+ if (!UUID.test(authority.incarnationId) || !absolutePath(home) || home !== sourceHome(home) || authority.sessionDir !== rootFor(home)) custodyError("captured root selection differs from original custody");
223
+ if (withIntent) validIntent(authority.intent, authority.incarnationId);
224
+ if (creating && typeof authority.assertAuthority !== "function") custodyError("kernel authority callback required");
225
+ }
226
+ function inspectedRoot(home, authority) {
227
+ const { header, rows } = inventory(home), captured = [...rows.values()].filter(r => r.version === 2);
228
+ if (header.version === 2 && header.capturedPi.incarnationId !== authority.incarnationId) custodyError("captured inventory belongs to another incarnation");
229
+ for (const row of captured) {
230
+ if (row.incarnationId !== authority.incarnationId || row.state !== "started") custodyError("foreign or uncertain captured root claim");
231
+ validateWitness(row, rows);
232
+ }
233
+ if (!captured.length) {
234
+ if (header.version !== 1 || rows.size || lstatSync(authority.sessionDir, { throwIfNoEntry: false })) custodyError("existing or previously claimed native storage is not fresh");
235
+ return { header, rows, witness: null };
236
+ }
237
+ const witness = captured[0].rootWitness;
238
+ if (captured.some(r => !isDeepStrictEqual(r.rootWitness, witness))) custodyError("conflicting original Pi root witnesses");
239
+ return { header, rows, witness };
240
+ }
241
+ export function inspectCapturedPiRoot(home, authority) {
242
+ selection(home, authority); inspectedRoot(home, authority);
243
+ }
244
+ function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { fsyncSync(fd); } finally { closeSync(fd); } }
245
+ function publishCustody(home, name, value, previous, check) {
246
+ const history = nativeHistoryPath(home), path = join(history, name), tmp = path + "." + randomUUID() + ".tmp";
247
+ const bytes = JSON.stringify(value) + "\n";
248
+ if (Buffer.byteLength(bytes) > 256 * 1024) custodyError("captured custody publication exceeds bound");
249
+ const unchanged = () => { check(); if (previous === null ? lstatSync(path, { throwIfNoEntry: false }) : !isDeepStrictEqual(privateJson(path), previous)) custodyError("native custody publication lost original authority"); };
250
+ unchanged();
251
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
252
+ try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
253
+ unchanged(); renameSync(tmp, path); syncDirectory(history); check();
254
+ if (!isDeepStrictEqual(privateJson(path), value)) custodyError("native custody publication is uncertain");
255
+ }
256
+ function publishCaptured(home, row, previous, check) {
257
+ validateReceipt(row, home, row.id);
258
+ publishCustody(home, row.id + ".json", row, previous, check);
259
+ }
260
+ export function prepareCapturedPiStart(home, authority) {
261
+ selection(home, authority, true, true); authority.assertAuthority();
262
+ const { header, rows, witness } = inspectedRoot(home, authority);
263
+ if ([...rows.values()].some(r => r.version === 2 && r.intent.executionId === authority.intent.executionId)) custodyError("existing native admission must not prepare another start");
264
+ const history = nativeHistoryPath(home), directories = [dirname(history), history].map(path => [path, directoryIdentity(path)]);
265
+ const check = () => {
266
+ authority.assertAuthority();
267
+ for (const [path, identity] of directories) if (!isDeepStrictEqual(directoryIdentity(path), identity)) custodyError("original native custody parent changed");
268
+ for (const [id, row] of rows) if (!isDeepStrictEqual(privateJson(join(history, id + ".json")), row)) custodyError("original native receipt changed during preparation");
269
+ if (witness && !isDeepStrictEqual(directoryIdentity(authority.sessionDir), witness.identity)) custodyError("original root changed during preparation");
270
+ };
271
+ const id = randomUUID(), pending = { version: 2, id, home, runtime: "pi", incarnationId: authority.incarnationId, intent: structuredClone(authority.intent), state: "pending", proposedRoot: authority.sessionDir, rootWitness: structuredClone(witness) };
272
+ const claimed = validateManifest({ version: 2, home, completeHistory: header.completeHistory, capturedPi: { incarnationId: authority.incarnationId, nativeRecordIds: [...(header.capturedPi?.nativeRecordIds ?? []), id] } }, home);
273
+ // The expected ID is durable BEFORE its pending receipt or root can exist.
274
+ // Losing either receipt or S can no longer masquerade as a fresh scaffold.
275
+ publishCustody(home, "history.json", claimed, header, check);
276
+ const claimCheck = () => { check(); if (!isDeepStrictEqual(protectedManifest(home), claimed)) custodyError("original expected-ID claim changed"); };
277
+ publishCaptured(home, pending, null, claimCheck); claimCheck();
278
+ let established = witness;
279
+ if (witness) validateWitness(pending, rows);
280
+ else {
281
+ mkdirSync(authority.sessionDir, { mode: 0o700 }); // exclusive: EEXIST is uncertainty, never adoption
282
+ const identity = directoryIdentity(authority.sessionDir); syncDirectory(dirname(authority.sessionDir)); claimCheck();
283
+ established = { schemaVersion: 1, profile: "pi-zero-print-1", path: authority.sessionDir, identity, establishedBy: { nativeRecordId: id, intent: structuredClone(authority.intent) } };
284
+ }
285
+ const guard = () => { claimCheck(); if (!isDeepStrictEqual(directoryIdentity(authority.sessionDir), established.identity)) custodyError("captured root changed during establishment"); };
286
+ publishCaptured(home, { ...pending, state: "root-established", rootWitness: established }, pending, guard);
287
+ inventory(home); // no unlisted/unfinished writes may be ignored before return
288
+ return id;
289
+ }
290
+ function recordCapturedPiStart(home, id, runtime, args, env) {
291
+ const { header, rows } = inventory(home), row = rows.get(id);
292
+ if (!row || row.version !== 2 || row.state !== "root-established" || runtime !== "pi") custodyError("captured start must consume an established v2 receipt");
293
+ for (const prior of rows.values()) if (prior.version === 2 && prior.id !== id) {
294
+ if (prior.state !== "started" || prior.incarnationId !== row.incarnationId || !isDeepStrictEqual(prior.rootWitness, row.rootWitness)) custodyError("other native start is foreign or uncertain");
295
+ validateWitness(prior, rows);
296
+ }
297
+ if (env.OATS_INSTANCE_HOME !== home || env.OATS_INCARNATION_ID !== row.incarnationId || env.OATS_EXECUTION_ID !== row.intent.executionId || env.OATS_EXECUTION_ATTEMPT !== String(row.intent.attempt)) custodyError("native execution differs from original admitted context");
298
+ const selected = Array.isArray(args) ? args.filter(a => typeof a === "string" && (a === "--session-dir" || a.startsWith("--session-dir="))) : [];
299
+ if (selected.length !== 1 || !isDeepStrictEqual(nativeLaunchLocations(runtime, { cwd: home, args, env }), [row.proposedRoot])) custodyError("effective native session directory differs from original root");
300
+ const guard = () => {
301
+ const { rows: current } = inventory(home);
302
+ if (!isDeepStrictEqual(current.get(id), row)) custodyError("prepared native receipt changed");
303
+ validateWitness(row, current);
304
+ };
305
+ // Publication itself changes this row, so the effect guard checks original
306
+ // root/establishing association; previous bytes are separately pinned above.
307
+ guard();
308
+ const establish = rows.get(row.rootWitness.establishedBy.nativeRecordId);
309
+ const check = () => {
310
+ if (!isDeepStrictEqual(protectedManifest(home), header)) custodyError("expected-ID claim changed before execution");
311
+ const current = privateJson(join(nativeHistoryPath(home), establish.id + ".json"));
312
+ if (establish.id !== id && !isDeepStrictEqual(current, establish)) custodyError("establishing receipt changed");
313
+ if (!isDeepStrictEqual(directoryIdentity(row.proposedRoot), row.rootWitness.identity)) custodyError("native root changed before execution");
314
+ };
315
+ publishCaptured(home, { ...row, state: "started", startedAt: new Date().toISOString(), locations: [row.proposedRoot] }, row, check);
316
+ }
317
+ function proofReceipt(proof) {
318
+ closed(proof, ["home", "nativeRecordId"]);
319
+ if (!UUID.test(proof.nativeRecordId)) custodyError("invalid native root proof reference");
320
+ const { rows } = inventory(proof.home), row = rows.get(proof.nativeRecordId);
321
+ if (!row || row.version !== 2 || row.state !== "started") custodyError("missing started native root proof");
322
+ for (const r of rows.values()) if (r.version === 2) {
323
+ if (r.state !== "started" || r.incarnationId !== row.incarnationId || !isDeepStrictEqual(r.rootWitness, row.rootWitness)) custodyError("incomplete or contradictory captured history");
324
+ validateWitness(r, rows);
325
+ }
326
+ return row;
327
+ }
328
+ export function assertCapturedPiStart(home, nativeRecordId, authority) {
329
+ selection(home, authority, true);
330
+ const row = proofReceipt({ home, nativeRecordId });
331
+ if (row.incarnationId !== authority.incarnationId || !isDeepStrictEqual(row.intent, authority.intent) || row.proposedRoot !== authority.sessionDir) custodyError("started receipt differs from original admitted root");
332
+ }
333
+ function protectedRoot(path) {
334
+ for (let dir = resolve(path); ; dir = dirname(dir)) {
335
+ if (basename(dirname(dir)) === ".oats-native-record" && /^[a-f0-9]{64}\.pi$/.test(basename(dir))) return dir;
336
+ if (dirname(dir) === dir) return null;
337
+ }
338
+ }
339
+ // Shared discovery/snapshot/capture perimeter. A copied proof is only a REF:
340
+ // reload the external authoritative receipts; never accept supplied dev/ino.
341
+ export function assertCapturedPiProof(proof, path) {
342
+ const row = proofReceipt(proof), rel = relative(row.proposedRoot, path);
343
+ if (!absolutePath(path) || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) custodyError("native source escaped witnessed root");
344
+ for (let current = path; ; current = dirname(current)) {
345
+ const stat = physical(current, current !== path);
346
+ if (current === row.proposedRoot) {
347
+ if (!stat.isDirectory() || !isDeepStrictEqual({ dev: stat.dev, ino: stat.ino }, row.rootWitness.identity)) custodyError("native root changed during contained-path validation");
348
+ break;
349
+ }
350
+ }
351
+ }
352
+ export function guardCapturedPath(path, proof) {
353
+ if (proof !== undefined) return assertCapturedPiProof(proof, path);
354
+ let physicalPath = path;
355
+ try { physicalPath = realpathSync(path); } catch (e) { if (e.code !== "ENOENT") throw e; }
356
+ if (protectedRoot(path) || protectedRoot(physicalPath)) custodyError("protected native path requires its original proof");
357
+ }
@@ -2,7 +2,8 @@
2
2
  // are not attribution: a renamed/replaced source must never donate bytes to
3
3
  // the previous source's stream. Reads and validation use the SAME descriptor.
4
4
  import { createHash } from "node:crypto";
5
- import { fstatSync, readSync, statSync } from "node:fs";
5
+ import { fstatSync, lstatSync, readSync, statSync } from "node:fs";
6
+ import { guardCapturedPath } from "./native-history.mjs";
6
7
 
7
8
  export function identity(stat) {
8
9
  if (!stat.isFile()) throw new Error("session source is not a regular file");
@@ -15,24 +16,41 @@ export function sameVersion(a, b) {
15
16
  }
16
17
  export function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
17
18
 
18
- export function readRange(fd, start, size, path) {
19
+ // O_NOFOLLOW protects the leaf only. An ancestor may have redirected open()
20
+ // and been restored before it returns: bind that FD to the physically contained
21
+ // named file under the original root proof BEFORE reading even one byte.
22
+ export function assertProtectedDescriptor(fd, path, capturedPi) {
23
+ guardCapturedPath(path, capturedPi);
24
+ if (capturedPi === undefined) return;
25
+ const opened = fstatSync(fd), named = lstatSync(path);
26
+ if (!opened.isFile() || !named.isFile() || !sameFile(opened, named)) throw new Error(`protected session descriptor differs from its contained source: ${path}`);
27
+ guardCapturedPath(path, capturedPi);
28
+ }
29
+
30
+ export function readRange(fd, start, size, path, capturedPi) {
31
+ assertProtectedDescriptor(fd, path, capturedPi);
19
32
  const buf = Buffer.alloc(size - start);
20
33
  let done = 0;
21
34
  while (done < buf.length) {
35
+ if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
22
36
  const n = readSync(fd, buf, done, buf.length - done, start + done);
23
37
  if (n === 0) throw new Error(`short read of session source: ${path}`);
24
38
  done += n;
25
39
  }
40
+ if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
26
41
  return buf;
27
42
  }
28
43
 
29
- export function hashPrefix(fd, size, path) {
44
+ export function hashPrefix(fd, size, path, capturedPi) {
45
+ assertProtectedDescriptor(fd, path, capturedPi);
30
46
  const hash = createHash("sha256"), buf = Buffer.alloc(64 * 1024);
31
47
  for (let pos = 0; pos < size;) {
48
+ if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
32
49
  const n = readSync(fd, buf, 0, Math.min(buf.length, size - pos), pos);
33
50
  if (n === 0) throw new Error(`short read of session source: ${path}`);
34
51
  hash.update(buf.subarray(0, n)); pos += n;
35
52
  }
53
+ if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
36
54
  return hash.digest("hex");
37
55
  }
38
56
 
@@ -46,16 +64,18 @@ export function assertIdentity(stat, expected, path) {
46
64
  // A changed same-size file is not append growth. Verification happens before
47
65
  // journal writes; afterwards the buffer to append is independent of the path.
48
66
  export function verifySnapshot(fd, path, snapshot) {
67
+ assertProtectedDescriptor(fd, path, snapshot.capturedPi);
49
68
  const after = fstatSync(fd), named = statSync(path);
50
69
  assertIdentity(after, snapshot, path);
51
70
  assertIdentity(named, snapshot, path);
52
71
  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) {
72
+ if (sameVersion(after, snapshot)) { guardCapturedPath(path, snapshot.capturedPi); return; }
73
+ if (after.size <= snapshot.size || hashPrefix(fd, snapshot.size, path, snapshot.capturedPi) !== snapshot.hash) {
55
74
  throw new Error(`session source changed during capture: ${path}`);
56
75
  }
57
76
  // A second change while validating is uncertain, not absence of evidence.
58
77
  if (!sameVersion(after, fstatSync(fd)) || !sameVersion(after, statSync(path))) {
59
78
  throw new Error(`session source changed during verification: ${path}`);
60
79
  }
80
+ guardCapturedPath(path, snapshot.capturedPi);
61
81
  }
@@ -12,14 +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, fstatSync, openSync, readSync, realpathSync } from "node:fs";
15
+ import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
16
16
  import { basename, resolve, sep } from "node:path";
17
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";
20
+ import { assertProtectedDescriptor, hashPrefix, identity, sameVersion, verifySnapshot } from "./session-snapshot.mjs";
21
21
  import { sourceSessionEnvironment } from "./session-roots.mjs";
22
- import { historicalSessionRoots } from "./native-history.mjs";
22
+ import { guardCapturedPath, historicalSessionRoots } from "./native-history.mjs";
23
23
 
24
24
  // A session's first lines can be large (Claude Code queue operations and
25
25
  // file-history snapshots run to 100 KB and more) and the first cwd-bearing
@@ -30,11 +30,12 @@ import { historicalSessionRoots } from "./native-history.mjs";
30
30
  const CHUNK_BYTES = 64 * 1024;
31
31
  export const CWD_SCAN_BOUND_BYTES = 8 * 1024 * 1024;
32
32
 
33
- function* wholeLines(fd, bound) {
33
+ function* wholeLines(fd, bound, path, capturedPi) {
34
34
  const buf = Buffer.alloc(CHUNK_BYTES);
35
35
  let pieces = [], size = 0; // keep raw bytes across UTF-8/chunk boundaries
36
36
  let offset = 0;
37
37
  while (offset < bound) {
38
+ if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
38
39
  const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
39
40
  if (n === 0) break;
40
41
  offset += n;
@@ -73,17 +74,20 @@ function cwdOfLine(source, line) {
73
74
 
74
75
  /** Descriptor-derived attribution and (for accepted cwd) content witness.
75
76
  * 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");
77
+ export function sessionAttribution(source, path, { bound = CWD_SCAN_BOUND_BYTES, acceptCwd = () => true, capturedPi } = {}) {
78
+ if (capturedPi && source !== "pi") throw new Error("protected Pi proof cannot qualify another format");
79
+ guardCapturedPath(path, capturedPi);
80
+ const fd = openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
78
81
  try {
79
- const snapshot = identity(fstatSync(fd));
82
+ if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
83
+ const snapshot = { ...identity(fstatSync(fd)), ...(capturedPi ? { capturedPi } : {}) };
80
84
  let cwd;
81
- for (const line of wholeLines(fd, Math.min(bound, snapshot.size))) {
85
+ for (const line of wholeLines(fd, Math.min(bound, snapshot.size), path, capturedPi)) {
82
86
  cwd = cwdOfLine(source, line);
83
87
  if (cwd) break;
84
88
  }
85
89
  // Never hash/read the body of an unrelated or unattributable session.
86
- if (cwd && acceptCwd(cwd)) snapshot.hash = hashPrefix(fd, snapshot.size, path);
90
+ if (cwd && acceptCwd(cwd)) snapshot.hash = hashPrefix(fd, snapshot.size, path, capturedPi);
87
91
  // Never combine attribution read from an old prefix with a new witness.
88
92
  // Discovery requires a stable read; append growth between discovery and
89
93
  // capture is supported by the witness's prefix hash.
@@ -133,17 +137,19 @@ export function sessionsForHome(home, { roots, onUnattributed, bound, ignore, on
133
137
  // excluded, not filled from an unrelated observer. The opt-in fallback is
134
138
  // for standalone/legacy inventories, never proof of historical completeness.
135
139
  let context;
136
- if (!roots && fallback !== "current-env") roots = historicalSessionRoots(home);
140
+ if (!roots && fallback !== "current-env") roots = historicalSessionRoots(home, { withProof: true });
137
141
  if (!roots) context = sourceSessionEnvironment(home, env);
138
142
  for (const fmt of Object.values(SESSION_FORMATS)) {
139
143
  const rs = roots ? (roots[fmt.source] ?? []) : fmt.defaultRoots(context.home, context.env, { cwd: home });
140
- for (const path of fmt.listFiles(rs, { strict: true })) {
144
+ for (const entry of fmt.listFiles(rs, { strict: true })) {
145
+ const path = typeof entry === "string" ? entry : entry.path;
146
+ const capturedPi = typeof entry === "string" ? undefined : entry.capturedPi;
141
147
  const sessionId = fmt.sessionId(path);
142
148
  if (ignore?.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
143
149
  onIgnored?.(fmt.source, path);
144
150
  continue;
145
151
  }
146
- const { cwd, snapshot } = sessionAttribution(fmt.source, path, { bound, acceptCwd: cwd => within(canonical(cwd), target) });
152
+ const { cwd, snapshot } = sessionAttribution(fmt.source, path, { bound, capturedPi, acceptCwd: cwd => within(canonical(cwd), target) });
147
153
  if (!cwd) { if (onUnattributed) onUnattributed(fmt.source, path); continue; }
148
154
  if (!within(canonical(cwd), target)) continue;
149
155
  out.push({
@@ -155,8 +161,19 @@ export function sessionsForHome(home, { roots, onUnattributed, bound, ignore, on
155
161
  bytes: snapshot.size,
156
162
  mtime: new Date(snapshot.mtimeMs).toISOString(),
157
163
  snapshot,
164
+ ...(capturedPi ? { capturedPi } : {}),
158
165
  });
159
166
  }
160
167
  }
161
- return out.sort((a, b) => a.mtime.localeCompare(b.mtime) || a.path.localeCompare(b.path));
168
+ out.sort((a, b) => a.mtime.localeCompare(b.mtime) || a.path.localeCompare(b.path));
169
+ const protectedRoots = (roots?.pi ?? []).filter(r => typeof r === "object" && r?.capturedPi);
170
+ if (protectedRoots.length) {
171
+ // Existing CLI iterates this same inventory again after its capture lock,
172
+ // including when it is empty. Retain root proof without synthetic sessions
173
+ // or a bin/parser change, and revalidate before certifying its boundaries.
174
+ const check = () => { for (const r of protectedRoots) guardCapturedPath(r.path, r.capturedPi); };
175
+ check();
176
+ Object.defineProperty(out, Symbol.iterator, { value: function* () { check(); yield* Array.prototype[Symbol.iterator].call(this); check(); } });
177
+ }
178
+ return out;
162
179
  }
@@ -1,15 +1,20 @@
1
1
  ---
2
2
  name: oats
3
3
  description: >-
4
- How to operate inside OATS (Open Agent Team Specification): instance layout and
5
- lifecycle, status, spawn, retire, doctor, operational capability commands,
6
- canonical-vs-generated instructions, or explaining OATS. For configuring
7
- deployments (capabilities, layers, agent types, injections) load the
8
- oats-config skill. Triggers: "spawn an agent", "what agents are running",
9
- "retire this instance", "oats doctor", "oats status", "how does OATS work".
4
+ Use only for operating a legacy uncaptured OATS deployment through its current
5
+ config chain: legacy status, spawn, retire, doctor, capability commands, and
6
+ instance layout. Triggers: "legacy OATS", "uncaptured instance", or an
7
+ instance without executionBinding. For a captured portable composition, load
8
+ oats-portable instead; never use this skill to fill missing captured inputs.
10
9
  ---
11
10
 
12
- # Operating in OATS
11
+ # Operating legacy uncaptured OATS
12
+
13
+ > **Legacy-only procedure.** This skill documents the current config-chain
14
+ > engine for instances without a captured execution binding. A portable instance
15
+ > must use **oats-portable** and explicit deployment/resolution authority. Never
16
+ > apply the config cascade, agent types, or team-scoped lookup below to complete
17
+ > or override a captured record.
13
18
 
14
19
  A **soul** is a durable specialized agent. An **instance** is one disposable,
15
20
  resumable incarnation. A **capability package** distributes reusable skills,
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  name: oats-config
3
3
  description: >-
4
- How to configure OATS deployments with oats-config.yaml and the oats CLI.
5
- Use for capability activation, fundamental-layer integrations, agent
6
- types, targeting souls, binding settings, injection overrides, config
7
- scopes, or adopting a package config template. Triggers: "bind a layer",
8
- "target these souls", "agent type", "override an injection", "oats use",
9
- "oats init", "configure OATS", "oats-config.yaml", "adopt a config template".
10
- Package acquisition/update/remove, locks, restore, and trust mechanics
11
- belong to the oats-packages skill.
4
+ Use only for configuring a legacy uncaptured OATS deployment with the current
5
+ oats-config.yaml cascade, including legacy activation, agent types, targeting,
6
+ overrides, and templates. Triggers: "legacy oats-config", "uncaptured config",
7
+ "oats use", or "agent type". This legacy policy is not a portable preparation
8
+ authority and never fills a captured record.
12
9
  ---
13
10
 
14
- # Configuring OATS
11
+ # Configuring legacy uncaptured OATS
12
+
13
+ > **Legacy-only procedure.** The cascading scopes, agent-type targeting and
14
+ > closest-team rules below are compatibility behavior for uncaptured instances.
15
+ > They are not portable composition authorities. Never consult this cascade as
16
+ > a fallback during captured preparation or dispatch.
15
17
 
16
18
  Config lives in `oats-config.yaml` at laptop (`~`), workspace, and repository
17
19
  levels; resolution walks from a soul's repository outward, closest scope wins.