@awebai/oats 0.24.11 → 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
@@ -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})` : ""}`);
@@ -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 21:30Z · **v0.24.10 PUBLISHED** (tag `d47dda94`; tarball shasum `030bcc3e`; bump PR86 → main `27f9f334`; artifact probe: preview API 2 → keyed apply → explicit-branch same-key replay → E_IDEMPOTENCY_CONFLICT → concurrent E_PLACEMENT_TAKEN/winner → keyed home retires clean) · **6b COMPLETE**: READ (PR78) + APPLY companion (PR85) · kernel K6b–K6f (PR76/80/81/82/83/84) · engineer → **7b (K7 instance events) proposal** → 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,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";
@@ -7257,7 +7259,7 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
7257
7259
  * otherwise every stop or event write would read as "changed home bytes". */
7258
7260
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
7259
7261
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
7260
- function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
7262
+ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
7261
7263
  const hash = createHash("sha256");
7262
7264
  const rootStat = lstatSync(root);
7263
7265
  if (!rootStat.isDirectory()) {
@@ -7269,13 +7271,13 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
7269
7271
  }
7270
7272
  const walk = (dir, rel = "") => {
7271
7273
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
7272
- 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;
7273
7275
  const childRel = rel ? join(rel, e.name) : e.name;
7274
7276
  const path = join(dir, e.name);
7275
7277
  const st = lstatSync(path);
7276
7278
  hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
7277
7279
  if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
7278
- else if (st.isFile()) { hash.update("file\0"); hash.update(!rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : 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"); }
7279
7281
  else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
7280
7282
  else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
7281
7283
  }
@@ -7360,7 +7362,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
7360
7362
  ...(incarnationId ? { incarnationId, executionBinding } : {}),
7361
7363
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
7362
7364
  home: realPathOrNearest(home),
7363
- homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
7365
+ homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
7364
7366
  disposableReceipts,
7365
7367
  generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
7366
7368
  runtime: {
@@ -8453,7 +8455,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
8453
8455
  const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
8454
8456
  if (!baselineValid) {
8455
8457
  classes.push("unknown instance-home provenance");
8456
- } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
8458
+ } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
8457
8459
  classes.push("changed instance-home bytes");
8458
8460
  }
8459
8461
  // A mutable mode must not turn owned directory bytes into an excluded shared
@@ -8478,7 +8480,7 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
8478
8480
  if (nestedGitRoots(work).length) classes.push("nested repository state");
8479
8481
  }
8480
8482
  const stateFingerprint = createHash("sha256")
8481
- .update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
8483
+ .update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
8482
8484
  .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
8483
8485
  .digest("hex");
8484
8486
  return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
@@ -8604,7 +8606,7 @@ function preserveRetirementWork(observation, meta, instance) {
8604
8606
  }
8605
8607
  const recoveredHome = join(staging, "home");
8606
8608
  copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
8607
- 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 })) {
8608
8610
  throw new Error("home recovery verification disagreed with the source");
8609
8611
  }
8610
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.11",
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",