@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.
- package/README.md +40 -50
- package/bin/oats.mjs +242 -22
- package/capabilities/oats-authoring/LICENSE +21 -0
- package/capabilities/oats-authoring/oats-package.json +11 -0
- package/capabilities/oats-authoring/oats.json +4 -4
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
- package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
- package/capabilities/oats-aweb/injects/aweb.md +4 -3
- package/capabilities/oats-aweb/oats.json +7 -7
- package/capabilities/oats-aweb/skills/LICENSE +21 -0
- package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
- package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
- package/capabilities/oats-jira/oats.json +1 -1
- package/capabilities/oats-linear/oats.json +1 -1
- package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +5 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/capabilities/oats-review/oats.json +1 -1
- package/docs/2026-09-03-architecture-proposal.md +642 -0
- package/docs/execution-targets.md +181 -0
- package/docs/first-team-demo.md +87 -0
- package/docs/first-team.md +179 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +83 -65
- package/docs/layers.md +356 -80
- package/docs/migration-from-oas.md +80 -116
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +217 -0
- package/docs/release-notes/v0.22.1.md +106 -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 +626 -415
- 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/README.md +76 -16
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/docs/turn-record-sot.md +1 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/packages/record/lib/store.mjs +207 -43
- package/skills/oats/SKILL.md +6 -2
- package/capabilities/oats-aweb/package.json +0 -20
- package/capabilities/oats-jira/package.json +0 -25
- package/capabilities/oats-linear/README.md +0 -234
- package/capabilities/oats-linear/package.json +0 -29
- package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
- 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
|
|
@@ -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,
|
|
26
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
|
24
27
|
import { join, isAbsolute, dirname } from "node:path";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
repo: context, work: "attached", workDir, model: harvestModel,
|
|
241
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
repo: soulRepo, work: "worktree",
|
|
254
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
repo: context, work: "attached", workDir, model: harvestModel,
|
|
266
|
-
|
|
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.
|
|
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": [],
|
|
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.
|
|
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"],
|