@awebai/oats 0.24.10 → 0.24.12

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/bin/oats.mjs CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  approveCapability, approveAvailableCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
31
31
  packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
32
32
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, planInstanceResources, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
33
- findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions, refreshRetirementBaselineHome,
33
+ findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
34
34
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
35
35
  spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
36
36
  } from "../lib/core.mjs";
@@ -3144,8 +3144,9 @@ function instanceCmd() {
3144
3144
  if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
3145
3145
  try {
3146
3146
  const { resolveInstance } = await_import_lifecycle();
3147
- let home = homeOpt;
3148
- if (!home) home = resolveInstance(dirFlag(), root, name).home;
3147
+ // K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
3148
+ // exactly this name under the scope (E_HOME_MISMATCH otherwise).
3149
+ const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
3149
3150
  const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
3150
3151
  if (JSON_MODE) { jsonOk(ev); return; }
3151
3152
  console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
@@ -4358,10 +4359,10 @@ function spawnCmd() {
4358
4359
  // K6e: record the wake outcome in the home so a same-key replay can report
4359
4360
  // it instead of leaving "saved or not?" to inference.
4360
4361
  if (r.spawnIdempotencyKey) {
4361
- try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: true, saved: !wakeScheduleError, error: wakeScheduleError ?? null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; refreshRetirementBaselineHome(r.home); } catch { /* the receipt still says it */ }
4362
+ try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: true, saved: !wakeScheduleError, error: wakeScheduleError ?? null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; } catch { /* the receipt still says it */ }
4362
4363
  }
4363
4364
  } else if (r.spawnIdempotencyKey && r.replayed !== true) {
4364
- try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: false, saved: null, error: null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; refreshRetirementBaselineHome(r.home); } catch { /* nothing to record */ }
4365
+ try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: false, saved: null, error: null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; } catch { /* nothing to record */ }
4365
4366
  }
4366
4367
  if (JSON_MODE) {
4367
4368
  // Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
@@ -5033,7 +5034,7 @@ function versionCmd() {
5033
5034
  // on it (an older CLI without the surface must fail closed with a
5034
5035
  // reason, not an argument error). `features`: kernel abilities a peer
5035
5036
  // must see before relying on them (retire-home: retire --home).
5036
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5037
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5037
5038
  return;
5038
5039
  }
5039
5040
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
4
4
 
5
- **Last update:** 2026-09-22 19:10Z · **v0.24.9 PUBLISHED** (tag `04a4f709`; both packages on npm, tarball shasum `6333c01c`; bump PR79 → main `a5cc390e`; artifact probe: readiness/preview API 2/bound apply/E_DECISION_STALE/retire retention/no-git verify all pass, caller alive, soul intact) · **6b READ MERGED** (PR78) · K6b ✅ · PR77 ✅ · engineer → **6b APPLY companion proposal** → 7b → 8 · open contracts: K11, attach-knowledge, auto-PR, branch enumeration
5
+ **Last update:** 2026-09-22 21:40Z · K7b on main (PR89 `e808fbfc`; PR90 `17182cdb` open-time O_NOFOLLOW+fstat guard + DTO pins) · **K6h PR91 → main `dcd2bc89`**: kernel-field neutrality + receipt exclusions now opt-in for instance homes only (work/recovery trees fully significant) · **7b wiring head = `dcd2bc89`** · engineer wiring 7b → 8 · 0.24.12 after 7b Desktop PR · open: K11, attach-knowledge, auto-PR, branch enumeration
6
6
 
7
7
  Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
8
8
 
@@ -156,7 +156,7 @@ returns a bounded unified diff:
156
156
 
157
157
  No forge (PR/checks/reviews) data here: forge connections are an ADE/workstation integration (P1 decision), read by the Desktop server through the forge's own CLI; the kernel only reports the instance's `remote` so the ADE can pick a backend.
158
158
 
159
- ## Instance events (`oats instance events`, `eventsApi: 1`, OATS 0.24.8+) — K7
159
+ ## Instance events (`oats instance events`, `eventsApi: 1` → **2**, OATS 0.24.8+) — K7
160
160
 
161
161
  Typed lifecycle events per instance, **written by the kernel action that made
162
162
  them true**, with the receipt it produced. Nothing is inferred from
@@ -185,6 +185,66 @@ home's removal, so a retired instance's `retired` event is still readable).
185
185
  - Window is bounded (`--limit`, default 200; `truncated` says so). A torn line
186
186
  appears as `kind: "unreadable"` rather than vanishing.
187
187
 
188
+ ### Events API 2 (`eventsApi: 2`, feature `instance-events-2`, OATS 0.24.12+) — K7b
189
+
190
+ Gate a Desktop read on **both** `eventsApi === 2` and `"instance-events-2"` in
191
+ `features[]`. API 1 is not a sufficient fence for a bounded read: its reader
192
+ opened and read a source whole, followed symlinks, kept foreign rows and lost
193
+ torn lines and cleared claims silently. API 2:
194
+
195
+ - **Bounded, descriptor-safe read.** Each source (`home` =
196
+ `<home>/.oats-events.jsonl`, `workspace` = `<ws>/.agents/events/<agent>--<instance>.jsonl`)
197
+ is `lstat`ed first; anything but a regular file is **refused unopened**.
198
+ The open itself is `O_RDONLY|O_NOFOLLOW|O_NONBLOCK` and the descriptor is
199
+ `fstat`ed: it must be a regular file with the same device+inode lstat saw
200
+ (closes the lstat→open swap). At most the last 4 MiB is read by descriptor.
201
+ **Canonical source shape**: `{path: "home"|"workspace", status: "ok"|"absent"|"refused"|"tail", bytes}`
202
+ — `"tail"` means only the last 4 MiB was read (partial first line dropped);
203
+ there is no separate `tail` boolean.
204
+ - **Row fields.** `incarnation` is the writing home's `instance.json.createdAt`
205
+ (ISO) or `null` for rows written before the tag; the result's top-level
206
+ `incarnation` is the current home's `createdAt` or `null` if unreadable. A
207
+ row with `incarnation: null` never matches the current incarnation, so it
208
+ cannot contribute a current waiting claim. **An unknown current incarnation
209
+ (top-level `incarnation: null`) admits NO claim**: `waitingOnYou: null`,
210
+ `waitingClaims: []`, rows still returned as history. A consumer must refuse
211
+ a null-incarnation response that nevertheless carries claims. Dedup identity is
212
+ `producer|at|kind|incarnation|data`.
213
+ - **`waitingClaims[]` row shape**: `{producer: string, waiting: boolean, since: ISO, reason: string|null}`
214
+ — one row per producer with a claim in the current incarnation, INCLUDING
215
+ cleared ones (`waiting: false`, `reason: null`, `since` = the clearing row's
216
+ `at`). `waitingOnYou` = the newest `waiting: true` row or `null`.
217
+ - **Address history.** `--home <abs>` must be a home of exactly `<instance>` under
218
+ the scope (`E_HOME_MISMATCH` otherwise, like K1). Rows whose `instance`/`home`
219
+ are not the admitted address are dropped and counted (`integrity.foreignRows`).
220
+ Every row carries `incarnation` (the writing home's `instance.json.createdAt`);
221
+ the result echoes the current home's `incarnation`. Rows tagged with an earlier
222
+ incarnation ARE returned — they are this address's history — so a consumer can
223
+ label them "earlier instance at this address". No current home → no read
224
+ (archived access is a separate contract).
225
+ - **`waitingOnYou` is a producer STATE for the current incarnation**, decided per
226
+ producer by that producer's latest row that carries the field: an explicit
227
+ `false` clears, a row without the field does not; earlier incarnations never
228
+ contribute; computed over the FULL admitted read (a `--limit` window cannot
229
+ hide a clear). `waitingClaims[]` lists every producer's current claim
230
+ (`{producer, waiting, since, reason}`); `waitingOnYou` is the newest positive.
231
+ Still `null` today — no producer emits it.
232
+ - **Provenance and corruption never disappear.** Dedup is by
233
+ `producer|at|kind|incarnation|data` (the same facts from two producers are two
234
+ rows). `integrity.unreadableRows` counts torn/invalid lines regardless of
235
+ `--since` or the window. `count` = admitted rows after `--since`, `returned` =
236
+ the window, `truncated` = window cut OR any source read as a tail.
237
+
238
+ ```
239
+ {"eventsApi":2,"instance":"dev-1","home":"/abs/home","incarnation":"<iso>","count":7,"returned":7,"truncated":false,
240
+ "integrity":{"unreadableRows":0,"foreignRows":0,"sources":[{"path":"home","status":"ok","bytes":1234},{"path":"workspace","status":"ok","bytes":1234}]},
241
+ "events":[{"eventsApi":2,"at":"<iso>","instance":"dev-1","home":"/abs/home","incarnation":"<iso>","producer":"kernel","kind":"spawned","data":{...}}, ...],
242
+ "lastEvent":{"kind":"launched","at":"<iso>","producer":"kernel","incarnation":"<iso>"},
243
+ "waitingOnYou":null,"waitingClaims":[],"notes":[...]}
244
+ ```
245
+
246
+ Desktop passes `--limit` (50|100|200) only; `--since` remains a human flag.
247
+
188
248
  ## Schedule run history (`scheduleApi: 2`, OATS 0.24.8+) — K8
189
249
 
190
250
  `oats schedule show|list --json` entries gain **`recentRuns`**: the last 50
@@ -0,0 +1,30 @@
1
+ # OATS v0.24.11 — retirement recovery: kernel-owned fields only, nothing re-blessed after launch
2
+
3
+ Kernel/Pi/Desktop **0.24.11**. Tag `v0.24.11` → the commit carrying these
4
+ notes; the version-bump commit lands after the tag. **Upgrade from 0.24.10 —
5
+ it can lose an agent's early home bytes at retire.**
6
+
7
+ ## Fix
8
+
9
+ 0.24.10 (PR84) made a fresh keyed spawn retire clean by re-hashing the **whole
10
+ home** after the kernel's own post-spawn writes (completion marker, wake
11
+ record). That re-stamp ran after the tmux launch, so anything the agent wrote
12
+ in the launch→completion interval — `STATE.md`, notes — was blessed into the
13
+ retirement baseline and **not recovered** at retire. Found by the Desktop
14
+ engineer's exact-source inert analysis of the merged fix.
15
+
16
+ Now the re-stamp is gone. The baseline fingerprint hashes the root
17
+ `instance.json` with exactly the kernel-owned post-spawn fields
18
+ (`spawnCompleted`, `wake`) removed. Kernel writes never read as the agent's
19
+ changes; **nothing else in the home is ever re-blessed**; there is no
20
+ observation of the home after launch. Regression: authored bytes written at
21
+ launch are recovered under `changed instance-home bytes` with the bytes
22
+ intact; a plain keyed spawn still retires with `workRecovery: null`.
23
+
24
+ Rule recorded for the framework: the retirement baseline may account only for
25
+ bytes the kernel can *prove* it wrote — never for what it *observes* after the
26
+ agent may have started.
27
+
28
+ ## Upgrade
29
+
30
+ `npm i -g @awebai/oats@0.24.11`, then `oats doctor`.
@@ -0,0 +1,57 @@
1
+ # OATS v0.24.12 — instance events API 2 and the Desktop activity popover
2
+
3
+ Kernel/Pi/Desktop **0.24.12**. Tag `v0.24.12` → the commit carrying these
4
+ notes; the version-bump commit lands after the tag. Consumers gate on
5
+ `oats version --json` `features[]` names and API integers — never on the version.
6
+
7
+ ## Kernel — `instance-events-2` (`eventsApi: 2`)
8
+
9
+ `oats instance events` was a working reader with four defects found by the
10
+ Desktop engineer's exact-source review; each is closed under a new advertised
11
+ name because a strengthened guarantee must be distinguishable from the installed
12
+ CLI that lacks it.
13
+
14
+ - **Bounded, descriptor-safe read.** Each log source is `lstat`ed first; anything
15
+ but a regular file is refused unopened. The open is `O_NOFOLLOW|O_NONBLOCK` and
16
+ the descriptor is `fstat`ed against lstat's device+inode (closes the
17
+ lstat→open swap). At most the last 4 MiB is read by descriptor
18
+ (`integrity.sources[].status: ok|absent|refused|tail`).
19
+ - **Address history, incarnation-tagged.** `--home` must be a home of exactly
20
+ the named instance (`E_HOME_MISMATCH`). Rows of another address are dropped and
21
+ counted (`integrity.foreignRows`). Every row carries `incarnation` (the writing
22
+ home's `instance.json.createdAt`); the result echoes the current one, so a
23
+ recreated same-address instance's earlier rows are visible *as earlier*.
24
+ - **`waitingOnYou` is a producer state**, per (producer, current incarnation):
25
+ that producer's latest row carrying the field decides, an explicit `false`
26
+ clears, earlier incarnations never contribute, and it is computed over the full
27
+ admitted read so a `--limit` window cannot hide a clear. `waitingClaims[]`
28
+ shows every producer's current claim. **An unknown current incarnation
29
+ (unreadable `instance.json`) admits no claim at all.** Still `null` today — no
30
+ producer emits it.
31
+ - **Provenance and corruption never disappear.** Dedup includes the producer;
32
+ `integrity.unreadableRows` counts torn lines regardless of `--since` or the
33
+ window; `count`/`returned`/`truncated` are consistent.
34
+
35
+ ## Kernel — retirement fingerprint scope (K6h)
36
+
37
+ 0.24.11's kernel-field neutrality for `instance.json` — and the pre-existing
38
+ `.oats-*` receipt exclusions — were keyed on a root-level *filename*, so they
39
+ also applied to directory work trees and recovery trees. Both are now opt-in
40
+ (`{ instanceHome: true }`), passed by exactly the four instance-home callers;
41
+ work and recovery trees are fully significant. A comparison blind spot (directory
42
+ work was already always recovered when non-empty), not a deletion.
43
+
44
+ ## Desktop
45
+
46
+ - **Reported lifecycle activity** in the selected-instance popover: an explicit
47
+ *Load / Refresh activity* control (no roster or per-card fan-out) shows the
48
+ kernel's dated events with source, integrity (refused/tail/torn/foreign
49
+ visibly incomplete), earlier/unknown incarnation labels, and attributed
50
+ waiting claims as *claims as of a timestamp* — never as current runtime
51
+ authority. Gated on `eventsApi === 2` and `instance-events-2`; two coalesced
52
+ process-wide read slots; one observation retained per open popup; a failed
53
+ refresh keeps visibly stale rows rather than a false empty.
54
+
55
+ ## Upgrade
56
+
57
+ `npm i -g @awebai/oats@0.24.12`, then `oats doctor`.
package/lib/core.mjs CHANGED
@@ -73,6 +73,8 @@ import { invokeProviderBinding } from "./provider-binding-broker.mjs";
73
73
  import { withCapturedBindingFile } from "./captured-binding-file.mjs";
74
74
  import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
75
75
  export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
76
+ /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
77
+ export { fingerprintTree };
76
78
  import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
77
79
  import { createCapabilityMaterializer } from "./package-materialization.mjs";
78
80
  import { resolvePackageClosure } from "./package-closure.mjs";
@@ -6898,8 +6900,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6898
6900
  if (o.idempotencyKey !== undefined) {
6899
6901
  // Only now is the spawn a finished receipt a same-key retry may replay.
6900
6902
  meta.spawnCompleted = true;
6901
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
6902
- refreshRetirementBaselineHome(home); // a kernel write, not the agent's
6903
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
6903
6904
  }
6904
6905
  return { ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
6905
6906
  } catch (error) {
@@ -7258,7 +7259,7 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
7258
7259
  * otherwise every stop or event write would read as "changed home bytes". */
7259
7260
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
7260
7261
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
7261
- function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
7262
+ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
7262
7263
  const hash = createHash("sha256");
7263
7264
  const rootStat = lstatSync(root);
7264
7265
  if (!rootStat.isDirectory()) {
@@ -7270,13 +7271,13 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
7270
7271
  }
7271
7272
  const walk = (dir, rel = "") => {
7272
7273
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
7273
- if ((!rel && excludeRoot.has(e.name)) || (!rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
7274
+ if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
7274
7275
  const childRel = rel ? join(rel, e.name) : e.name;
7275
7276
  const path = join(dir, e.name);
7276
7277
  const st = lstatSync(path);
7277
7278
  hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
7278
7279
  if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
7279
- else if (st.isFile()) { hash.update("file\0"); hash.update(readFileSync(path)); hash.update("\0"); }
7280
+ else if (st.isFile()) { hash.update("file\0"); hash.update(instanceHome && !rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); /* kernel-field neutrality applies ONLY when the tree IS an instance home; a work tree's instance.json is the agent's bytes */ hash.update("\0"); }
7280
7281
  else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
7281
7282
  else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
7282
7283
  }
@@ -7335,17 +7336,21 @@ function retirementDisposableRoots(work, workMode, capabilities) {
7335
7336
  return roots;
7336
7337
  }
7337
7338
 
7338
- /** Re-stamp ONLY the home fingerprint of an existing baseline after a KERNEL
7339
- * write to the home (completion marker, wake record). Retirement compares the
7340
- * home against this baseline; kernel-owned metadata written after spawn must
7341
- * not read as the agent's "changed instance-home bytes". Everything else in
7342
- * the baseline (work fingerprint, mode, capabilities) is untouched. */
7343
- export function refreshRetirementBaselineHome(home) {
7344
- const path = retirementBaselinePath(home);
7345
- let baseline; try { baseline = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
7346
- baseline.homeFingerprint = fingerprintTree(home, { excludeRoot: new Set(["work"]) });
7347
- writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
7348
- return true;
7339
+ /** The fields the KERNEL writes into a home's instance.json after the spawn's
7340
+ * retirement baseline was taken (completion marker, wake record). The
7341
+ * baseline fingerprint hashes instance.json with exactly these removed, so a
7342
+ * kernel write does not read as the agent's change — and NOTHING ELSE in the
7343
+ * home is ever re-blessed: an agent's STATE.md written seconds after launch
7344
+ * is still recovered at retire. (Replaces a whole-home re-stamp that could
7345
+ * bless authored bytes written in the launch→completion interval.) */
7346
+ const KERNEL_POST_SPAWN_FIELDS = ["spawnCompleted", "wake"];
7347
+ function kernelNeutralInstanceJson(bytes) {
7348
+ try {
7349
+ const m = JSON.parse(String(bytes));
7350
+ if (!m || typeof m !== "object" || Array.isArray(m)) return bytes;
7351
+ for (const k of KERNEL_POST_SPAWN_FIELDS) delete m[k];
7352
+ return Buffer.from(JSON.stringify(m));
7353
+ } catch { return bytes; }
7349
7354
  }
7350
7355
  function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
7351
7356
  if (mode === "directory") assertDirectoryRoots(home);
@@ -7357,7 +7362,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
7357
7362
  ...(incarnationId ? { incarnationId, executionBinding } : {}),
7358
7363
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
7359
7364
  home: realPathOrNearest(home),
7360
- homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
7365
+ homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
7361
7366
  disposableReceipts,
7362
7367
  generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
7363
7368
  runtime: {
@@ -8450,7 +8455,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
8450
8455
  const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
8451
8456
  if (!baselineValid) {
8452
8457
  classes.push("unknown instance-home provenance");
8453
- } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
8458
+ } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
8454
8459
  classes.push("changed instance-home bytes");
8455
8460
  }
8456
8461
  // A mutable mode must not turn owned directory bytes into an excluded shared
@@ -8475,7 +8480,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
8475
8480
  if (nestedGitRoots(work).length) classes.push("nested repository state");
8476
8481
  }
8477
8482
  const stateFingerprint = createHash("sha256")
8478
- .update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
8483
+ .update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
8479
8484
  .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
8480
8485
  .digest("hex");
8481
8486
  return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
@@ -8601,7 +8606,7 @@ function preserveRetirementWork(observation, meta, instance) {
8601
8606
  }
8602
8607
  const recoveredHome = join(staging, "home");
8603
8608
  copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
8604
- if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
8609
+ if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
8605
8610
  throw new Error("home recovery verification disagreed with the source");
8606
8611
  }
8607
8612
  let branchDrift;
@@ -5,11 +5,24 @@
5
5
  * receipt it produced. Nothing here is inferred from transcripts, task files or
6
6
  * prose; "waiting on you" is reported only when a producer says so — today no
7
7
  * producer does, and the view says `null`, not a guess. Retirement's event
8
- * outlives the home in the workspace-level log. */
9
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
8
+ * outlives the home in the workspace-level log.
9
+ *
10
+ * eventsApi 2 (`instance-events-2`) — the read is BOUNDED and ADDRESSED:
11
+ * - a source is `lstat`ed first and opened only if it is a regular file; the
12
+ * reader reads at most the last MAX_BYTES by descriptor, never the whole file;
13
+ * - rows are the ADDRESS's history: a row whose instance/home is not the
14
+ * admitted address is dropped and counted (`integrity.foreignRows`), a torn
15
+ * line is counted (`integrity.unreadableRows`) — never sorted or windowed away;
16
+ * - every row is tagged with the `incarnation` (the home's instance.json
17
+ * `createdAt`) that wrote it; the result echoes the current one, so a
18
+ * recreated same-address instance's earlier rows are visible as earlier;
19
+ * - `waitingOnYou` is a producer STATE per (producer, current incarnation):
20
+ * that producer's latest row carrying the field decides, an explicit `false`
21
+ * clears, and it is computed over the full admitted read, before windowing. */
22
+ import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
10
23
  import { basename, dirname, join } from "node:path";
11
24
 
12
- export const EVENTS_API = 1;
25
+ export const EVENTS_API = 2;
13
26
  const HOME_LOG = ".oats-events.jsonl";
14
27
  const MAX_BYTES = 4 * 1024 * 1024;
15
28
 
@@ -23,11 +36,16 @@ function workspaceLogPath(home) {
23
36
  return join(workspace, ".agents", "events", `${basename(agentDir)}--${basename(home)}.jsonl`);
24
37
  }
25
38
 
39
+ /** The incarnation of the home at `home`: its instance.json `createdAt`, or null. */
40
+ export function incarnationOf(home) {
41
+ try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; }
42
+ }
43
+
26
44
  /** Append one typed event. Never throws into the caller's action: an event is
27
45
  * evidence, not authority — a failed write is reported in the return value. */
28
- export function appendEvent(home, event, { workspaceOnly = false } = {}) {
46
+ export function appendEvent(home, event, { workspaceOnly = false, incarnation } = {}) {
29
47
  if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
30
- const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
48
+ const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
31
49
  const line = JSON.stringify(row) + "\n";
32
50
  const results = [];
33
51
  // Retirement fingerprints the home against its baseline and preserves any
@@ -44,33 +62,81 @@ export function appendEvent(home, event, { workspaceOnly = false } = {}) {
44
62
  return { ok: results.some((r) => r.ok), row, results };
45
63
  }
46
64
 
47
- function readLog(path) {
48
- if (!existsSync(path)) return { rows: [], truncated: false, bytes: 0 };
49
- const size = statSync(path).size;
50
- const text = readFileSync(path, "utf8");
51
- const rows = [];
52
- for (const line of text.split("\n")) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r && r.eventsApi === EVENTS_API && typeof r.kind === "string") rows.push(r); } catch { /* a torn line is skipped, counted */ rows.push({ eventsApi: EVENTS_API, kind: "unreadable", at: null, producer: "kernel", data: { reason: "torn or invalid line" } }); } }
53
- return { rows, truncated: size > MAX_BYTES, bytes: size };
65
+ /** Bounded, descriptor-safe read of one log: lstat → regular file only → read
66
+ * at most the last MAX_BYTES. Returns rows plus the source's integrity facts. */
67
+ function readLog(path, label) {
68
+ let st;
69
+ try { st = lstatSync(path); } catch (e) { return e.code === "ENOENT" ? { rows: [], unreadable: 0, source: { path: label, status: "absent", bytes: 0 } } : { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; }
70
+ if (!st.isFile()) return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; // symlink, FIFO, device, directory: never opened
71
+ let fd;
72
+ try {
73
+ // Close the lstat→open swap: open WITHOUT following a symlink and without
74
+ // blocking on a FIFO, then verify by descriptor that what was opened is the
75
+ // regular file lstat saw (same device+inode). Anything else is refused.
76
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
77
+ const fst = fstatSync(fd);
78
+ if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino) throw new Error("swapped");
79
+ st = fst; // the size of what is actually open
80
+ } catch { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: 0 } }; }
81
+ const tail = st.size > MAX_BYTES;
82
+ const length = tail ? MAX_BYTES : st.size;
83
+ const buf = Buffer.alloc(length);
84
+ try {
85
+ let got = 0; while (got < length) { const n = readSync(fd, buf, got, length - got, (tail ? st.size - MAX_BYTES : 0) + got); if (n <= 0) break; got += n; }
86
+ } catch { return { rows: [], unreadable: 0, source: { path: label, status: "refused", bytes: st.size } }; }
87
+ finally { try { closeSync(fd); } catch { /* nothing to recover */ } }
88
+ const text = buf.toString("utf8");
89
+ const lines = text.split("\n");
90
+ if (tail) lines.shift(); // the first line of a tail is (almost surely) partial; it is not counted as torn — the tail itself is reported
91
+ const rows = []; let unreadable = 0;
92
+ for (const line of lines) {
93
+ if (!line.trim()) continue;
94
+ try { const r = JSON.parse(line); if (r && typeof r === "object" && Number.isInteger(r.eventsApi) && r.eventsApi >= 1 && typeof r.kind === "string" && typeof r.at === "string") rows.push(r); else unreadable++; } catch { unreadable++; }
95
+ }
96
+ return { rows, unreadable, source: { path: label, status: tail ? "tail" : "ok", bytes: st.size } };
54
97
  }
55
98
 
56
- /** Events for one instance, newest last, from both logs (deduplicated by
57
- * at+kind), with a bounded window. `waitingOnYou` is a producer claim or null. */
99
+ /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
100
+ * window; integrity is reported independently of the selected rows. */
58
101
  export function readEvents(home, { limit = 200, since = null } = {}) {
59
- const a = readLog(join(home, HOME_LOG)), b = readLog(workspaceLogPath(home));
60
- const seen = new Set(); const rows = [];
61
- for (const r of [...a.rows, ...b.rows]) { const k = `${r.at}|${r.kind}|${JSON.stringify(r.data ?? null)}`; if (seen.has(k)) continue; seen.add(k); rows.push(r); }
62
- rows.sort((x, y) => String(x.at ?? "").localeCompare(String(y.at ?? "")));
63
- const filtered = since ? rows.filter((r) => r.at && r.at > since) : rows;
102
+ const instance = basename(home);
103
+ const incarnation = incarnationOf(home);
104
+ const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
105
+ const seen = new Set(); const rows = []; let foreign = 0;
106
+ for (const r of [...a.rows, ...b.rows]) {
107
+ if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
108
+ const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
109
+ if (seen.has(k)) continue; seen.add(k); rows.push({ ...r, incarnation: r.incarnation ?? null });
110
+ }
111
+ rows.sort((x, y) => x.at.localeCompare(y.at));
112
+ // Waiting: a producer STATE for the CURRENT incarnation, decided by that
113
+ // producer's latest row that carries the field, over the FULL admitted read.
114
+ // An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
115
+ // unknown stays unknown, it never resurrects an earlier incarnation's positive.
116
+ const claims = new Map();
117
+ for (const r of (incarnation === null ? [] : rows)) {
118
+ if (r.incarnation !== incarnation) continue;
119
+ if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
120
+ const p = r.producer ?? "kernel";
121
+ claims.set(p, r.data.waitingOnYou ? { producer: p, waiting: true, since: r.at, reason: typeof r.data.reason === "string" ? r.data.reason : null } : { producer: p, waiting: false, since: r.at, reason: null });
122
+ }
123
+ const waitingClaims = [...claims.values()];
124
+ const positive = waitingClaims.filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
125
+ const filtered = since ? rows.filter((r) => r.at > since) : rows;
64
126
  const window = filtered.slice(-limit);
65
127
  const last = window.at(-1) ?? null;
66
- const waiting = window.filter((r) => r.data && r.data.waitingOnYou === true).at(-1) ?? null;
67
128
  return {
68
- eventsApi: EVENTS_API, instance: basename(home), home, count: filtered.length, returned: window.length, truncated: filtered.length > window.length || a.truncated || b.truncated,
69
- events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer } : null,
70
- waitingOnYou: waiting ? { since: waiting.at, producer: waiting.producer, reason: waiting.data.reason ?? null } : null,
129
+ eventsApi: EVENTS_API, instance, home, incarnation, count: filtered.length, returned: window.length,
130
+ truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
131
+ integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
132
+ events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer ?? "kernel", incarnation: last.incarnation } : null,
133
+ waitingOnYou: positive ? { since: positive.since, producer: positive.producer, reason: positive.reason } : null,
134
+ waitingClaims,
71
135
  notes: [
72
136
  "events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
73
- "waitingOnYou is null unless a producer reported it — null means unknown, not 'not waiting'",
137
+ "this is the ADDRESS's history: rows tagged with an earlier incarnation belong to a previous instance at this address",
138
+ "waitingOnYou is null unless a producer reported it for the current incarnation — null means unknown, not 'not waiting'; an explicit false clears that producer's claim; an unknown current incarnation (null) admits no claim at all",
139
+ "integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
74
140
  ],
75
141
  };
76
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.10",
3
+ "version": "0.24.12",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",