@awebai/oats 0.22.1 → 0.22.3

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.
@@ -37,7 +37,8 @@
37
37
  * identity joined moments before the failure must still be deletable.
38
38
  */
39
39
  import { execFileSync } from "node:child_process";
40
- import { existsSync, statSync } from "node:fs";
40
+ import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
41
+ import { hostname } from "node:os";
41
42
  import { join, dirname, resolve, delimiter } from "node:path";
42
43
 
43
44
  /** Run a command as ARGV — never a shell string. Team ids, aliases, instance
@@ -66,6 +67,10 @@ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false } =
66
67
  const why = secretSafe ? "" : (scrub(e.stderr).trim() || (e.status === undefined ? String(e.code || "failed") : ""));
67
68
  const err = new Error(`${where} failed${e.status === undefined ? "" : ` (exit ${e.status})`}${why ? `: ${why}` : ""}${secretSafe ? " (output withheld: this command handles credentials)" : ""}`);
68
69
  err.status = e.status;
70
+ // A classification, never the text: the caller may name a KNOWN failure
71
+ // class (an alias that still holds a certificate) without any output of a
72
+ // credential-handling command reaching a log.
73
+ err.aliasConflict = /already|exists|conflict|422|active certificate/i.test(String(e.stderr ?? "") + String(e.stdout ?? ""));
69
74
  throw err;
70
75
  }
71
76
  };
@@ -97,6 +102,17 @@ const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb:
97
102
  const event = process.env.OATS_EVENT || process.argv[2];
98
103
  const instance = process.env.OATS_INSTANCE;
99
104
  const home = process.env.OATS_HOME || process.cwd();
105
+ // Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
106
+ // delivery: "channel" (default) keeps the native channel packages waking the
107
+ // instance; "session" hands delivery to the host wake broker (aweb-abil):
108
+ // AWEB_DELIVERY=session goes into the launch environment, the Claude channel
109
+ // flag is omitted, and nothing wakes the instance until the broker exists.
110
+ let settings = {};
111
+ try { settings = JSON.parse(process.env.OATS_SETTINGS || "{}"); } catch { settings = {}; }
112
+ const deliveryMode = (() => {
113
+ const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
114
+ return v === "session" ? "session" : "channel";
115
+ })();
100
116
 
101
117
  /**
102
118
  * The aweb root (minting authority). BOUNDED candidates — the deployment's team
@@ -148,7 +164,165 @@ if (!onPath("aw")) {
148
164
  warn(`aw CLI not on PATH — no identity minted; ${AW_INSTALL}`);
149
165
  }
150
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // Retained identity (explicit per-soul opt-in): a standing seat keeps its
169
+ // did:aw and address when re-seated as an OATS instance. Per aweb's contract
170
+ // (2026-09-05): copy exactly the identity-authority files from the source
171
+ // .aw into the home's .aw, reconnect the coordination binding with
172
+ // `aw workspace connect`, verify online, heartbeat and status show the new
173
+ // path, and hold a lock BESIDE the source so no second seat can take it.
174
+ // Never copy workspace.yaml, caches or locks; never delete the source; never
175
+ // team-join (that is the mint path, which would try to create the alias
176
+ // again). Retire releases the lock and leaves the identity alone.
177
+ const IDENTITY_AUTHORITY = ["signing.key", "identity.yaml", "teams.yaml", "team-certs", "encryption.yaml", "encryption-keys"];
178
+ // Session delivery registers the home with the host wake broker (aweb-abil:
179
+ // `aw wake register --home <abs> --identity-home <abs> --delivery session
180
+ // [--backend tmux|herdr]`, durable even when the daemon is down). An aw
181
+ // without `aw wake` cannot deliver in session mode: refuse, never silently
182
+ // turn a working channel into a poll-only instance.
183
+ // Throws, never exits: both callers run it inside a try whose catch performs
184
+ // the rollback (the seat path restores the binding; the mint path hands the
185
+ // minted identity to compensation).
186
+ function wakeRegister(instanceHome, identityHome) {
187
+ const backend = process.env.OATS_BACKEND;
188
+ try {
189
+ run(["aw", "wake", "register", "--home", instanceHome, "--identity-home", identityHome, "--delivery", "session", ...(backend ? ["--backend", backend] : [])], instanceHome, 60000);
190
+ } catch (e) {
191
+ throw new Error(`delivery: session needs an aw with the wake broker CLI (aw wake register), which this aw does not provide (${e.message || e}); install the aweb release that ships aw wake, or use delivery: channel`);
192
+ }
193
+ }
194
+ function wakeDeregister(instanceHome) {
195
+ try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
196
+ }
197
+ const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
198
+ const yamlScalar = (text, key) => {
199
+ const m = String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
200
+ return m ? m[1].trim() : undefined;
201
+ };
202
+ function retainedSeatSpawn(source, takeOver) {
203
+ if (typeof source !== "string" || !source.startsWith("/")) fatal("identity.source must be the absolute path of the legacy .aw directory to retain");
204
+ if (!existsSync(join(source, "signing.key"))) fatal(`identity.source ${source} holds no signing.key, so there is no identity to retain`);
205
+ const lockPath = seatLockPath(source);
206
+ let takenOver;
207
+ if (existsSync(lockPath)) {
208
+ let held; try { held = JSON.parse(readFileSync(lockPath, "utf8")); } catch { held = {}; }
209
+ const holderHome = held.home;
210
+ // Held means the holder's home still exists: retire removes both the lock
211
+ // and the home, so a home that is there is a seat that was never retired.
212
+ // No process liveness is inferred (the spawner's pid says nothing about the
213
+ // runtime). The only escape is the explicit, warned take-over for a seat
214
+ // whose runtime is known to be dead.
215
+ if (holderHome && existsSync(holderHome)) {
216
+ if (takeOver !== true) fatal(`identity at ${source} is already held by ${holderHome} (${lockPath}); a seat is never taken from a holder whose home exists — retire that instance first, or set identity.takeOver: true only if you know its runtime is dead`);
217
+ takenOver = holderHome;
218
+ }
219
+ }
220
+ const srcWorkspace = existsSync(join(source, "workspace.yaml")) ? readFileSync(join(source, "workspace.yaml"), "utf8") : "";
221
+ const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
222
+ if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
223
+ const role = yamlScalar(srcWorkspace, "role_name");
224
+ let team = process.env.OATS_TEAM_ID;
225
+ if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
226
+ if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (set team.id in oats-config.yaml, or an active team in ${join(source, "teams.yaml")})`);
227
+ const dest = join(home, ".aw");
228
+ const legacyHome = dirname(source);
229
+ // The lock is taken FIRST: a concurrent second spawn must see it before any
230
+ // byte of the identity is copied.
231
+ // Exclusive creation (wx): two concurrent spawns cannot both pass the
232
+ // existence check and overwrite each other; the loser fails here having
233
+ // copied nothing. A take-over replaces the stale lock first, deliberately.
234
+ if (takenOver) { try { rmSync(lockPath, { force: true }); } catch { /* replaced below */ } }
235
+ try {
236
+ writeFileSync(lockPath, JSON.stringify({ home, instance, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
237
+ } catch (e) {
238
+ fatal(`identity at ${source} was taken by another spawn a moment ago (${lockPath} exists); nothing copied`);
239
+ }
240
+ let connected = false;
241
+ const rollback = () => {
242
+ // After `aw workspace connect` the server binding points at the new home;
243
+ // deleting the copy alone would leave the identity bound to nothing. Put
244
+ // the binding back where it was, from the legacy home, then remove the
245
+ // copy; if the restore fails, KEEP the copy so the seat stays recoverable.
246
+ let restored = !connected;
247
+ if (connected) {
248
+ try { run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], legacyHome, 60000); restored = true; }
249
+ catch { restored = false; }
250
+ }
251
+ if (restored) { try { rmSync(dest, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } }
252
+ return restored;
253
+ };
254
+ try {
255
+ mkdirSync(dest, { recursive: true, mode: 0o700 });
256
+ chmodSync(dest, 0o700);
257
+ for (const name of IDENTITY_AUTHORITY) {
258
+ const from = join(source, name);
259
+ if (!existsSync(from)) continue; // encryption material may be absent on an identity that never had it
260
+ const to = join(dest, name);
261
+ if (statSync(from).isDirectory()) {
262
+ cpSync(from, to, { recursive: true }); chmodSync(to, 0o700);
263
+ for (const f of readdirSync(to)) { const p = join(to, f); if (statSync(p).isFile()) chmodSync(p, 0o600); } // private keys inside, whatever the source modes were
264
+ } else { copyFileSync(from, to); chmodSync(to, 0o600); }
265
+ }
266
+ for (const forbidden of ["workspace.yaml", "context", "interaction-log.jsonl", "channel-delivered-ids.json", "chat-delivered-ids.json"]) {
267
+ if (existsSync(join(dest, forbidden))) rmSync(join(dest, forbidden), { recursive: true, force: true });
268
+ }
269
+ run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], home, 60000);
270
+ connected = true;
271
+ run(["aw", "check", "--online"], home, 60000);
272
+ run(["aw", "heartbeat"], home, 60000);
273
+ const status = run(["aw", "workspace", "status", "--json"], home, 60000);
274
+ // Thrown, not fatal: the catch below rolls the copy and the lock back first.
275
+ // Verified by parsing: the workspace row's path must be this home. The
276
+ // hostname the row records is whatever the binding stored (on hosted
277
+ // teams it need not equal this OS hostname), so it is reported, not judged.
278
+ let st; try { st = JSON.parse(String(status)); } catch { throw new Error(`aw workspace status from ${home} answered no JSON, so the seat is not connected; nothing is briefed`); }
279
+ const ws = st.workspace && typeof st.workspace === "object" ? st.workspace : st;
280
+ const shownPath = String(ws.workspace_path || ws.path || "");
281
+ const same = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
282
+ if (!shownPath || !same(shownPath, home)) throw new Error(`aw workspace status from ${home} shows workspace_path ${JSON.stringify(shownPath)} not this home, so the seat is not connected; nothing is briefed`);
283
+ const hostNote = ws.hostname && ws.hostname !== hostname() && ws.hostname.split(".")[0] !== hostname().split(".")[0] ? ` (workspace row hostname ${ws.hostname}, this host ${hostname()})` : "";
284
+ const identityText = readFileSync(join(dest, "identity.yaml"), "utf8");
285
+ const expectedDid = yamlScalar(identityText, "did");
286
+ const expectedAddress = yamlScalar(identityText, "address");
287
+ // The same-identity check the contract is for: `aw whoami --json` from the
288
+ // new home reports the did and address the CLI now acts as (workspace
289
+ // status carries no did); both must equal the copied identity.yaml.
290
+ let who; try { who = JSON.parse(String(run(["aw", "whoami", "--json"], home, 60000))); } catch (e) { throw new Error(`aw whoami from ${home} answered no JSON (${e.message || e}); the seat is not verified`); }
291
+ const shownDid = who.did || who.identity?.did;
292
+ const shownAddress = who.address || who.identity?.address;
293
+ if (expectedDid && shownDid !== expectedDid) throw new Error(`aw whoami shows did ${shownDid || "(none)"}, not the retained identity's ${expectedDid}; the seat is not the same identity`);
294
+ if (expectedAddress && shownAddress !== expectedAddress) throw new Error(`aw whoami shows address ${shownAddress || "(none)"}, not the retained identity's ${expectedAddress}; the seat is not the same identity`);
295
+ const aliasRaw = String(ws.alias || st.alias || (expectedAddress || "").split("/").pop() || instance);
296
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
297
+ const alias = aliasRaw;
298
+ if (expectedAddress && !expectedAddress.endsWith(`/${alias}`)) throw new Error(`aw workspace status shows alias ${alias}, not the retained identity's address ${expectedAddress}; the seat is not the same identity`);
299
+ writeFileSync(lockPath, JSON.stringify({ home, instance, alias, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600 });
300
+ const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
301
+ ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
302
+ : undefined;
303
+ const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
304
+ const deliveryBrief = deliveryMode === "session"
305
+ ? ` Notification delivery: external (AWEB_DELIVERY=session); until the host wake broker registers this instance NOTHING wakes you: check \`aw mail inbox\` and \`aw chat pending\` at every task boundary.`
306
+ : "";
307
+ if (deliveryMode === "session") wakeRegister(home, dest);
308
+ const warnings = [];
309
+ if (takenOver) warnings.push(`oats-aweb: took over the retained identity from ${takenOver} on identity.takeOver: true; if that runtime was still alive there are now two seats with one key — stop the old one`);
310
+ if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
311
+ out({
312
+ meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, ...(takenOver ? { tookOverFrom: takenOver } : {}) },
313
+ ...(env ? { env } : {}),
314
+ brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
315
+ ...(launch ? { launch } : {}),
316
+ ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
317
+ });
318
+ } catch (e) {
319
+ const restored = rollback();
320
+ fatal(`retained identity could not be seated from ${source}: ${e.message || e}${connected ? (restored ? " (the server binding was restored to the legacy home and the copy removed)" : ` (the server binding still points at ${home} and the copy was KEPT there so the seat is recoverable: run aw workspace connect from ${legacyHome} to restore it, or retry the spawn)`) : ""}`);
321
+ }
322
+ }
323
+
151
324
  if (event === "spawn") {
325
+ if (settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
152
326
  let minted; // external identity, once `aw team join` succeeds
153
327
  const root = awebRoot();
154
328
  if (!root) fatal(`no initialized aweb root (.aw) among the bounded candidates (home, its git repo, context repo, workspace ${process.env.OATS_WORKSPACE || "?"}), so no identity could be minted and this instance would have no messaging — run \`oats aweb setup\` for guided onboarding`);
@@ -173,7 +347,18 @@ if (event === "spawn") {
173
347
  // so neither their output nor their diagnostics may reach a log.
174
348
  const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
175
349
  if (!inv?.token || typeof inv.token !== "string") fatal("aw team invite returned no usable token, so no identity could be minted");
176
- const raw = parseSecretJson(run(["aw", "team", "join", inv.token, "--name", instance, "--json"], home, 45000, { secrets: [inv.token], secretSafe: true }), "aw team join");
350
+ let raw;
351
+ try {
352
+ raw = parseSecretJson(run(["aw", "team", "join", inv.token, "--name", instance, "--json"], home, 45000, { secrets: [inv.token], secretSafe: true }), "aw team join");
353
+ } catch (e) {
354
+ // A retired alias keeps its certificate until aweb-abim ships, so a
355
+ // re-spawn under the same name is refused by AWID. Say that, and the
356
+ // remedy, instead of relaying a bare join error.
357
+ if (e.aliasConflict) {
358
+ fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — spawn with a fresh --purpose instead`);
359
+ }
360
+ throw e;
361
+ }
177
362
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) fatal("aw team join returned no usable result, so no identity could be minted", minted);
178
363
  // The RESPONSE is not a safe place to take strings from. Suppressing the
179
364
  // failure paths does nothing if a successful reply is copied into meta and
@@ -203,13 +388,21 @@ if (event === "spawn") {
203
388
  // spawn, which is exactly the silent host mutation the consent gate exists
204
389
  // to prevent. By the time this runs the kernel has already proven the plugin
205
390
  // is present and enabled, so contributing the flag is safe.
206
- const launch = (process.env.OATS_RUNTIME || "") === "claude"
391
+ // Session delivery: no channel flag, AWEB_DELIVERY=session in the launch
392
+ // environment (declared in the manifest), and the truth about waking.
393
+ const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
207
394
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
208
395
  : undefined;
396
+ const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
209
397
  const channelWarning = undefined;
398
+ if (deliveryMode === "session") wakeRegister(home, join(home, ".aw"));
399
+ const deliveryBrief = deliveryMode === "session"
400
+ ? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
401
+ : "";
210
402
  out({
211
- meta: { team: joined.team_id, alias },
212
- brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
403
+ meta: { team: joined.team_id, alias, delivery: deliveryMode },
404
+ ...(env ? { env } : {}),
405
+ brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
213
406
  ...(launch ? { launch } : {}),
214
407
  ...(mismatch ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : channelWarning ? { warning: channelWarning } : {}),
215
408
  });
@@ -221,6 +414,14 @@ if (event === "spawn") {
221
414
  }
222
415
  } else if (event === "retire") {
223
416
  const meta = JSON.parse(process.env.OATS_META || "{}");
417
+ // A retained seat: release the lock and leave the identity alone. Never
418
+ // aw workspace delete (it would soft-delete the standing identity's row)
419
+ // and never team retire; the source .aw stays until a human removes it.
420
+ if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
421
+ if (meta.retained) {
422
+ if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
423
+ out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
424
+ }
224
425
  // No alias means the spawn hook never reported an identity: nothing exists to
225
426
  // undo, which is completion. An alias WITH no local `.aw` is the opposite —
226
427
  // the remote record exists and its key is gone, so the self-delete cannot be
@@ -233,7 +434,11 @@ if (event === "spawn") {
233
434
  // Self-delete from inside the home, authenticated by its own key — a remote
234
435
  // delete would 409 until the server marks the workspace stale.
235
436
  run(["aw", "workspace", "delete", meta.alias], home);
236
- out({ meta: { retired: true } });
437
+ // Honest: the workspace row is deleted, but a hosted local member cannot
438
+ // revoke its own AWID certificate (aweb-abim), so the alias is NOT
439
+ // reusable. retired stays true because the cleanup is as complete as the
440
+ // platform allows; the field and the line carry the truth.
441
+ out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a fresh --purpose` });
237
442
  } catch (e) {
238
443
  // Exit nonzero: during a required-hook rollback this is the signal that
239
444
  // compensation did NOT complete, so the spawn is not reported as cleanly
@@ -40,10 +40,17 @@ Aliases are instance names (e.g. `dev-coordinator-1`). Discovery:
40
40
  `oats status --team` lists this machine's live instances; `oats aweb roster`
41
41
  lists the aweb team across machines.
42
42
 
43
+ **Notification delivery.** Your instance briefing (TASK.md, the Comms line)
44
+ says how messages reach you. If it carries "Notification delivery: external",
45
+ the native channel is NOT running in this session and, until the host wake
46
+ broker registers you, nothing wakes you: check `aw mail inbox` and
47
+ `aw chat pending` at every task boundary. Otherwise the rule below applies.
48
+
43
49
  **Never sleep, poll, or busy-wait for another agent's reply.** Send your
44
- message, finish your turn, and go idle — the aweb channel awakens your
45
- session the moment mail or chat arrives (you saw `✓ aweb connected` at
46
- startup). A `sleep N; aw mail inbox` loop burns tokens, delays the reply,
50
+ message, finish your turn, and go idle when a delivery channel is configured
51
+ (you saw `✓ aweb connected` at startup). Native Codex has no aweb channel:
52
+ check inbox and pending chat at task boundaries or when the operator asks,
53
+ as described in TASK.md; incoming messages alone will not wake that session. A `sleep N; aw mail inbox` loop burns tokens, delays the reply,
47
54
  and adds nothing. An empty `aw mail inbox` means no UNREAD mail — not that
48
55
  messages were lost.
49
56
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "capability": "oats.aweb",
3
3
  "command": "aweb",
4
- "version": "1.8.0",
4
+ "version": "1.10.0",
5
5
  "compatibility": {
6
- "oats": ">=0.19.0"
6
+ "oats": ">=0.22.3"
7
7
  },
8
8
  "layer": "messaging",
9
9
  "description": "Messaging layer via aweb: per-instance team identities + native aw mail/chat skills + cross-machine team roster.",
@@ -16,15 +16,32 @@
16
16
  {
17
17
  "runtime": "pi",
18
18
  "package": "npm:@awebai/pi",
19
- "why": "the aweb channel extension for pi sessions — real-time mail/chat awakenings; without it a pi instance can send with `aw` but is never woken by incoming messages",
20
- "install": "https://aweb.ai/docs (installed into pi with `pi install npm:@awebai/pi`)"
19
+ "why": "the aweb channel extension for pi sessions \u2014 real-time mail/chat awakenings; without it a pi instance can send with `aw` but is never woken by incoming messages",
20
+ "install": "https://aweb.ai/docs (installed into pi with `pi install npm:@awebai/pi`)",
21
+ "when": {
22
+ "delivery": "channel"
23
+ }
21
24
  },
22
25
  {
23
26
  "runtime": "claude",
24
27
  "package": "aweb-channel@awebai-marketplace",
25
28
  "marketplace": "awebai/claude-plugins",
26
- "why": "the aweb channel plugin for Claude Code sessions — real-time mail/chat awakenings; without it a Claude instance can send with `aw` but is never woken by incoming messages",
27
- "install": "https://aweb.ai/docs (installs the awebai marketplace and the aweb-channel plugin into Claude Code)"
29
+ "why": "the aweb channel plugin for Claude Code sessions \u2014 real-time mail/chat awakenings; without it a Claude instance can send with `aw` but is never woken by incoming messages",
30
+ "install": "https://aweb.ai/docs (installs the awebai marketplace and the aweb-channel plugin into Claude Code)",
31
+ "when": {
32
+ "delivery": "channel"
33
+ }
34
+ },
35
+ {
36
+ "runtime": "pi",
37
+ "package": "npm:@awebai/pi",
38
+ "minVersion": "0.3.10",
39
+ "when": {
40
+ "delivery": "session"
41
+ },
42
+ "why": "an ambient aweb pi extension, if one is installed, must honour AWEB_DELIVERY=session (0.3.10 and later); an older one would open a second event stream beside the wake broker; no extension at all is fine",
43
+ "install": "pi install npm:@awebai/pi@latest",
44
+ "ifInstalled": true
28
45
  }
29
46
  ],
30
47
  "skills": [
@@ -43,5 +60,21 @@
43
60
  "required": true
44
61
  },
45
62
  "retire": "bin/oats-aweb.mjs retire"
46
- }
63
+ },
64
+ "environment": [
65
+ "AWEB_DELIVERY"
66
+ ],
67
+ "settings": {
68
+ "delivery": {
69
+ "default": "channel",
70
+ "values": [
71
+ "channel",
72
+ "session"
73
+ ],
74
+ "description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
75
+ }
76
+ },
77
+ "environmentNamespaces": [
78
+ "AWEB_"
79
+ ]
47
80
  }
@@ -1,7 +1,9 @@
1
1
  # memory-harvest — soul promotion from live instances
2
2
 
3
3
  You are a memory-harvest instance. You were spawned because a live agent
4
- instance committed work while holding pending notes.
4
+ instance committed work while holding pending notes, or because its own
5
+ captured session turns hold candidates nobody has judged yet (your briefing
6
+ says which, and names the exact record windows when it is the latter).
5
7
 
6
8
  **Your briefing (TASK.md) is the authority on your situation**: the source
7
9
  notes dir, the soul to update, the work tree you were given, and how your
@@ -11,6 +11,9 @@
11
11
  * spawn scaffold instance memory (STATE.md, log.md, notes/) + brief
12
12
  * retire no-op (promotion is continuous — see harvest)
13
13
  * harvest AGENT-INITIATED (not a kernel hook): run from an instance
14
+ * home; with no pending notes (or with --from-record) it
15
+ * harvests the instance's own captured session turns since
16
+ * the last harvest (watermark .okf-harvest-record.json)
14
17
  * home (`node <pkg>/capabilities/oats-okf/bin/oats-okf.mjs harvest`)
15
18
  * after committing with pending notes — spawns the memory-harvest
16
19
  * agent attached to this instance's work tree.
@@ -23,7 +26,7 @@
23
26
  import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
24
27
  import { join, isAbsolute, dirname } from "node:path";
25
28
  import { tmpdir } from "node:os";
26
- import { execFile } from "node:child_process";
29
+ import { execFile, spawnSync } from "node:child_process";
27
30
 
28
31
  const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); process.exit(0); };
29
32
  const warn = (m) => out({ warning: `oats-okf: ${String(m).slice(0, 300)}` });
@@ -66,6 +69,97 @@ function packageRuntimeCli() {
66
69
  return cli;
67
70
  }
68
71
 
72
+ /** Record-fed harvest (aweb-abfz). An instance that writes no notes still
73
+ * leaves a record: every Claude Code, pi and Codex session on the machine is
74
+ * captured as session turns, and the sessions that ran inside this home are
75
+ * the instance's own. Ask the kernel to capture them and report exact
76
+ * sequence boundaries, compare with the watermark of what was already
77
+ * harvested, and return the windows that are new — or null when nothing is.
78
+ * The watermark advances only when the harvester delivers (it writes the
79
+ * file its briefing hands it), so a failed harvest re-reads the same window. */
80
+ const RECORD_WATERMARK = ".okf-harvest-record.json";
81
+ /** A window is what one harvester can actually read: a first harvest of a
82
+ * long-lived session must not hand it the whole thread (tens of MB on real
83
+ * homes) and then let it advance the watermark past what it never read. The
84
+ * plan sizes each window with an ids-only listing and stops at the turn or
85
+ * byte cap; the rest drains over later harvests, each with a truthful
86
+ * watermark. Overridable through okf settings { "record-window-turns",
87
+ * "record-window-bytes" }. */
88
+ // Sized to ONE tool-output read: harnesses truncate a command's output well
89
+ // under 100 KB (Claude Code around 30 KB), and the byte cap is measured on
90
+ // the JSON the harvester receives, not on the text inside it. A backlog
91
+ // drains over successive harvests; the caps are settings for operators
92
+ // whose harness reads more.
93
+ const DEFAULT_WINDOW_TURNS = 60;
94
+ const DEFAULT_WINDOW_BYTES = 96_000;
95
+ function sizeWindow(cli, thread, afterTurnId, caps) {
96
+ const list = (after) => {
97
+ const args = ["recall", "--thread", thread, "--json", "--ids-only", "--limit", String(caps.turns)];
98
+ if (after) args.push("--after", after);
99
+ const r = spawnSync(cli, args, { encoding: "utf8", env: process.env, timeout: 120000, maxBuffer: 64 * 1024 * 1024 });
100
+ if (r.status !== 0) return { error: String(r.stderr || r.error?.message || `recall exited ${r.status}`).trim().slice(0, 200) };
101
+ try { return { doc: JSON.parse(String(r.stdout || "").trim()) }; } catch (e) { return { error: `recall answered no JSON: ${String(e.message).slice(0, 100)}` }; }
102
+ };
103
+ let { doc, error } = list(afterTurnId);
104
+ let restarted = false;
105
+ // The watermark's boundary turn can leave the thread (a redaction hides it
106
+ // for good). That must not strand the thread: read from the start again,
107
+ // bounded as always, and say so. The harvester's own fallback covers the
108
+ // same case between plan and read.
109
+ if (error && afterTurnId && /--after: no turn/.test(error)) { ({ doc, error } = list(null)); restarted = true; }
110
+ if (error) return { error };
111
+ let bytes = 0; let n = 0;
112
+ for (const t of doc.turns || []) {
113
+ if (n > 0 && bytes + t.bytes > caps.bytes) break; // always at least one turn, so a single huge turn still drains
114
+ bytes += t.bytes; n++;
115
+ }
116
+ if (!n) return { empty: true };
117
+ return { untilTurnId: doc.turns[n - 1].id, newTurns: n, bytes, remaining: (doc.turns.length - n) + (doc.remaining || 0), restarted };
118
+ }
119
+ function planRecordHarvest(instanceHome) {
120
+ const watermarkPath = join(instanceHome, RECORD_WATERMARK);
121
+ let prior = {};
122
+ try { prior = JSON.parse(readFileSync(watermarkPath, "utf8")).threads || {}; } catch { prior = {}; }
123
+ let report;
124
+ try {
125
+ const r = spawnSync(packageRuntimeCli(), ["capture", "--home", instanceHome, "--quiet"], { encoding: "utf8", env: process.env, timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
126
+ if (r.status !== 0) return { unavailable: String(r.stderr || r.error?.message || `capture exited ${r.status}`).trim().slice(0, 200) };
127
+ report = JSON.parse(String(r.stdout || "").trim());
128
+ } catch (e) { return { unavailable: String(e.message || e).slice(0, 200) }; }
129
+ const positive = (v, d) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
130
+ const caps = { turns: positive(settings["record-window-turns"], DEFAULT_WINDOW_TURNS), bytes: positive(settings["record-window-bytes"], DEFAULT_WINDOW_BYTES) };
131
+ const threads = [];
132
+ const problems = [];
133
+ for (const s of report.sessions || []) {
134
+ const seen = prior[s.thread];
135
+ // Nothing new when the last visible turn is the one already harvested;
136
+ // ids, not counts, so a redaction inside the harvested prefix neither
137
+ // hides genuinely new turns nor re-reads old ones.
138
+ if (seen && seen.untilTurnId === s.lastTurnId) continue;
139
+ const win = sizeWindow(packageRuntimeCli(), s.thread, seen?.untilTurnId || null, caps);
140
+ if (win.error) { problems.push(`${s.thread}: ${win.error}`); continue; }
141
+ if (win.empty) { problems.push(`${s.thread}: capture reports new turns after ${seen?.untilTurnId || "the start"} but recall lists none; the two views disagree, nothing planned for it`); continue; }
142
+ if (win.restarted) problems.push(`${s.thread}: the harvested boundary ${seen.untilTurnId} is no longer in the thread (redacted?); reading from the start again`);
143
+ threads.push({ thread: s.thread, source: s.source, afterTurnId: win.restarted ? null : (seen?.untilTurnId || null), untilTurnId: win.untilTurnId, turns: (win.restarted ? 0 : (seen?.turns || 0)) + win.newTurns, newTurns: win.newTurns, bytes: win.bytes, remaining: win.remaining });
144
+ }
145
+ if (!threads.length) return problems.length ? { unavailable: problems.join("; ") } : null;
146
+ const next = { threads: { ...prior } };
147
+ for (const t of threads) next.threads[t.thread] = { untilTurnId: t.untilTurnId, turns: t.turns, harvestedAt: new Date().toISOString() };
148
+ // The exact next watermark is written beside the current one by the
149
+ // package; the harvester's delivery is a rename, nothing retyped.
150
+ const nextPath = join(instanceHome, RECORD_WATERMARK.replace(/\.json$/, ".next.json"));
151
+ writeFileSync(nextPath, JSON.stringify(next, null, 2) + "\n");
152
+ return { threads, watermarkPath, nextPath, watermark: next, unattributed: (report.unattributed || []).length, problems };
153
+ }
154
+
155
+ /** Briefing block for record-fed candidates, appended to the harvest task. */
156
+ function recordBrief(plan, cli) {
157
+ if (!plan?.threads?.length) return "";
158
+ const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
159
+ const lines = plan.threads.map((t) => ` - ${t.thread} (${t.newTurns} turns, ~${Math.round(t.bytes / 1024)} KB of JSON${t.remaining ? `, ${t.remaining} more wait for the next harvest` : ""}): \`${cli} recall --thread ${q(t.thread)} --json${t.afterTurnId ? ` --after ${q(t.afterTurnId)}` : ""} --until ${q(t.untilTurnId)}\``);
160
+ return `\n- RECORD-FED CANDIDATES (the memory-harvest skill, section "Record-fed candidates"): this instance's own captured session turns since the last harvest, in windows sized for one reading (about ${Math.round(DEFAULT_WINDOW_BYTES / 1024)} KB at most). Read each window with the exact command given, never wider, and read it IN FULL. If your tool output truncates, redirect the command's output to a file in your home and read that file in parts: that is a complete reading, not a wider one. A window you could not read completely is a failed harvest, and a failed harvest leaves the watermark alone.\n${lines.join("\n")}\n If a window command is rejected because its --after id is no longer in the thread, run it again without --after and read from the start; if its --until id is rejected, this harvest has failed (leave the watermark files alone; the next oats okf harvest replans). Extract candidate lessons from them in the same shape as notes (one candidate per insight, provenance = the turn ids it came from), then judge every candidate under the same promotion bar as a note. Session trivia, tool noise and anything derivable from the repo fail the bar; promoting nothing is a normal outcome.\n- When your judgement of every window is COMPLETE, whether or not anything was promoted, and after any delivery it needed, advance the watermark by renaming the prepared file (it records what you read, not what you promoted; a failed or abandoned harvest must leave both files as they are):\n mv '${plan.nextPath}' '${plan.watermarkPath}'`;
161
+ }
162
+
69
163
  /** Invoke the versioned package-runtime boundary. Task text crosses the
70
164
  * process boundary only through an owner-readable tempfile, removed on every
71
165
  * success/failure outcome. */
@@ -240,10 +334,15 @@ _(the single next action — keep this current; a fresh session on any model res
240
334
  const skip = (why) => (JSON_MODE ? jsonOk({ harvest: "skipped", reason: why }) : out({ meta: { harvestSpawn: "skipped", why } }));
241
335
  if (String(agName).startsWith("memory-harvest")) skip("self (loop guard)");
242
336
  const notes = existsSync(notesDir) ? readdirSync(notesDir).filter((f) => f.endsWith(".md")) : [];
243
- if (notes.length === 0) skip("no pending notes");
244
- if (!root || (!existsSync(root) && !existsSync(join(dirname(root), "local-agents")))) skip("no agents root found above this home");
245
- if (!inst) skip("no instance identity (run from an instance home)");
246
- if (!context) skip("no repository context (instance metadata has no repo)");
337
+
338
+ // With no notes the record path could still apply, but it needs the same
339
+ // root, identity and context as any spawn; a home missing them answers
340
+ // exactly as before ("no pending notes"), so nothing an operator scripted
341
+ // against that reason changes.
342
+ const prerequisite = (why) => skip(notes.length ? why : "no pending notes");
343
+ if (!root || (!existsSync(root) && !existsSync(join(dirname(root), "local-agents")))) prerequisite("no agents root found above this home");
344
+ if (!inst) prerequisite("no instance identity (run from an instance home)");
345
+ if (!context) prerequisite("no repository context (instance metadata has no repo)");
247
346
  const slug = String(inst).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
248
347
  // Debounce: one harvester per source instance at a time (canonical sibling
249
348
  // local-agents/ plus legacy nested locations). The public spawn boundary
@@ -254,6 +353,23 @@ _(the single next action — keep this current; a fresh session on any model res
254
353
  join(root, "tmp-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
255
354
  ];
256
355
  if (harvesterHomes.some((h) => existsSync(h))) skip("harvester already running for this instance");
356
+ // No notes is no longer the end: the record may hold this instance's own
357
+ // sessions with turns nobody has judged yet (standing, non-coding roles
358
+ // write few notes). --from-record asks for the record even with notes.
359
+ // Planned only now, after every skip above: a capture pass is a real
360
+ // write and index, and "calling it too often is safe" must stay true.
361
+ let recordPlan = null;
362
+ if (notes.length === 0 || process.argv.includes("--from-record")) {
363
+ let planned = null;
364
+ try { planned = planRecordHarvest(home); } catch { planned = null; }
365
+ if (planned?.unavailable) {
366
+ process.stderr.write(`oats-okf: record unavailable${notes.length ? ", harvesting notes only" : ""}: ${planned.unavailable}\n`);
367
+ if (notes.length === 0) skip("no pending notes");
368
+ } else recordPlan = planned;
369
+ for (const line of recordPlan?.problems || []) process.stderr.write(`oats-okf: record: ${line}\n`);
370
+ if (recordPlan?.unattributed) process.stderr.write(`oats-okf: record: ${recordPlan.unattributed} session file(s) carry no working directory and cannot be attributed to any home (oats capture --home <home> lists them)\n`);
371
+ if (notes.length === 0 && !recordPlan) skip("no pending notes");
372
+ }
257
373
  // Effective command settings are injected by capability dispatch. No
258
374
  // resolved-config read crosses the public package boundary.
259
375
  const harvestModel = settings["harvest-model"] || DEFAULT_HARVEST_MODEL;
@@ -268,7 +384,7 @@ _(the single next action — keep this current; a fresh session on any model res
268
384
  // harvester judges notes exactly as usual, but the deliverable is DIRECT
269
385
  // edits to the canonical soul — no commit, no PR: there is nothing to
270
386
  // version. It must not touch the owner's work tree.
271
- const task = `Harvest the pending notes of live LOCAL-SOUL instance "${inst}" (agent "${agName}") into its soul — by direct edits, no commit.\n\n- Source notes: ${notesDir} (${notes.join(", ")})\n- Soul knowledge bundle to update: ${join(realSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(realSoul, "skills")}\n- This soul is LOCAL (uncommitted, gitignored): edit those soul files IN PLACE. Do NOT run git commit — not for the soul, and not in ./work (the shared tree belongs to the working instance; leave it untouched).\n- Follow your memory-harvest skill for everything else: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir.\n- Then run \`oats retire ${harvName} --self\`.`;
387
+ const task = `Harvest the pending notes of live LOCAL-SOUL instance "${inst}" (agent "${agName}") into its soul — by direct edits, no commit.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Soul knowledge bundle to update: ${join(realSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(realSoul, "skills")}\n- This soul is LOCAL (uncommitted, gitignored): edit those soul files IN PLACE. Do NOT run git commit — not for the soul, and not in ./work (the shared tree belongs to the working instance; leave it untouched).\n- Follow your memory-harvest skill for everything else: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir.\n${recordBrief(recordPlan, packageRuntimeCli())}\n- Then run \`oats retire ${harvName} --self\`.`;
272
388
  r = await spawnHarvester(harvestSpawnArgs({
273
389
  slug, parent: inst, repo: context, work: "attached", workDir, model: harvestModel,
274
390
  }), task);
@@ -280,7 +396,7 @@ _(the single next action — keep this current; a fresh session on any model res
280
396
  const soulRepo = gitRootOf(realSoul);
281
397
  if (!soulRepo) skip("workspace-mode soul is not inside a git repo — nowhere to deliver a PR");
282
398
  const relSoul = realSoul.slice(soulRepo.length + 1);
283
- const task = `Harvest the pending notes of live WORKSPACE-MODE instance "${inst}" (agent "${agName}") into its soul — delivered as a PR.\n\n- Source notes: ${notesDir} (${notes.join(", ")})\n- Your ./work is a dedicated worktree of the soul's home repo (${soulRepo}), branch memory-harvest/${slug}.\n- Soul knowledge bundle to update: ./work/${join(relSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ./work/${join(relSoul, "skills")}\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir, commit once (prefixed "memory-harvest:").\n- Then push the branch and open a PR (\`git push -u origin memory-harvest/${slug}\` then \`gh pr create --fill\`). Do NOT merge it; the humans/owners of ${soulRepo} review soul changes. If gh is unavailable, push the branch and report the compare URL.\n- Finally run \`oats retire ${harvName} --self\` (keep the branch: --self only).`;
399
+ const task = `Harvest the pending notes of live WORKSPACE-MODE instance "${inst}" (agent "${agName}") into its soul — delivered as a PR.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Your ./work is a dedicated worktree of the soul's home repo (${soulRepo}), branch memory-harvest/${slug}.\n- Soul knowledge bundle to update: ./work/${join(relSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ./work/${join(relSoul, "skills")}\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir, and commit once (prefixed "memory-harvest:") if anything changed.${recordBrief(recordPlan, packageRuntimeCli())}\n- If you changed anything: push the branch and open a PR (\`git push -u origin memory-harvest/${slug}\` then \`gh pr create --fill\`). Do NOT merge it; the humans/owners of ${soulRepo} review soul changes. If gh is unavailable, push the branch and report the compare URL. A harvest that promoted nothing has nothing to commit, push or open; that is a completed harvest, not a failed one.\n- Finally run \`oats retire ${harvName} --self\` (keep the branch: --self only).`;
284
400
  r = await spawnHarvester(harvestSpawnArgs({
285
401
  slug, parent: inst, repo: soulRepo, work: "worktree",
286
402
  branch: `memory-harvest/${slug}`, model: harvestModel,
@@ -292,12 +408,12 @@ _(the single next action — keep this current; a fresh session on any model res
292
408
  const soulTarget = realSoul.startsWith(realRepo + "/")
293
409
  ? join(workDir, realSoul.slice(realRepo.length + 1))
294
410
  : realSoul;
295
- const task = `Harvest the pending notes of live instance "${inst}" (agent "${agName}") into its soul.\n\n- Source notes: ${notesDir} (${notes.join(", ")})\n- Soul knowledge bundle to update: ${join(soulTarget, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(soulTarget, "skills")}\n- You are ATTACHED to the instance's work tree (./work) — commit your promotions there as a single commit, prefixed "memory-harvest:".\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir (so they are not re-harvested), commit, then run \`oats retire ${harvName} --self\`.`;
411
+ const task = `Harvest the pending notes of live instance "${inst}" (agent "${agName}") into its soul.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Soul knowledge bundle to update: ${join(soulTarget, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(soulTarget, "skills")}\n- You are ATTACHED to the instance's work tree (./work) — commit your promotions there as a single commit, prefixed "memory-harvest:".\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir (so they are not re-harvested).${recordBrief(recordPlan, packageRuntimeCli())}\n- Commit if you changed anything (a harvest that promoted nothing has nothing to commit), then run \`oats retire ${harvName} --self\`.`;
296
412
  r = await spawnHarvester(harvestSpawnArgs({
297
413
  slug, parent: inst, repo: context, work: "attached", workDir, model: harvestModel,
298
414
  }), task);
299
415
  }
300
- if (JSON_MODE) jsonOk({ harvest: "spawned", instance: r.instance, window: r.tmux?.window || null });
416
+ if (JSON_MODE) jsonOk({ harvest: "spawned", instance: r.instance, window: r.tmux?.window || null, ...(recordPlan ? { record: { threads: recordPlan.threads.map((t) => t.thread), ...(recordPlan.unattributed ? { unattributed: recordPlan.unattributed } : {}), ...(recordPlan.problems?.length ? { problems: recordPlan.problems } : {}) } } : {}) });
301
417
  out({ meta: { harvestSpawn: r.instance, window: r.tmux?.window } });
302
418
  } catch (e) {
303
419
  if (JSON_MODE) jsonFail(e.code || "E_HARVEST_FAILED", `harvest spawn failed (notes are safe on disk): ${e.message || e}`);
@@ -43,6 +43,13 @@ are no notes or a harvester is already running — calling it "too often" is
43
43
  safe; not calling it means your insights never reach the soul, and unwritten
44
44
  or unharvested notes are lost when your home is retired).
45
45
 
46
+ **If you write few notes** (a coordinating or reviewing role, a standing
47
+ session): still run `oats okf harvest` at task boundaries, and at least once
48
+ a day. With no notes pending it harvests your own captured session turns
49
+ since the last harvest instead; the harvester judges them under the same
50
+ bar. It skips when nothing is new. `oats okf harvest --from-record` asks for
51
+ the record even when notes are pending.
52
+
46
53
  **Workspace-mode instances**: your soul lives in its own home repo, and your
47
54
  `./work` (the workspace) is not where it commits. `oats okf harvest` handles
48
55
  this — it promotes your notes in a worktree of the soul's home repo and
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "capability": "oats.okf",
3
3
  "command": "okf",
4
- "version": "1.4.1",
5
- "compatibility": { "oats": ">=0.19.0" },
4
+ "version": "1.5.0",
5
+ "compatibility": { "oats": ">=0.22.2" },
6
6
  "layer": "knowledge",
7
7
  "description": "Knowledge layer via OKF: soul bundles, instance memory (STATE.md/log.md/notes/), continuous post-commit harvest into the soul (commit, PR, or direct-edit for local souls), craft + memory skills, validator.",
8
8
  "requires": [],