@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.
- package/README.md +10 -3
- package/bin/oats.mjs +231 -16
- package/capabilities/oats-aweb/injects/aweb.md +4 -3
- package/capabilities/oats-aweb/oats.json +1 -1
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +3 -1
- package/capabilities/oats-okf/bin/oats-okf.mjs +125 -9
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +2 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/docs/execution-targets.md +181 -0
- package/docs/implementation.md +14 -1
- package/docs/migration-from-oas.md +1 -1
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +217 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/servers.md +94 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +379 -60
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +436 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/skills/oats/SKILL.md +6 -2
|
@@ -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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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:")
|
|
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.
|
|
5
|
-
"compatibility": { "oats": ">=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.
|
package/docs/implementation.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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",
|