@awebai/oats 0.23.0 → 0.23.1
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 +48 -18
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +18 -24
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +2 -2
- package/capabilities/oats-okf/bin/oats-okf.mjs +105 -517
- package/capabilities/oats-okf/injects/okf.md +32 -67
- package/capabilities/oats-okf/lib/config.mjs +112 -0
- package/capabilities/oats-okf/lib/inspection.mjs +96 -0
- package/capabilities/oats-okf/lib/io.mjs +103 -0
- package/capabilities/oats-okf/lib/migration.mjs +116 -0
- package/capabilities/oats-okf/lib/sources.mjs +238 -0
- package/capabilities/oats-okf/lib/stores.mjs +331 -0
- package/capabilities/oats-okf/lib/worker.mjs +352 -0
- package/capabilities/oats-okf/oats.json +23 -7
- package/capabilities/oats-okf/schemas/okf-base.schema.json +46 -0
- package/capabilities/oats-okf/schemas/okf-bindings.schema.json +112 -0
- package/capabilities/oats-okf/schemas/okf-soul.schema.json +37 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +263 -140
- package/capabilities/oats-okf/skills/okf/SKILL.md +13 -4
- package/docs/capabilities.md +14 -3
- package/docs/configuration.md +11 -1
- package/docs/design/okf-mirror-provenance.md +105 -0
- package/docs/desktop-cli-api.md +59 -10
- package/docs/first-team-demo.md +6 -1
- package/docs/first-team.md +151 -115
- package/docs/integrations.md +42 -42
- package/docs/knowledge-capability-authoring.md +10 -7
- package/docs/knowledge-migration.md +138 -0
- package/docs/knowledge.md +316 -129
- package/docs/layers.md +57 -62
- package/docs/migration-from-oas.md +7 -1
- package/docs/packages.md +26 -2
- package/docs/release-notes/v0.23.1.md +97 -0
- package/docs/schedules.md +42 -3
- package/docs/souls-and-instances.md +55 -48
- package/package-catalog.json +6 -1
- package/package.json +1 -1
- package/capabilities/oats-okf/lib/harvest-branch.mjs +0 -43
|
@@ -1,523 +1,111 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// pipe when the kernel's operation runner, a Desktop or a test reads it, and
|
|
34
|
-
// on macOS Node writes to a pipe asynchronously: process.exit right after
|
|
35
|
-
// process.stdout.write drops whatever has not left Node's buffer (64 KiB,
|
|
36
|
-
// so a view of one long STATE.md arrived cut). fs.writeSync on fd 1 blocks
|
|
37
|
-
// until the OS has the bytes; EAGAIN (a non-blocking fd whose reader is
|
|
38
|
-
// slower than us) is retried after a short wait. The exit stays synchronous,
|
|
39
|
-
// which the call sites below rely on to end their flow.
|
|
40
|
-
const emit = (text, code) => {
|
|
41
|
-
const buf = Buffer.from(text, "utf8");
|
|
42
|
-
for (let off = 0; off < buf.length;) {
|
|
43
|
-
try { off += writeSync(1, buf, off, buf.length - off); }
|
|
44
|
-
catch (e) { if (e.code !== "EAGAIN") throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); }
|
|
45
|
-
}
|
|
46
|
-
process.exit(code);
|
|
47
|
-
};
|
|
48
|
-
const out = (o) => emit(JSON.stringify(o) + "\n", 0);
|
|
49
|
-
const warn = (m) => out({ warning: `oats-okf: ${String(m).slice(0, 300)}` });
|
|
50
|
-
// A reported failure must not exit 0: callers and hooks read the status.
|
|
51
|
-
const warnFail = (m) => emit(JSON.stringify({ warning: `oats-okf: ${String(m).slice(0, 300)}` }) + "\n", 1);
|
|
52
|
-
|
|
53
|
-
// Desktop CLI API v1: `oats okf harvest --json` emits EXACTLY ONE envelope
|
|
54
|
-
// object on stdout — {schemaVersion:1,ok,result|error} — and a nonzero exit
|
|
55
|
-
// on failure. Ordinary (non---json) output keeps the hook JSON shape above.
|
|
56
|
-
const JSON_MODE = process.argv.includes("--json");
|
|
57
|
-
const jsonOk = (result) => emit(JSON.stringify({ schemaVersion: 1, ok: true, result }) + "\n", 0);
|
|
58
|
-
const jsonFail = (code, message) => emit(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message).slice(0, 300) } }) + "\n", 1);
|
|
59
|
-
|
|
60
|
-
// --help/-h never runs a command here either (the kernel answers it from the
|
|
61
|
-
// manifest since 0.22.6; this keeps an older kernel from spawning a harvester).
|
|
62
|
-
if (process.argv.slice(2).some((a) => a === "--help" || a === "-h")) {
|
|
63
|
-
emit("oats okf harvest [--json] [--from-record] [--force] promote this instance's pending notes (and record windows) into its soul by spawning a memory-harvest worker; --help never runs it\noats okf inspect [--json] answer this instance's working knowledge (STATE.md, log.md, pending notes) as labeled documents\n", 0);
|
|
64
|
-
}
|
|
65
|
-
const event = process.env.OATS_EVENT || process.argv[2];
|
|
66
|
-
const instance = process.env.OATS_INSTANCE;
|
|
67
|
-
const home = process.env.OATS_HOME || process.cwd();
|
|
68
|
-
const soulDir = process.env.OATS_SOUL;
|
|
69
|
-
const agentName = process.env.OATS_AGENT || "agent";
|
|
70
|
-
// Fallible init stays inside an error boundary: malformed inherited env must
|
|
71
|
-
// never produce a bare stack trace — in --json mode Desktop expects one
|
|
72
|
-
// envelope object on stdout even for init failures.
|
|
73
|
-
let settings = {};
|
|
74
|
-
try { settings = JSON.parse(process.env.OATS_SETTINGS || "{}"); }
|
|
75
|
-
catch (e) {
|
|
76
|
-
if (JSON_MODE) jsonFail("E_HARVEST_FAILED", `malformed OATS_SETTINGS: ${e.message || e}`);
|
|
77
|
-
process.stderr.write(`oats-okf: malformed OATS_SETTINGS (ignoring): ${e.message || e}\n`);
|
|
78
|
-
}
|
|
79
|
-
function runtimeError(code, message) {
|
|
80
|
-
return Object.assign(new Error(message), { code });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Canonical package-runtime binary supplied by capability command dispatch.
|
|
84
|
-
* Never discover or resolve the kernel through PATH. */
|
|
85
|
-
function packageRuntimeCli() {
|
|
86
|
-
const cli = process.env.OATS_CLI_BIN;
|
|
87
|
-
if (!cli) throw runtimeError("E_SPAWN_FAILED", "OATS_CLI_BIN is required by the package-runtime contract");
|
|
88
|
-
if (!isAbsolute(cli)) throw runtimeError("E_SPAWN_FAILED", "OATS_CLI_BIN must be an absolute path");
|
|
89
|
-
return cli;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Record-fed harvest (aweb-abfz). An instance that writes no notes still
|
|
93
|
-
* leaves a record: every Claude Code, pi and Codex session on the machine is
|
|
94
|
-
* captured as session turns, and the sessions that ran inside this home are
|
|
95
|
-
* the instance's own. Ask the kernel to capture them and report exact
|
|
96
|
-
* sequence boundaries, compare with the watermark of what was already
|
|
97
|
-
* harvested, and return the windows that are new — or null when nothing is.
|
|
98
|
-
* The watermark advances only when the harvester delivers (it writes the
|
|
99
|
-
* file its briefing hands it), so a failed harvest re-reads the same window. */
|
|
100
|
-
const RECORD_WATERMARK = ".okf-harvest-record.json";
|
|
101
|
-
/** A window is what one harvester can actually read: a first harvest of a
|
|
102
|
-
* long-lived session must not hand it the whole thread (tens of MB on real
|
|
103
|
-
* homes) and then let it advance the watermark past what it never read. The
|
|
104
|
-
* plan sizes each window with an ids-only listing and stops at the turn or
|
|
105
|
-
* byte cap; the rest drains over later harvests, each with a truthful
|
|
106
|
-
* watermark. Overridable through okf settings { "record-window-turns",
|
|
107
|
-
* "record-window-bytes" }. */
|
|
108
|
-
// Sized to ONE tool-output read: harnesses truncate a command's output well
|
|
109
|
-
// under 100 KB (Claude Code around 30 KB), and the byte cap is measured on
|
|
110
|
-
// the JSON the harvester receives, not on the text inside it. A backlog
|
|
111
|
-
// drains over successive harvests; the caps are settings for operators
|
|
112
|
-
// whose harness reads more.
|
|
113
|
-
const DEFAULT_WINDOW_TURNS = 60;
|
|
114
|
-
const DEFAULT_WINDOW_BYTES = 96_000;
|
|
115
|
-
function sizeWindow(cli, thread, afterTurnId, caps) {
|
|
116
|
-
const list = (after) => {
|
|
117
|
-
const args = ["recall", "--thread", thread, "--json", "--ids-only", "--limit", String(caps.turns)];
|
|
118
|
-
if (after) args.push("--after", after);
|
|
119
|
-
const r = spawnSync(cli, args, { encoding: "utf8", env: process.env, timeout: 120000, maxBuffer: 64 * 1024 * 1024 });
|
|
120
|
-
if (r.status !== 0) return { error: String(r.stderr || r.error?.message || `recall exited ${r.status}`).trim().slice(0, 200) };
|
|
121
|
-
try { return { doc: JSON.parse(String(r.stdout || "").trim()) }; } catch (e) { return { error: `recall answered no JSON: ${String(e.message).slice(0, 100)}` }; }
|
|
122
|
-
};
|
|
123
|
-
let { doc, error } = list(afterTurnId);
|
|
124
|
-
let restarted = false;
|
|
125
|
-
// The watermark's boundary turn can leave the thread (a redaction hides it
|
|
126
|
-
// for good). That must not strand the thread: read from the start again,
|
|
127
|
-
// bounded as always, and say so. The harvester's own fallback covers the
|
|
128
|
-
// same case between plan and read.
|
|
129
|
-
if (error && afterTurnId && /--after: no turn/.test(error)) { ({ doc, error } = list(null)); restarted = true; }
|
|
130
|
-
if (error) return { error };
|
|
131
|
-
let bytes = 0; let n = 0;
|
|
132
|
-
for (const t of doc.turns || []) {
|
|
133
|
-
if (n > 0 && bytes + t.bytes > caps.bytes) break; // always at least one turn, so a single huge turn still drains
|
|
134
|
-
bytes += t.bytes; n++;
|
|
135
|
-
}
|
|
136
|
-
if (!n) return { empty: true };
|
|
137
|
-
return { untilTurnId: doc.turns[n - 1].id, newTurns: n, bytes, remaining: (doc.turns.length - n) + (doc.remaining || 0), restarted };
|
|
138
|
-
}
|
|
139
|
-
function planRecordHarvest(instanceHome) {
|
|
140
|
-
const watermarkPath = join(instanceHome, RECORD_WATERMARK);
|
|
141
|
-
let prior = {};
|
|
142
|
-
try { prior = JSON.parse(readFileSync(watermarkPath, "utf8")).threads || {}; } catch { prior = {}; }
|
|
143
|
-
let report;
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fail, unlock } from '../lib/io.mjs';
|
|
4
|
+
import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
|
|
5
|
+
import { register, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, service, markerPath, views } from '../lib/sources.mjs';
|
|
6
|
+
import { runSource, complete, retry, readRun } from '../lib/worker.mjs';
|
|
7
|
+
import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource } from '../lib/migration.mjs';
|
|
8
|
+
import { inspect } from '../lib/inspection.mjs';
|
|
9
|
+
const HELP=`oats okf inspect [--home PATH | --source FILE] [--json]
|
|
10
|
+
oats okf harvest [--home PATH] [--no-launch] [--json]
|
|
11
|
+
oats okf run-source --source FILE [--manual] [--no-launch] [--json]
|
|
12
|
+
oats okf complete --source FILE --run ID --judgment FILE [--json]
|
|
13
|
+
oats okf retry --source FILE [--run ID --rejudge | --rejudge | --launch | --adopt-home PATH] [--json]
|
|
14
|
+
oats okf read [--home PATH | --source FILE] --base ALIAS [--path node/index.md] [--json]
|
|
15
|
+
oats okf refresh [--home PATH | --source FILE] [--json]
|
|
16
|
+
oats okf setup --source FILE [--enable | --disable] [--install-host] [--json]
|
|
17
|
+
oats okf init --base ALIAS --nodes FILE [--output PATH | --confirm] [--json]
|
|
18
|
+
oats okf migrate --legacy PATH --base ALIAS --node NODE --output PATH [--json]
|
|
19
|
+
oats okf migrate --deliver FILE | --cutover FILE --soul-dir PATH [--json]
|
|
20
|
+
oats okf migrate --source-home PATH [--json]
|
|
21
|
+
oats okf unlock --lock PATH --token TOKEN [--json]
|
|
22
|
+
Explicit retry --run ID requires --rejudge; add --launch only for operator-approved launch.
|
|
23
|
+
Closed-PR recovery uses retained evidence and a fresh run; complete its returned ID.
|
|
24
|
+
Settled destinations and old proposals/receipts are preserved; another active run blocks recovery.
|
|
25
|
+
All settings use one absolute bindings-file. Setup host installation is explicit.
|
|
26
|
+
`;
|
|
27
|
+
const args=process.argv.slice(2);
|
|
28
|
+
if(args.includes('--help') || args.includes('-h')) {process.stdout.write(HELP);}
|
|
29
|
+
else {
|
|
30
|
+
const event=process.env.OATS_EVENT || args[0];
|
|
31
|
+
const hook=['spawn','retire','soul-scaffold'].includes(event);
|
|
32
|
+
let exit=0,answer;
|
|
144
33
|
try {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const threads = [];
|
|
152
|
-
const problems = [];
|
|
153
|
-
for (const s of report.sessions || []) {
|
|
154
|
-
const seen = prior[s.thread];
|
|
155
|
-
// Nothing new when the last visible turn is the one already harvested;
|
|
156
|
-
// ids, not counts, so a redaction inside the harvested prefix neither
|
|
157
|
-
// hides genuinely new turns nor re-reads old ones.
|
|
158
|
-
if (seen && seen.untilTurnId === s.lastTurnId) continue;
|
|
159
|
-
const win = sizeWindow(packageRuntimeCli(), s.thread, seen?.untilTurnId || null, caps);
|
|
160
|
-
if (win.error) { problems.push(`${s.thread}: ${win.error}`); continue; }
|
|
161
|
-
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; }
|
|
162
|
-
if (win.restarted) problems.push(`${s.thread}: the harvested boundary ${seen.untilTurnId} is no longer in the thread (redacted?); reading from the start again`);
|
|
163
|
-
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 });
|
|
164
|
-
}
|
|
165
|
-
if (!threads.length) return problems.length ? { unavailable: problems.join("; ") } : null;
|
|
166
|
-
const next = { threads: { ...prior } };
|
|
167
|
-
for (const t of threads) next.threads[t.thread] = { untilTurnId: t.untilTurnId, turns: t.turns, harvestedAt: new Date().toISOString() };
|
|
168
|
-
// The exact next watermark is written beside the current one by the
|
|
169
|
-
// package; the harvester's delivery is a rename, nothing retyped.
|
|
170
|
-
const nextPath = join(instanceHome, RECORD_WATERMARK.replace(/\.json$/, ".next.json"));
|
|
171
|
-
const windows = threads.map(({ thread, afterTurnId, untilTurnId }) => ({ thread, afterTurnId, untilTurnId }));
|
|
172
|
-
let pending;
|
|
173
|
-
try { pending = JSON.parse(readFileSync(nextPath, "utf8")).pendingHarvest; } catch { /* no prior plan */ }
|
|
174
|
-
const sameWindows = pending?.instance && JSON.stringify(pending.windows) === JSON.stringify(windows);
|
|
175
|
-
const warnings = sameWindows ? [
|
|
176
|
-
`previous harvester ${pending.instance} did not advance the watermark for ${windows.map((w) => `${w.thread} (${w.afterTurnId || "start"} -> ${w.untilTurnId})`).join(", ")}; inspect its outcome and use oats okf harvest --from-record --force to retry`,
|
|
177
|
-
] : [];
|
|
178
|
-
// Reuse the existing prepared watermark; no extra journal. Stamp it only
|
|
179
|
-
// after a successful spawn, and never overwrite a stalled plan on a skip.
|
|
180
|
-
if (sameWindows && !process.argv.includes("--force")) return { stalled: true, warnings };
|
|
181
|
-
const prepared = JSON.stringify(next, null, 2) + "\n";
|
|
182
|
-
writeFileSync(nextPath, prepared);
|
|
183
|
-
return { threads, windows, watermarkPath, nextPath, watermark: next, prepared, unattributed: (report.unattributed || []).length, problems, warnings };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Briefing block for record-fed candidates, appended to the harvest task. */
|
|
187
|
-
function recordBrief(plan, cli) {
|
|
188
|
-
if (!plan?.threads?.length) return "";
|
|
189
|
-
const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
190
|
-
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)}\``);
|
|
191
|
-
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}'`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Invoke the versioned package-runtime boundary. Task text crosses the
|
|
195
|
-
* process boundary only through an owner-readable tempfile, removed on every
|
|
196
|
-
* success/failure outcome. */
|
|
197
|
-
async function spawnHarvester(spawnArgs, task) {
|
|
198
|
-
const temp = mkdtempSync(join(tmpdir(), "oats-okf-harvest-"));
|
|
199
|
-
const taskFile = join(temp, "TASK.md");
|
|
200
|
-
try {
|
|
201
|
-
writeFileSync(taskFile, task, { mode: 0o600, flag: "wx" });
|
|
202
|
-
const args = ["spawn", "memory-harvest", ...spawnArgs, "--task-file", taskFile, "--json"];
|
|
203
|
-
const child = await new Promise((resolveChild) => {
|
|
204
|
-
execFile(packageRuntimeCli(), args, {
|
|
205
|
-
encoding: "utf8",
|
|
206
|
-
env: process.env,
|
|
207
|
-
timeout: 300000,
|
|
208
|
-
maxBuffer: 1024 * 1024,
|
|
209
|
-
}, (error, stdout, stderr) => resolveChild({ error, stdout, stderr }));
|
|
210
|
-
});
|
|
211
|
-
if (child.stderr) process.stderr.write(child.stderr);
|
|
212
|
-
if (child.error && !String(child.stdout || "").trim()) {
|
|
213
|
-
throw runtimeError("E_SPAWN_FAILED", child.error.message || child.error);
|
|
214
|
-
}
|
|
215
|
-
let envelope;
|
|
216
|
-
try { envelope = JSON.parse(String(child.stdout || "").trim()); }
|
|
217
|
-
catch { throw runtimeError("E_SPAWN_FAILED", "oats spawn returned an invalid JSON envelope"); }
|
|
218
|
-
if (envelope?.schemaVersion !== 1 || typeof envelope.ok !== "boolean") {
|
|
219
|
-
throw runtimeError("E_SPAWN_FAILED", "oats spawn returned an unsupported JSON envelope");
|
|
220
|
-
}
|
|
221
|
-
if (!envelope.ok) {
|
|
222
|
-
throw runtimeError(envelope.error?.code || "E_SPAWN_FAILED", envelope.error?.message || "oats spawn failed");
|
|
223
|
-
}
|
|
224
|
-
if (child.error) throw runtimeError("E_SPAWN_FAILED", child.error.message || "oats spawn failed");
|
|
225
|
-
if (!envelope.result?.instance) throw runtimeError("E_SPAWN_FAILED", "oats spawn success envelope has no instance");
|
|
226
|
-
return envelope.result;
|
|
227
|
-
} finally {
|
|
228
|
-
rmSync(temp, { recursive: true, force: true });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function harvestRuntime() {
|
|
233
|
-
const runtime = settings["harvest-runtime"] ?? "pi";
|
|
234
|
-
if (!["pi", "claude", "codex"].includes(runtime)) {
|
|
235
|
-
throw runtimeError("E_HARVEST_SETTINGS", "harvest-runtime must be pi, claude or codex");
|
|
236
|
-
}
|
|
237
|
-
const configured = settings["harvest-model"];
|
|
238
|
-
if (configured != null && (typeof configured !== "string" || !configured.trim())) {
|
|
239
|
-
throw runtimeError("E_HARVEST_SETTINGS", "harvest-model must be a nonempty model name");
|
|
240
|
-
}
|
|
241
|
-
const model = configured?.trim() || undefined;
|
|
242
|
-
if (runtime !== "pi" && model?.includes("/")) {
|
|
243
|
-
throw runtimeError("E_HARVEST_SETTINGS", `harvest-model for ${runtime} must be a native model name, without a Pi provider/ prefix`);
|
|
244
|
-
}
|
|
245
|
-
return { runtime, model };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function harvestSpawnArgs({ slug, parent, repo, work, workDir, branch, runtime, model }) {
|
|
249
|
-
const args = ["--purpose", slug, "--parent", parent, "--repo", repo, "--work", work, "--runtime", runtime];
|
|
250
|
-
if (workDir) args.push("--work-dir", workDir);
|
|
251
|
-
if (branch) args.push("--branch", branch);
|
|
252
|
-
if (model) args.push("--model", model);
|
|
253
|
-
return args;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Append a one-line entry to an OKF log.md (newest-first, date-grouped per spec §7). */
|
|
257
|
-
function appendLogEntry(logPath, entry, title) {
|
|
258
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
259
|
-
let text = existsSync(logPath) ? readFileSync(logPath, "utf8") : `# ${title}\n\n`;
|
|
260
|
-
const heading = `## ${today}`;
|
|
261
|
-
if (text.includes(heading)) text = text.replace(`${heading}\n`, `${heading}\n* ${entry}\n`);
|
|
262
|
-
else text = text.replace(/^(# [^\n]*\n\n?)/, `$1${heading}\n* ${entry}\n\n`);
|
|
263
|
-
writeFileSync(logPath, text);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Scaffold the soul's OKF knowledge bundle (idempotent). */
|
|
267
|
-
function scaffoldSoul() {
|
|
268
|
-
if (!soulDir) return false;
|
|
269
|
-
const kb = join(soulDir, "knowledge");
|
|
270
|
-
mkdirSync(kb, { recursive: true });
|
|
271
|
-
const index = join(kb, "index.md");
|
|
272
|
-
if (!existsSync(index)) {
|
|
273
|
-
let seeded = "";
|
|
274
|
-
const sf = settings["sections-file"];
|
|
275
|
-
if (sf) {
|
|
276
|
-
const abs = isAbsolute(sf) ? sf : join(process.env.OATS_CONTEXT || home, sf);
|
|
277
|
-
if (existsSync(abs)) seeded = readFileSync(abs, "utf8").trim() + "\n";
|
|
34
|
+
const flags={}; const boolean=new Set(['json','no-launch','manual','rejudge','launch','enable','disable','install-host','confirm']);
|
|
35
|
+
for(let i=1;i<args.length;i++) {
|
|
36
|
+
if(!args[i].startsWith('--')) fail('E_USAGE',`unexpected argument ${args[i]}`);
|
|
37
|
+
const k=args[i].slice(2);if(k in flags) fail('E_USAGE',`duplicate --${k}`);
|
|
38
|
+
if(boolean.has(k)) flags[k]=true;
|
|
39
|
+
else {if(!args[i+1] || args[i+1].startsWith('--')) fail('E_USAGE',`--${k} needs a value`);flags[k]=args[++i];}
|
|
278
40
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return true;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (event === "soul-scaffold") {
|
|
305
|
-
try { out({ meta: { scaffolded: scaffoldSoul() } }); } catch (e) { warn(e.message || e); }
|
|
306
|
-
} else if (event === "spawn") {
|
|
307
|
-
// Ephemeral CAPABILITY agents (reviewer, memory-harvest) carry no episodic
|
|
308
|
-
// state of their own — no STATE.md/log.md/notes scaffolding, and no
|
|
309
|
-
// session-protocol brief. LOCAL souls are full souls: they get everything.
|
|
310
|
-
// ("tmp" is the legacy spelling of local — treat it as local, with memory.)
|
|
311
|
-
if ((process.env.OATS_KIND || "") === "capability") {
|
|
312
|
-
out({ meta: { memory: "none" }, brief: "Memory: none — you are ephemeral; no STATE.md/log.md/notes upkeep, no harvest." });
|
|
313
|
-
}
|
|
314
|
-
try {
|
|
315
|
-
const task = (process.env.OATS_TASK || "").trim();
|
|
316
|
-
writeFileSync(join(home, "STATE.md"), `---
|
|
317
|
-
type: Instance State
|
|
318
|
-
title: ${instance} working state
|
|
319
|
-
description: Live working state for instance ${instance} — rewritten as work progresses.
|
|
320
|
-
timestamp: ${new Date().toISOString()}
|
|
321
|
-
---
|
|
322
|
-
|
|
323
|
-
# Task
|
|
324
|
-
|
|
325
|
-
${task || "_No task assigned yet — await instructions._"}
|
|
326
|
-
|
|
327
|
-
# Plan
|
|
328
|
-
|
|
329
|
-
_(numbered steps once you have a plan)_
|
|
330
|
-
|
|
331
|
-
# Progress
|
|
332
|
-
|
|
333
|
-
_(what is done — commits, files touched, verified results)_
|
|
334
|
-
|
|
335
|
-
# Next
|
|
336
|
-
|
|
337
|
-
_(the single next action — keep this current; a fresh session on any model resumes from here)_
|
|
338
|
-
|
|
339
|
-
# Context
|
|
340
|
-
|
|
341
|
-
- repo: ${process.env.OATS_REPO || "?"} (branch ${process.env.OATS_BRANCH || "?"}, mode ${process.env.OATS_WORK || "?"})
|
|
342
|
-
- key files/paths: _(fill in as you learn them)_
|
|
343
|
-
`);
|
|
344
|
-
appendLogEntry(join(home, "log.md"),
|
|
345
|
-
`**Creation**: instance ${instance} spawned from soul ${agentName}${task ? ` — task: ${task.split("\n")[0].slice(0, 120)}` : ""}.`,
|
|
346
|
-
"Instance Log");
|
|
347
|
-
mkdirSync(join(home, "notes"), { recursive: true });
|
|
348
|
-
out({
|
|
349
|
-
meta: { memory: "okf" },
|
|
350
|
-
brief: "Memory: your STATE.md/log.md/notes/ are scaffolded — your AGENTS.md's 'Knowledge: OKF' section has the session protocol.",
|
|
351
|
-
});
|
|
352
|
-
} catch (e) { warn(`instance memory scaffold failed: ${e.message || e}`); }
|
|
353
|
-
} else if (event === "harvest") {
|
|
354
|
-
// AGENT-INITIATED HARVEST. An instance that committed with pending notes
|
|
355
|
-
// runs `harvest` from its home: spawn the
|
|
356
|
-
// memory-harvest agent ATTACHED to the same work tree — sibling home, shared
|
|
357
|
-
// tree — to promote notes into the soul, commit, and retire itself.
|
|
358
|
-
// Long-lived sessions thus feed the soul continuously, on the agent's call.
|
|
359
|
-
try {
|
|
360
|
-
// Derive context from the instance home (cwd) when hook env is absent.
|
|
361
|
-
const metaFile = join(home, "instance.json");
|
|
362
|
-
const meta = existsSync(metaFile) ? JSON.parse(readFileSync(metaFile, "utf8")) : {};
|
|
363
|
-
const inst = instance || meta.instance;
|
|
364
|
-
const agName = process.env.OATS_AGENT || meta.agent || "agent";
|
|
365
|
-
const sDir = soulDir || join(home, "soul");
|
|
366
|
-
const context = process.env.OATS_CONTEXT || meta.repo;
|
|
367
|
-
let root = process.env.OATS_ROOT;
|
|
368
|
-
if (!root) { // walk up from home to the agents/ dir
|
|
369
|
-
let d = home;
|
|
370
|
-
while (d !== dirname(d)) { if (d.endsWith("/instances")) { root = join(d, "..", ".."); break; } d = dirname(d); }
|
|
371
|
-
root = root ? realpathSync(join(root)) : undefined;
|
|
372
|
-
// instances live at <root>/<agent>/instances/<inst>, at the legacy nested
|
|
373
|
-
// <root>/{local,tmp}-agents/<agent>/instances/<inst>, or at the sibling
|
|
374
|
-
// <scope>/local-agents/<agent>/instances/<inst> — canonical root is <scope>/agents.
|
|
375
|
-
if (root && ["local-agents", "tmp-agents"].includes(root.split("/").pop())) {
|
|
376
|
-
const parent = dirname(root);
|
|
377
|
-
root = parent.split("/").pop() === "agents" ? parent : join(parent, "agents");
|
|
41
|
+
const accepted={
|
|
42
|
+
spawn:[],retire:['home'], 'soul-scaffold':[],
|
|
43
|
+
harvest:['home','no-launch'],inspect:['home','source'],
|
|
44
|
+
'run-source':['source','manual','no-launch'],complete:['source','run','judgment'],
|
|
45
|
+
retry:['source','run','rejudge','launch','adopt-home'],read:['home','source','base','path'],refresh:['home','source'],
|
|
46
|
+
setup:['source','enable','disable','install-host'],init:['base','nodes','output','confirm'],
|
|
47
|
+
migrate:['source-home','legacy','base','node','output','deliver','cutover','soul-dir'],unlock:['lock','token']
|
|
48
|
+
};
|
|
49
|
+
for(const k of Object.keys(flags)) if(!['json','soul',...(accepted[event] || [])].includes(k)) fail('E_USAGE',`unknown flag --${k} for ${event}`);
|
|
50
|
+
if(flags.source && flags.home) fail('E_USAGE','choose source descriptor OR home');
|
|
51
|
+
const home=resolve(flags.home || process.env.OATS_INSTANCE_HOME || process.env.OATS_HOME || process.cwd());
|
|
52
|
+
const src=()=>flags.source?loadSource(resolve(flags.source)):homeSource(home);
|
|
53
|
+
let result;
|
|
54
|
+
if(event==='soul-scaffold') {
|
|
55
|
+
// Souls are portable declarations, never an implicit knowledge store.
|
|
56
|
+
result={meta:{scaffolded:false},brief:'OKF requires explicit external bindings and soul/okf.json before a working instance can spawn. Use init or migrate; no knowledge was created in this soul.'};
|
|
57
|
+
} else if(event==='spawn') {
|
|
58
|
+
const s=register(home);
|
|
59
|
+
if(s.skipped) result={meta:{memory:'none'},brief:'Service agent: follow your own task; no working-memory upkeep.'};
|
|
60
|
+
else {
|
|
61
|
+
const schedule=loadStatus(s).schedule.result;
|
|
62
|
+
result={meta:{memory:'okf-v2',source:s.file,schedule},brief:`Knowledge is an immutable accepted snapshot at ./knowledge/. Read knowledge/view.json for base paths under knowledge/bases/<alias>/, then the indexes for ${[...new Set([...s.decl.owns,...s.decl.reads])].join(', ')}. Follow only relevant links. All configured bases are available. Use oats okf read for current accepted text; old views stay stable. Keep STATE.md/log.md/notes/ current; never edit knowledge.`};
|
|
378
63
|
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// root, identity and context as any spawn; a home missing them answers
|
|
387
|
-
// exactly as before ("no pending notes"), so nothing an operator scripted
|
|
388
|
-
// against that reason changes.
|
|
389
|
-
const prerequisite = (why) => skip(notes.length ? why : "no pending notes");
|
|
390
|
-
if (!root || (!existsSync(root) && !existsSync(join(dirname(root), "local-agents")))) prerequisite("no agents root found above this home");
|
|
391
|
-
if (!inst) prerequisite("no instance identity (run from an instance home)");
|
|
392
|
-
if (!context) prerequisite("no repository context (instance metadata has no repo)");
|
|
393
|
-
const slug = String(inst).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
|
|
394
|
-
// Debounce: one harvester per source instance at a time (canonical sibling
|
|
395
|
-
// local-agents/ plus legacy nested locations). The public spawn boundary
|
|
396
|
-
// derives the deterministic instance name from --purpose <slug>.
|
|
397
|
-
const harvesterHomes = [
|
|
398
|
-
join(dirname(root), "local-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
|
|
399
|
-
join(root, "local-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
|
|
400
|
-
join(root, "tmp-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
|
|
401
|
-
];
|
|
402
|
-
if (harvesterHomes.some((h) => existsSync(h))) skip("harvester already running for this instance");
|
|
403
|
-
// No notes is no longer the end: the record may hold this instance's own
|
|
404
|
-
// sessions with turns nobody has judged yet (standing, non-coding roles
|
|
405
|
-
// write few notes). --from-record asks for the record even with notes.
|
|
406
|
-
// Planned only now, after every skip above: a capture pass is a real
|
|
407
|
-
// write and index, and "calling it too often is safe" must stay true.
|
|
408
|
-
const execution = harvestRuntime();
|
|
409
|
-
let recordPlan = null;
|
|
410
|
-
if (notes.length === 0 || process.argv.includes("--from-record")) {
|
|
411
|
-
let planned = null;
|
|
412
|
-
try { planned = planRecordHarvest(home); } catch { planned = null; }
|
|
413
|
-
if (planned?.unavailable) {
|
|
414
|
-
process.stderr.write(`oats-okf: record unavailable${notes.length ? ", harvesting notes only" : ""}: ${planned.unavailable}\n`);
|
|
415
|
-
if (notes.length === 0) skip("no pending notes");
|
|
416
|
-
} else recordPlan = planned;
|
|
417
|
-
for (const warning of recordPlan?.warnings || []) process.stderr.write(`oats-okf: record: ${warning}\n`);
|
|
418
|
-
if (recordPlan?.stalled) skip("previous harvester did not advance the watermark", recordPlan.warnings);
|
|
419
|
-
for (const line of recordPlan?.problems || []) process.stderr.write(`oats-okf: record: ${line}\n`);
|
|
420
|
-
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`);
|
|
421
|
-
if (notes.length === 0 && !recordPlan) skip("no pending notes");
|
|
422
|
-
}
|
|
423
|
-
// Effective command settings are injected by capability dispatch. No
|
|
424
|
-
// resolved-config read crosses the public package boundary.
|
|
425
|
-
const workDir = realpathSync(join(home, "work"));
|
|
426
|
-
const realSoul = realpathSync(sDir);
|
|
427
|
-
const harvName = `memory-harvest-${slug}`;
|
|
428
|
-
const gitRootOf = (start) => { let d = start; while (d !== dirname(d)) { if (existsSync(join(d, ".git"))) return d; d = dirname(d); } return undefined; };
|
|
429
|
-
let r;
|
|
430
|
-
const srcKind = process.env.OATS_KIND || meta.kind || "";
|
|
431
|
-
if (["local", "tmp"].includes(srcKind)) {
|
|
432
|
-
// LOCAL soul: uncommitted by contract (local-agents/, gitignored). The
|
|
433
|
-
// harvester judges notes exactly as usual, but the deliverable is DIRECT
|
|
434
|
-
// edits to the canonical soul — no commit, no PR: there is nothing to
|
|
435
|
-
// version. It must not touch the owner's work tree.
|
|
436
|
-
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\`.`;
|
|
437
|
-
r = await spawnHarvester(harvestSpawnArgs({
|
|
438
|
-
slug, parent: inst, repo: context, work: "attached", workDir, ...execution,
|
|
439
|
-
}), task);
|
|
440
|
-
} else if ((process.env.OATS_WORK || meta.work) === "workspace") {
|
|
441
|
-
// WORKSPACE-MODE instance: ./work is the whole workspace, not a git repo —
|
|
442
|
-
// the harvester may NOT commit there. The soul lives in its own home repo
|
|
443
|
-
// (committed to the workspace): harvest in a WORKTREE of that repo and
|
|
444
|
-
// deliver the promotion as a PR, never a direct push to its main branch.
|
|
445
|
-
const soulRepo = gitRootOf(realSoul);
|
|
446
|
-
if (!soulRepo) skip("workspace-mode soul is not inside a git repo — nowhere to deliver a PR");
|
|
447
|
-
const relSoul = realSoul.slice(soulRepo.length + 1);
|
|
448
|
-
// A leftover memory-harvest/<slug> branch from a merged promotion is
|
|
449
|
-
// deleted first; an unmerged one refuses the harvest with the remedy.
|
|
450
|
-
const reclaimed = reclaimHarvestBranch(soulRepo, `memory-harvest/${slug}`);
|
|
451
|
-
if (reclaimed.action === "deleted") process.stderr.write(`oats-okf: deleted stale harvest branch memory-harvest/${slug} (merged into ${reclaimed.base})\n`);
|
|
452
|
-
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).`;
|
|
453
|
-
r = await spawnHarvester(harvestSpawnArgs({
|
|
454
|
-
slug, parent: inst, repo: soulRepo, work: "worktree",
|
|
455
|
-
branch: `memory-harvest/${slug}`, ...execution,
|
|
456
|
-
}), task);
|
|
457
|
-
} else {
|
|
458
|
-
// Repo-resident souls: write to the soul AS SEEN FROM THE WORK TREE, so the
|
|
459
|
-
// promotion commits onto the instance's own branch. Otherwise the canonical soul.
|
|
460
|
-
const realRepo = realpathSync(context || workDir);
|
|
461
|
-
const soulTarget = realSoul.startsWith(realRepo + "/")
|
|
462
|
-
? join(workDir, realSoul.slice(realRepo.length + 1))
|
|
463
|
-
: realSoul;
|
|
464
|
-
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\`.`;
|
|
465
|
-
r = await spawnHarvester(harvestSpawnArgs({
|
|
466
|
-
slug, parent: inst, repo: context, work: "attached", workDir, ...execution,
|
|
467
|
-
}), task);
|
|
468
|
-
}
|
|
469
|
-
if (recordPlan) {
|
|
470
|
-
try {
|
|
471
|
-
// A fast harvester may already have renamed the prepared file. Never
|
|
472
|
-
// recreate that consumed plan or replace a different plan's contents.
|
|
473
|
-
if (readFileSync(recordPlan.nextPath, "utf8") === recordPlan.prepared) {
|
|
474
|
-
writeFileSync(recordPlan.nextPath, JSON.stringify({ ...recordPlan.watermark, pendingHarvest: { instance: r.instance, windows: recordPlan.windows } }, null, 2) + "\n");
|
|
475
|
-
}
|
|
476
|
-
} catch (e) {
|
|
477
|
-
if (e.code !== "ENOENT") {
|
|
478
|
-
const warning = `harvester ${r.instance} spawned but its retry marker could not be recorded: ${e.message}`;
|
|
479
|
-
recordPlan.warnings.push(warning);
|
|
480
|
-
process.stderr.write(`oats-okf: ${warning}\n`);
|
|
481
|
-
}
|
|
64
|
+
} else if(event==='retire') {
|
|
65
|
+
if(service(home)) result={meta:{retired:true}};
|
|
66
|
+
else if(!fs.existsSync(markerPath(home))) {
|
|
67
|
+
if(['STATE.md','log.md','notes','.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p)))) fail('E_MIGRATION','unregistered/legacy source has memory; explicitly migrate/register before retirement');
|
|
68
|
+
result={meta:{retired:true,reason:'nothing-to-delete'}};
|
|
69
|
+
} else {
|
|
70
|
+
const s=src();scheduleSource(s);const r=capture(s,{final:true});result={meta:{retired:r.complete===true,source:s.file,capture:r},brief:'Final input is in durable custody. Delivery remains asynchronous.'};
|
|
482
71
|
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
warn(`unknown event "${event}" (expected soul-scaffold|spawn|retire|harvest|inspect)`);
|
|
72
|
+
} else if(event==='harvest') {
|
|
73
|
+
const s=register(home);
|
|
74
|
+
result=s.skipped?{status:'skipped',reason:'service'}:runSource(s,{manual:true,noLaunch:!!flags['no-launch']});
|
|
75
|
+
} else if(event==='run-source') result=runSource(src(),{manual:!!flags.manual,noLaunch:!!flags['no-launch']});
|
|
76
|
+
else if(event==='complete') result=complete(src(),flags.run,flags.judgment && resolve(flags.judgment));
|
|
77
|
+
else if(event==='retry') result=retry(src(),{run:flags.run,rejudge:!!flags.rejudge,launch:!!flags.launch,adoptHome:flags['adopt-home']});
|
|
78
|
+
else if(event==='inspect') result=inspect(src());
|
|
79
|
+
else if(event==='setup') {
|
|
80
|
+
const s=src();if(flags.enable && flags.disable) fail('E_USAGE','choose enable or disable');
|
|
81
|
+
scheduleSource(s);
|
|
82
|
+
if(flags.enable || flags.disable) {oats(['schedule',flags.enable?'enable':'disable',`okf-${s.id}`,'--dir',s.context,'--json'],s.context);updateStatus(s,current=>{current.auto=!!flags.enable;});}
|
|
83
|
+
if(flags['install-host']) oats(['schedule','host','install','--dir',s.context,'--json'],s.context);
|
|
84
|
+
result={source:s.file,scheduler:oats(['schedule','list','--dir',s.context,'--json'],s.context).scheduler};
|
|
85
|
+
} else if(event==='read' || event==='refresh') {
|
|
86
|
+
const s=src();
|
|
87
|
+
// A descriptor-selected read is independent of any invoking/source home.
|
|
88
|
+
// In particular, retired sources must not leave caches in context/repo.
|
|
89
|
+
const target=join(flags.source?join(dirname(s.file),'views'):home,`knowledge-view-${randomUUID()}`);
|
|
90
|
+
const receipts=views(s.bindings,s.decl,target);
|
|
91
|
+
if(event==='refresh') result={path:target,receipts};
|
|
92
|
+
else {
|
|
93
|
+
if(!Object.hasOwn(s.bindings.bases,flags.base || '')) fail('E_CONFIG','unknown --base');
|
|
94
|
+
const basePath=join(target,receipts[flags.base].path);
|
|
95
|
+
const p=safePath(join(basePath,flags.path || 'index.md'));
|
|
96
|
+
if(!p.startsWith(basePath+'/') || !p.endsWith('.md')) fail('E_PATH','read only contained Markdown');
|
|
97
|
+
result={path:p,text:fs.readFileSync(p,'utf8'),receipt:receipts[flags.base]};
|
|
98
|
+
}
|
|
99
|
+
} else if(event==='init') result=initBase(loadBindings(),flags.base,flags.nodes,flags.output,{confirm:!!flags.confirm});
|
|
100
|
+
else if(event==='migrate') {
|
|
101
|
+
if(flags['source-home']) result=migrateSource(loadBindings(),flags['source-home']);
|
|
102
|
+
else if(flags.deliver) result=deliverMigration(resolve(flags.deliver));
|
|
103
|
+
else if(flags.cutover) result=cutoverMigration(resolve(flags.cutover),flags['soul-dir']);
|
|
104
|
+
else result=migrate(loadBindings(),{legacy:flags.legacy,alias:flags.base,node:flags.node,output:flags.output});
|
|
105
|
+
} else if(event==='unlock') result=unlock(resolve(flags.lock),flags.token);
|
|
106
|
+
else fail('E_USAGE',`unknown command ${event}; see --help`);
|
|
107
|
+
answer=hook?result:{schemaVersion:1,ok:true,result};
|
|
108
|
+
} catch(e) {exit=1;answer=hook?{meta:{...(event==='retire'?{retired:false,reason:e.message}:{})},warning:`oats-okf: ${e.message}`}:{schemaVersion:1,ok:false,error:{code:e.code || 'E_OKF',message:e.message}};}
|
|
109
|
+
// Let Node drain the pipe; no process.exit after a possibly large view.
|
|
110
|
+
process.stdout.write(JSON.stringify(answer)+'\n');process.exitCode=exit;
|
|
523
111
|
}
|