@awebai/oats 0.22.1 → 0.22.2

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.
@@ -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": [],
@@ -68,6 +68,45 @@ knowledge; if instances should RUN it the same way every time, it wants to
68
68
  be a skill instead. Souls also grow role-specific types and sections — list
69
69
  new sections in the bundle index and log the growth.
70
70
 
71
+ ## Record-fed candidates
72
+
73
+ Your briefing may name **record windows** beside (or instead of) notes: the
74
+ source instance's own captured session turns since the last harvest, each
75
+ window given as an exact `oats recall --thread <t> --json --after <id>
76
+ --until <id>` command. Standing roles that write few notes still learn; this
77
+ is how what they learned reaches the soul.
78
+
79
+ - Run each command exactly as given, never wider: the ids are the boundary
80
+ two harvests agree on, and each window is sized for one full reading (the
81
+ rest of a long backlog comes in later harvests). Read every window in full.
82
+ If your tool output truncates, redirect the command's output to a file in
83
+ your home and read the file in parts; that is a complete reading, not a
84
+ wider one. If you still could not read a window completely, this harvest
85
+ has FAILED: judge nothing from it and leave the watermark files untouched. If a command is rejected because its
86
+ `--after` id is no longer in the thread (a pruned or redacted record), run
87
+ it again without `--after` and read from the start; if its `--until` id is
88
+ rejected, this harvest has failed (leave the watermark files alone; the next
89
+ `oats okf harvest` replans). Read the `text` parts;
90
+ `tool_use` and `tool_result` are context, not lessons.
91
+ - Extract **candidates** in the shape of notes: one candidate per insight, a
92
+ one-line title, the claim, and its provenance as the turn ids it came from.
93
+ A candidate is something the instance learned or decided, stated in the
94
+ turns, not something you infer it should have learned.
95
+ - Then judge every candidate exactly as a note: promote, merge, or drop
96
+ against the same bar. Expect most to drop: session trivia, tool noise,
97
+ restated repo facts and task-scoped decisions all fail it. Promoted
98
+ concepts cite the turn ids in their frontmatter or body so the claim can be
99
+ traced back.
100
+ - **The watermark records what you read, not what you promoted.** The
101
+ package prepared the exact next watermark beside the current one; your
102
+ briefing gives the one `mv` that advances it. Run it once your judgement of
103
+ every window is complete: after the commit, PR, or direct edit when
104
+ something was promoted, and just the same when everything dropped, which
105
+ is the normal outcome. Never retype it. Only a harvest that fails or is
106
+ abandoned leaves both files untouched, so the next harvester reads the same
107
+ window again; a completed judgement that never advanced the watermark would
108
+ be re-read forever.
109
+
71
110
  ## Bookkeeping (non-negotiable)
72
111
 
73
112
  1. Every promoted concept: correct frontmatter, listed in its section's
@@ -92,7 +131,9 @@ what your custody allows:
92
131
  `memory-harvest: 2 lessons + 1 skill gotcha from worker-x notes`.
93
132
  2. **Worktree of the soul's home repo** (workspace-mode source): the same single
94
133
  commit on your own branch, then push it and open a PR. Never merge it, and
95
- never push to that repo's main branch — its owners review soul changes.
134
+ never push to that repo's main branch — its owners review soul changes. A
135
+ harvest that promoted nothing has no commit, push or PR to make; it is
136
+ complete, not failed, and still advances the watermark.
96
137
  3. **Uncommitted local soul**: nothing to commit. Your edits to the soul ARE the
97
138
  delivery; they take effect for the next instance immediately.
98
139
 
@@ -0,0 +1,181 @@
1
+ # Execution targets and shared wake delivery
2
+
3
+ Implementation agreement, 2026-09-05. Lead owns native runtime launch,
4
+ local tmux/Herdr adapters and terminal input; oats owns server registration,
5
+ remote CLI routing and Desktop target selection. Aweb owns the event listener,
6
+ notification state and delivery policy through OATS terminal input. This is the implementation
7
+ contract, not a claim that these features have shipped.
8
+
9
+ OATS manages composition, worktrees, capability lifecycle and retirement on the execution
10
+ host. A session backend manages the persistent terminal. Desktop is a client;
11
+ closing it must stop neither the agent nor notification delivery.
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ UI[Desktop] --> CLI[OATS CLI]
16
+ CLI --> Local[Local OATS]
17
+ CLI --> SSH[OpenSSH]
18
+ SSH --> Remote[Remote OATS]
19
+ Local --> Sessions[tmux or Herdr]
20
+ Remote --> RemoteSessions[tmux or Herdr]
21
+ Events[aweb SSE] --> Wake[aweb wake service on execution host]
22
+ Wake --> Local
23
+ RemoteWake[aweb wake service on remote host] --> Remote
24
+ ```
25
+
26
+ Server registrations live in the operator's machine configuration, outside
27
+ repository configuration. Each entry has an id, label, OpenSSH host alias,
28
+ absolute workspace path and OATS/Herdr executable paths. SSH owns key selection,
29
+ host verification and authentication. Registration stores no private keys.
30
+ Remote lifecycle calls invoke the remote installed OATS CLI with argument-safe
31
+ quoting and the same JSON envelope as local calls. Version/envelope compatibility
32
+ is checked before mutation. Repository operations always run on that host.
33
+
34
+ The local representation of a remote instance snapshots its route:
35
+
36
+ ```json
37
+ {
38
+ "serverId": "build-server",
39
+ "target": {
40
+ "sshHost": "build-server",
41
+ "workspace": "/srv/team",
42
+ "oatsPath": "/usr/local/bin/oats",
43
+ "herdrPath": "/usr/local/bin/herdr"
44
+ },
45
+ "instance": "developer-fix",
46
+ "home": "/srv/team/agents/developer/instances/developer-fix"
47
+ }
48
+ ```
49
+
50
+ `serverId` is for display. Later inspect/retire operations use the snapshot,
51
+ never silently resolve a changed registry entry. A local cache is not authority
52
+ for the remote instance's state. Remote status is pulled from its owning kernel.
53
+
54
+ The host's instance and independent retirement baseline retain the same local
55
+ session receipt. Existing `tmux: {session, window, socket}` remains readable.
56
+ New Herdr instances use:
57
+
58
+ ```json
59
+ {
60
+ "backend": "herdr",
61
+ "binary": "/usr/local/bin/herdr",
62
+ "socket": "/home/operator/.config/herdr/sessions/oats/herdr.sock",
63
+ "workspaceId": "w1",
64
+ "paneId": "w1:p1",
65
+ "terminalId": "term_65ab9108c6c301",
66
+ "protocol": 20
67
+ }
68
+ ```
69
+
70
+ The terminal id distinguishes a replacement occupant after a server restart.
71
+ Backend operations allocate, start, inspect, stop and attach a viewer. Retirement
72
+ compares the receipt with its baseline and proves the original session absent.
73
+ An unavailable server or failed inspection is not proof of absence. The same
74
+ rule applies to spawn compensation and detached self-retirement. Lifecycle
75
+ operations run on the target host, so the local backend does not implement SSH.
76
+
77
+ Herdr 0.8.2 exposes snapshots, socket commands, agent-state inspection and JSONL
78
+ terminal observation/control. Its protocol is versioned. Agent prompts reject
79
+ approval-blocked agents, but prompting a working agent does not prove the new
80
+ message was processed. The adapter must retain this distinction. See the
81
+ [Herdr socket API](https://herdr.dev/docs/socket-api/) and
82
+ [remote connections](https://herdr.dev/docs/persistence-remote/).
83
+
84
+ An aweb host service owns event streams for managed instances; the GUI displays
85
+ and controls it. Reuse aweb's existing authenticated event/run loop rather than copying credential
86
+ and SSE parsing into OATS or Desktop. OATS exposes backend-neutral session
87
+ inspection and literal terminal input; aweb supplies delivery policy. Current authorization is
88
+ per identity: one long-lived stream per active identity, coalesced per instance,
89
+ with bounded retries. A single team stream requires an explicit server API.
90
+ Reconnect also checks pending state so a lost edge does not strand unread work.
91
+
92
+ Delivery is a fixed instruction to check `aw` mail/chat from the instance home,
93
+ not arbitrary sender content typed into a shell. The service never acknowledges
94
+ mail or chat on the agent's behalf. Aweb pending hints survive reconnect and service
95
+ restart, coalesce while busy and defer at approval prompts. A stopped harness,
96
+ an unknown occupant or a fallback shell is not a delivery target. Do not call a
97
+ successful terminal write an agent acknowledgement.
98
+
99
+ Native channels remain selectable during qualification; session delivery must
100
+ be exclusive with them for each instance. Removal follows real tests of Pi,
101
+ Claude and Codex receiving mail/chat, a busy turn, an approval prompt, reconnect,
102
+ service restart, GUI closure and a stopped runtime. The OATS Pi tool extension
103
+ and the aweb Pi channel are separate packages; replacing notification transport
104
+ does not silently remove unrelated tools.
105
+
106
+ Acceptance includes local CLI/Desktop spawn, reattach, preserved work and
107
+ retirement through both backends; then the same operations on a user-designated
108
+ SSH target. Registering a host without a successful remote agent run does not
109
+ qualify remote support.
110
+
111
+ ## Session CLI contract
112
+
113
+ Run on the execution host:
114
+
115
+ ```sh
116
+ oats session attach --home /absolute/instance
117
+ oats session inspect --home /absolute/instance --json
118
+ oats session input --home /absolute/instance --text-file /path/to/message --json
119
+ printf '%s' 'Check your pending work.' | oats session input --home /absolute/instance --json
120
+ ```
121
+
122
+ `attach` is interactive and does not accept `--json`. It validates the saved
123
+ endpoint on the execution host, then opens a Herdr terminal viewer or an
124
+ isolated tmux session linked to that agent's window alone. Closing its terminal
125
+ cleans the viewer without stopping the agent; retiring the agent ends the viewer
126
+ instead of switching it to a sibling. This host-local command is also the
127
+ remote Desktop attachment seam over an SSH PTY.
128
+
129
+ Input accepts UTF-8 text up to 256 KiB, with no NUL bytes. The CLI uses the
130
+ independent lifecycle receipt and refuses metadata disagreement. Tmux uses
131
+ literal bracketed paste followed by Enter. Herdr uses pane input followed by
132
+ Enter. Neither path interprets message text as a shell command. A fallback
133
+ shell or ambiguous split tmux window refuses automatic input.
134
+
135
+ Success uses the existing envelope:
136
+
137
+ ```json
138
+ {"schemaVersion":1,"ok":true,"result":{"home":"/absolute/instance","backend":"herdr","present":true,"state":"idle","submitted":true}}
139
+ ```
140
+
141
+ Inspect omits `submitted`; optional backend identifiers describe the observed
142
+ terminal. `state` is the Herdr agent state when available, `unknown` for a live
143
+ unclassified harness, `shell` for a fallback shell, `stopped` for an absent/dead
144
+ terminal, or `not-launched`. Errors use `ok:false,error:{code,message}` and a
145
+ nonzero exit. An unavailable backend is an error, never a stopped result.
146
+ Tmux receipts identify socket/session/window; automatic input requires one live
147
+ pane in that exact window. Herdr additionally verifies the original terminal ID.
148
+ The broker owns busy/approval policy and must not interpret `submitted` as
149
+ processing acknowledgement.
150
+
151
+ Capability spawn hooks register a pending home before runtime allocation;
152
+ inspection becomes available once its receipt is persisted. Retire hooks
153
+ unregister after quiescence. The broker must tolerate this lifecycle order and
154
+ missing homes, and persist pending hints until handled. Kernel session operations
155
+ contain no aweb identity, credentials, stream or notification logic.
156
+
157
+ The portable integration belongs to the official `oats.aweb` capability.
158
+ The aweb development deployment currently selects its owned `aweb.identity`
159
+ capability; that deployment-specific choice does not change the broker interface
160
+ and needs equivalent registration glue when switched to session delivery.
161
+
162
+ ## Shared permission setting
163
+
164
+ Set `yolo: true` in an `oats-config.yaml` to apply it to that scope. The closest
165
+ scope wins; an optional `yolo` in soul.yaml overrides it; `oats spawn --yolo` or
166
+ `--no-yolo` overrides both. `oats create` accepts those flags too. Desktop offers
167
+ the same per-launch choice. With no setting, native policy is retained.
168
+
169
+ Codex receives `--yolo` plus a launch-local trust setting for the generated
170
+ instance home; Claude receives `--dangerously-skip-permissions`. Pi's existing
171
+ project trust behavior is unchanged. `--no-yolo` removes the OATS bypass flags;
172
+ it leaves the operator's native harness settings in force. Instance metadata
173
+ records an explicitly resolved setting. This choice applies when starting an
174
+ agent, not retroactively to running sessions.
175
+
176
+ Desktop remote terminal requests contain only the server id and instance name.
177
+ The selected installed CLI resolves the saved route and performs remote
178
+ inspection before attaching over SSH. Pending inspections share the terminal
179
+ resource limit and duplicate requests share one inspection. Remote status and
180
+ instance keys must include the server so identical paths on different hosts
181
+ remain distinct.
@@ -158,7 +158,20 @@ instance homed inside a repository with its own `.claude/skills` sees those
158
158
  too. Project *settings* — hooks, plugins, permissions, custom agents — resolve
159
159
  from the instance home rather than from ancestors.
160
160
 
161
- Both runtimes record what they actually expose in `instance.json` under
161
+ Codex is available with `--runtime codex`. It starts in the instance home,
162
+ reads `AGENTS.md` and `.agents/skills` natively, and receives `TASK.md` as its
163
+ initial prompt. User configuration, approval policy, ancestor instructions and
164
+ ambient skill sources remain native. Worktrees are already below the instance
165
+ home; checkout/attached paths outside it use Codex's normal approval handling
166
+ and may require approval for writes, depending on the operator's policy.
167
+ OATS does not pass `--add-dir`, which Codex refuses under some native policies.
168
+ OpenAI-prefixed model preferences are translated to
169
+ Codex ids; other provider preferences fall back to its configured default.
170
+ The Desktop model field accepts a native id without using Pi's model catalog.
171
+ This launch support does not supply an aweb channel for Codex: agents can use
172
+ `aw` from their home, with automatic wake delivery tracked separately.
173
+
174
+ All runtimes record what they actually expose in `instance.json` under
162
175
  `composition.materialized.runtimePosture`: the OATS-composed set, what is
163
176
  curtailed, and what remains ambient. The deviation from strict composition is
164
177
  auditable rather than implied.
@@ -29,7 +29,7 @@ version has a supported replacement. When the plan is correct:
29
29
 
30
30
  ```bash
31
31
  oats migrate --from-oas --dir /path/to/scope
32
- oats doctor --dir /path/to/scope
32
+ oats doctor /path/to/scope
33
33
  ```
34
34
 
35
35
  Run the exact `oats trust <capability> --dir <scope>` commands printed by
@@ -72,6 +72,7 @@
72
72
  },
73
73
  "type": "object",
74
74
  "properties": {
75
+ "yolo": { "type": "boolean", "description": "Skip Codex/Claude permission prompts. Closest scope wins; soul and launch overrides take precedence." },
75
76
  "name": { "type": "string" },
76
77
  "team": {
77
78
  "type": "object",