@awebai/oats 0.22.0 → 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.
Files changed (60) hide show
  1. package/README.md +40 -50
  2. package/bin/oats.mjs +242 -22
  3. package/capabilities/oats-authoring/LICENSE +21 -0
  4. package/capabilities/oats-authoring/oats-package.json +11 -0
  5. package/capabilities/oats-authoring/oats.json +4 -4
  6. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
  7. package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
  8. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
  9. package/capabilities/oats-aweb/injects/aweb.md +4 -3
  10. package/capabilities/oats-aweb/oats.json +7 -7
  11. package/capabilities/oats-aweb/skills/LICENSE +21 -0
  12. package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
  13. package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
  14. package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
  15. package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
  16. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
  17. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
  18. package/capabilities/oats-jira/oats.json +1 -1
  19. package/capabilities/oats-linear/oats.json +1 -1
  20. package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
  21. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
  22. package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
  23. package/capabilities/oats-okf/injects/okf.md +7 -0
  24. package/capabilities/oats-okf/oats.json +5 -2
  25. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
  26. package/capabilities/oats-review/oats.json +1 -1
  27. package/docs/2026-09-03-architecture-proposal.md +642 -0
  28. package/docs/execution-targets.md +181 -0
  29. package/docs/first-team-demo.md +87 -0
  30. package/docs/first-team.md +179 -0
  31. package/docs/implementation.md +14 -1
  32. package/docs/integrations.md +83 -65
  33. package/docs/layers.md +356 -80
  34. package/docs/migration-from-oas.md +80 -116
  35. package/docs/oats-config.schema.json +1 -0
  36. package/docs/operating-team-migration.md +217 -0
  37. package/docs/release-notes/v0.22.1.md +106 -0
  38. package/docs/release-notes/v0.22.2.md +69 -0
  39. package/docs/servers.md +94 -0
  40. package/docs/souls-and-instances.md +30 -3
  41. package/lib/core.mjs +626 -415
  42. package/lib/herdr.mjs +95 -0
  43. package/lib/servers.mjs +436 -0
  44. package/lib/session-input.mjs +78 -0
  45. package/lib/session-viewer.mjs +51 -0
  46. package/package-catalog.json +2 -2
  47. package/package.json +1 -1
  48. package/packages/record/README.md +76 -16
  49. package/packages/record/bin/capture.mjs +59 -3
  50. package/packages/record/bin/recall.mjs +67 -1
  51. package/packages/record/docs/turn-record-sot.md +1 -1
  52. package/packages/record/lib/sessions-for-home.mjs +130 -0
  53. package/packages/record/lib/store.mjs +207 -43
  54. package/skills/oats/SKILL.md +6 -2
  55. package/capabilities/oats-aweb/package.json +0 -20
  56. package/capabilities/oats-jira/package.json +0 -25
  57. package/capabilities/oats-linear/README.md +0 -234
  58. package/capabilities/oats-linear/package.json +0 -29
  59. package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
  60. package/capabilities/oats-okf/package.json +0 -22
@@ -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
@@ -0,0 +1,6 @@
1
+ name: memory-harvest
2
+ kind: capability
3
+ work: attached
4
+ runtime: pi
5
+ model: github-copilot/gpt-5.5
6
+ description: Ephemeral OKF service agent that promotes one live instance's pending notes into its soul.
@@ -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.
@@ -20,24 +23,10 @@
20
23
  * OATS_TASK (spawn), OATS_REPO/OATS_BRANCH/OATS_WORK (spawn), OATS_META (retire).
21
24
  * Output: JSON { meta, brief, warning } on stdout. Failures warn, never block.
22
25
  */
23
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync, realpathSync } from "node:fs";
26
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
24
27
  import { join, isAbsolute, dirname } from "node:path";
25
- import { fileURLToPath, pathToFileURL } from "node:url";
26
- import { execSync } from "node:child_process";
27
-
28
- /** The kernel install root. When this package runs from inside the kernel
29
- * (marketplace source tree), ../../.. works; when it runs as a copied
30
- * marketplace install (.agents/capabilities/installed/oats-okf), resolve the
31
- * kernel through `oats root` — the same mechanism adapters use. */
32
- const FRAMEWORK_ROOT = (() => {
33
- const rel = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
34
- if (existsSync(join(rel, "lib", "core.mjs"))) return rel;
35
- try {
36
- const root = execSync("oats root", { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }).trim();
37
- if (root && existsSync(join(root, "lib", "core.mjs"))) return root;
38
- } catch { /* fall through */ }
39
- return rel; // callers report the missing module with a clear path
40
- })();
28
+ import { tmpdir } from "node:os";
29
+ import { execFile, spawnSync } from "node:child_process";
41
30
 
42
31
  const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); process.exit(0); };
43
32
  const warn = (m) => out({ warning: `oats-okf: ${String(m).slice(0, 300)}` });
@@ -67,6 +56,156 @@ catch (e) {
67
56
  * work; default gpt-5.5, overridable via okf settings { "harvest-model": ... }. */
68
57
  const DEFAULT_HARVEST_MODEL = "github-copilot/gpt-5.5";
69
58
 
59
+ function runtimeError(code, message) {
60
+ return Object.assign(new Error(message), { code });
61
+ }
62
+
63
+ /** Canonical package-runtime binary supplied by capability command dispatch.
64
+ * Never discover or resolve the kernel through PATH. */
65
+ function packageRuntimeCli() {
66
+ const cli = process.env.OATS_CLI_BIN;
67
+ if (!cli) throw runtimeError("E_SPAWN_FAILED", "OATS_CLI_BIN is required by the package-runtime contract");
68
+ if (!isAbsolute(cli)) throw runtimeError("E_SPAWN_FAILED", "OATS_CLI_BIN must be an absolute path");
69
+ return cli;
70
+ }
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
+
163
+ /** Invoke the versioned package-runtime boundary. Task text crosses the
164
+ * process boundary only through an owner-readable tempfile, removed on every
165
+ * success/failure outcome. */
166
+ async function spawnHarvester(spawnArgs, task) {
167
+ const temp = mkdtempSync(join(tmpdir(), "oats-okf-harvest-"));
168
+ const taskFile = join(temp, "TASK.md");
169
+ try {
170
+ writeFileSync(taskFile, task, { mode: 0o600, flag: "wx" });
171
+ const args = ["spawn", "memory-harvest", ...spawnArgs, "--task-file", taskFile, "--json"];
172
+ const child = await new Promise((resolveChild) => {
173
+ execFile(packageRuntimeCli(), args, {
174
+ encoding: "utf8",
175
+ env: process.env,
176
+ timeout: 300000,
177
+ maxBuffer: 1024 * 1024,
178
+ }, (error, stdout, stderr) => resolveChild({ error, stdout, stderr }));
179
+ });
180
+ if (child.stderr) process.stderr.write(child.stderr);
181
+ if (child.error && !String(child.stdout || "").trim()) {
182
+ throw runtimeError("E_SPAWN_FAILED", child.error.message || child.error);
183
+ }
184
+ let envelope;
185
+ try { envelope = JSON.parse(String(child.stdout || "").trim()); }
186
+ catch { throw runtimeError("E_SPAWN_FAILED", "oats spawn returned an invalid JSON envelope"); }
187
+ if (envelope?.schemaVersion !== 1 || typeof envelope.ok !== "boolean") {
188
+ throw runtimeError("E_SPAWN_FAILED", "oats spawn returned an unsupported JSON envelope");
189
+ }
190
+ if (!envelope.ok) {
191
+ throw runtimeError(envelope.error?.code || "E_SPAWN_FAILED", envelope.error?.message || "oats spawn failed");
192
+ }
193
+ if (child.error) throw runtimeError("E_SPAWN_FAILED", child.error.message || "oats spawn failed");
194
+ if (!envelope.result?.instance) throw runtimeError("E_SPAWN_FAILED", "oats spawn success envelope has no instance");
195
+ return envelope.result;
196
+ } finally {
197
+ rmSync(temp, { recursive: true, force: true });
198
+ }
199
+ }
200
+
201
+ function harvestSpawnArgs({ slug, parent, repo, work, workDir, branch, model }) {
202
+ const args = ["--purpose", slug, "--parent", parent, "--repo", repo, "--work", work];
203
+ if (workDir) args.push("--work-dir", workDir);
204
+ if (branch) args.push("--branch", branch);
205
+ args.push("--model", model);
206
+ return args;
207
+ }
208
+
70
209
  /** Append a one-line entry to an OKF log.md (newest-first, date-grouped per spec §7). */
71
210
  function appendLogEntry(logPath, entry, title) {
72
211
  const today = new Date().toISOString().slice(0, 10);
@@ -195,35 +334,45 @@ _(the single next action — keep this current; a fresh session on any model res
195
334
  const skip = (why) => (JSON_MODE ? jsonOk({ harvest: "skipped", reason: why }) : out({ meta: { harvestSpawn: "skipped", why } }));
196
335
  if (String(agName).startsWith("memory-harvest")) skip("self (loop guard)");
197
336
  const notes = existsSync(notesDir) ? readdirSync(notesDir).filter((f) => f.endsWith(".md")) : [];
198
- if (notes.length === 0) skip("no pending notes");
199
- if (!root || (!existsSync(root) && !existsSync(join(dirname(root), "local-agents")))) skip("no agents root found above this home");
200
- if (!inst) skip("no instance identity (run from an instance home)");
201
- const core = await import(pathToFileURL(join(FRAMEWORK_ROOT, "lib", "core.mjs")).href);
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)");
202
346
  const slug = String(inst).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
203
347
  // Debounce: one harvester per source instance at a time (canonical sibling
204
- // local-agents/ plus legacy nested locations).
348
+ // local-agents/ plus legacy nested locations). The public spawn boundary
349
+ // derives the deterministic instance name from --purpose <slug>.
205
350
  const harvesterHomes = [
206
351
  join(dirname(root), "local-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
207
352
  join(root, "local-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
208
353
  join(root, "tmp-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
209
354
  ];
210
355
  if (harvesterHomes.some((h) => existsSync(h))) skip("harvester already running for this instance");
211
- let agentDef = core.findAgent(root, "memory-harvest");
212
- if (!agentDef) {
213
- const upsert = core.upsertLocalAgent || core.upsertTmpAgent; // kernel ≥0.18 / older
214
- upsert(root, { name: "memory-harvest", instructions: readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "memory-harvest.md"), "utf8") });
215
- agentDef = core.findAgent(root, "memory-harvest");
216
- }
217
- // The harvester is service infrastructure: ALWAYS ephemeral, regardless of
218
- // its on-disk kind (it homes as a local soul so it is uncommitted).
219
- agentDef = { ...agentDef, kind: "capability" };
220
- // Harvest model: explicit okf settings win (hook env, or resolved from config
221
- // when agent-initiated); else the integration's default.
222
- let harvestModel = settings["harvest-model"];
223
- if (!harvestModel && context) {
224
- try { harvestModel = core.resolveOatsConfig(context).layers?.knowledge?.settings?.["harvest-model"]; } catch { /* config unreadable: use default */ }
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");
225
372
  }
226
- harvestModel = harvestModel || DEFAULT_HARVEST_MODEL;
373
+ // Effective command settings are injected by capability dispatch. No
374
+ // resolved-config read crosses the public package boundary.
375
+ const harvestModel = settings["harvest-model"] || DEFAULT_HARVEST_MODEL;
227
376
  const workDir = realpathSync(join(home, "work"));
228
377
  const realSoul = realpathSync(sDir);
229
378
  const harvName = `memory-harvest-${slug}`;
@@ -235,11 +384,10 @@ _(the single next action — keep this current; a fresh session on any model res
235
384
  // harvester judges notes exactly as usual, but the deliverable is DIRECT
236
385
  // edits to the canonical soul — no commit, no PR: there is nothing to
237
386
  // version. It must not touch the owner's work tree.
238
- r = core.spawnInstance(root, agentDef, {
239
- instance: harvName, parent: inst,
240
- repo: context, work: "attached", workDir, model: harvestModel,
241
- 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\`.`,
242
- });
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\`.`;
388
+ r = await spawnHarvester(harvestSpawnArgs({
389
+ slug, parent: inst, repo: context, work: "attached", workDir, model: harvestModel,
390
+ }), task);
243
391
  } else if ((process.env.OATS_WORK || meta.work) === "workspace") {
244
392
  // WORKSPACE-MODE instance: ./work is the whole workspace, not a git repo —
245
393
  // the harvester may NOT commit there. The soul lives in its own home repo
@@ -248,11 +396,11 @@ _(the single next action — keep this current; a fresh session on any model res
248
396
  const soulRepo = gitRootOf(realSoul);
249
397
  if (!soulRepo) skip("workspace-mode soul is not inside a git repo — nowhere to deliver a PR");
250
398
  const relSoul = realSoul.slice(soulRepo.length + 1);
251
- r = core.spawnInstance(root, agentDef, {
252
- instance: harvName, parent: inst,
253
- repo: soulRepo, work: "worktree", branch: `memory-harvest/${slug}`, model: harvestModel,
254
- 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).`,
255
- });
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).`;
400
+ r = await spawnHarvester(harvestSpawnArgs({
401
+ slug, parent: inst, repo: soulRepo, work: "worktree",
402
+ branch: `memory-harvest/${slug}`, model: harvestModel,
403
+ }), task);
256
404
  } else {
257
405
  // Repo-resident souls: write to the soul AS SEEN FROM THE WORK TREE, so the
258
406
  // promotion commits onto the instance's own branch. Otherwise the canonical soul.
@@ -260,16 +408,15 @@ _(the single next action — keep this current; a fresh session on any model res
260
408
  const soulTarget = realSoul.startsWith(realRepo + "/")
261
409
  ? join(workDir, realSoul.slice(realRepo.length + 1))
262
410
  : realSoul;
263
- r = core.spawnInstance(root, agentDef, {
264
- instance: harvName, parent: inst,
265
- repo: context, work: "attached", workDir, model: harvestModel,
266
- 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\`.`,
267
- });
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\`.`;
412
+ r = await spawnHarvester(harvestSpawnArgs({
413
+ slug, parent: inst, repo: context, work: "attached", workDir, model: harvestModel,
414
+ }), task);
268
415
  }
269
- 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 } : {}) } } : {}) });
270
417
  out({ meta: { harvestSpawn: r.instance, window: r.tmux?.window } });
271
418
  } catch (e) {
272
- if (JSON_MODE) jsonFail("E_HARVEST_FAILED", `harvest spawn failed (notes are safe on disk): ${e.message || e}`);
419
+ if (JSON_MODE) jsonFail(e.code || "E_HARVEST_FAILED", `harvest spawn failed (notes are safe on disk): ${e.message || e}`);
273
420
  warn(`harvest spawn failed (notes are safe on disk): ${e.message || e}`);
274
421
  }
275
422
  } else if (event === "retire") {
@@ -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,11 +1,14 @@
1
1
  {
2
2
  "capability": "oats.okf",
3
3
  "command": "okf",
4
- "version": "1.4.1",
5
- "compatibility": { "oats": ">=0.6.2" },
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": [],
9
+ "agents": [
10
+ "agents/memory-harvest"
11
+ ],
9
12
  "skills": [
10
13
  "skills"
11
14
  ],
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.review",
3
3
  "version": "1.2.0",
4
- "compatibility": { "oats": ">=0.16.0" },
4
+ "compatibility": { "oats": ">=0.19.0" },
5
5
  "description": "Post-commit review discipline: a fresh reviewer agent (defined by this capability) reviews each new commit's diff, reports its verdict to its spawner over the deployment's messaging layer (or in its transcript when there is none), and retires — plus the shared developer delivery discipline.",
6
6
  "requires": [],
7
7
  "agents": ["agents/reviewer"],