@awebai/oats 0.22.1 → 0.22.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/oats.mjs +302 -19
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +211 -6
- package/capabilities/oats-aweb/injects/aweb.md +10 -3
- package/capabilities/oats-aweb/oats.json +40 -7
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +3 -1
- package/capabilities/oats-okf/bin/oats-okf.mjs +125 -9
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +2 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/docs/capability-manifest.schema.json +41 -0
- package/docs/execution-targets.md +210 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +36 -0
- package/docs/migration-from-oas.md +1 -1
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +269 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/release-notes/v0.22.3.md +80 -0
- package/docs/servers.md +145 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +528 -78
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +623 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/skills/oats/SKILL.md +6 -2
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
* identity joined moments before the failure must still be deletable.
|
|
38
38
|
*/
|
|
39
39
|
import { execFileSync } from "node:child_process";
|
|
40
|
-
import { existsSync, statSync } from "node:fs";
|
|
40
|
+
import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { hostname } from "node:os";
|
|
41
42
|
import { join, dirname, resolve, delimiter } from "node:path";
|
|
42
43
|
|
|
43
44
|
/** Run a command as ARGV — never a shell string. Team ids, aliases, instance
|
|
@@ -66,6 +67,10 @@ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false } =
|
|
|
66
67
|
const why = secretSafe ? "" : (scrub(e.stderr).trim() || (e.status === undefined ? String(e.code || "failed") : ""));
|
|
67
68
|
const err = new Error(`${where} failed${e.status === undefined ? "" : ` (exit ${e.status})`}${why ? `: ${why}` : ""}${secretSafe ? " (output withheld: this command handles credentials)" : ""}`);
|
|
68
69
|
err.status = e.status;
|
|
70
|
+
// A classification, never the text: the caller may name a KNOWN failure
|
|
71
|
+
// class (an alias that still holds a certificate) without any output of a
|
|
72
|
+
// credential-handling command reaching a log.
|
|
73
|
+
err.aliasConflict = /already|exists|conflict|422|active certificate/i.test(String(e.stderr ?? "") + String(e.stdout ?? ""));
|
|
69
74
|
throw err;
|
|
70
75
|
}
|
|
71
76
|
};
|
|
@@ -97,6 +102,17 @@ const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb:
|
|
|
97
102
|
const event = process.env.OATS_EVENT || process.argv[2];
|
|
98
103
|
const instance = process.env.OATS_INSTANCE;
|
|
99
104
|
const home = process.env.OATS_HOME || process.cwd();
|
|
105
|
+
// Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
|
|
106
|
+
// delivery: "channel" (default) keeps the native channel packages waking the
|
|
107
|
+
// instance; "session" hands delivery to the host wake broker (aweb-abil):
|
|
108
|
+
// AWEB_DELIVERY=session goes into the launch environment, the Claude channel
|
|
109
|
+
// flag is omitted, and nothing wakes the instance until the broker exists.
|
|
110
|
+
let settings = {};
|
|
111
|
+
try { settings = JSON.parse(process.env.OATS_SETTINGS || "{}"); } catch { settings = {}; }
|
|
112
|
+
const deliveryMode = (() => {
|
|
113
|
+
const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
|
|
114
|
+
return v === "session" ? "session" : "channel";
|
|
115
|
+
})();
|
|
100
116
|
|
|
101
117
|
/**
|
|
102
118
|
* The aweb root (minting authority). BOUNDED candidates — the deployment's team
|
|
@@ -148,7 +164,165 @@ if (!onPath("aw")) {
|
|
|
148
164
|
warn(`aw CLI not on PATH — no identity minted; ${AW_INSTALL}`);
|
|
149
165
|
}
|
|
150
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Retained identity (explicit per-soul opt-in): a standing seat keeps its
|
|
169
|
+
// did:aw and address when re-seated as an OATS instance. Per aweb's contract
|
|
170
|
+
// (2026-09-05): copy exactly the identity-authority files from the source
|
|
171
|
+
// .aw into the home's .aw, reconnect the coordination binding with
|
|
172
|
+
// `aw workspace connect`, verify online, heartbeat and status show the new
|
|
173
|
+
// path, and hold a lock BESIDE the source so no second seat can take it.
|
|
174
|
+
// Never copy workspace.yaml, caches or locks; never delete the source; never
|
|
175
|
+
// team-join (that is the mint path, which would try to create the alias
|
|
176
|
+
// again). Retire releases the lock and leaves the identity alone.
|
|
177
|
+
const IDENTITY_AUTHORITY = ["signing.key", "identity.yaml", "teams.yaml", "team-certs", "encryption.yaml", "encryption-keys"];
|
|
178
|
+
// Session delivery registers the home with the host wake broker (aweb-abil:
|
|
179
|
+
// `aw wake register --home <abs> --identity-home <abs> --delivery session
|
|
180
|
+
// [--backend tmux|herdr]`, durable even when the daemon is down). An aw
|
|
181
|
+
// without `aw wake` cannot deliver in session mode: refuse, never silently
|
|
182
|
+
// turn a working channel into a poll-only instance.
|
|
183
|
+
// Throws, never exits: both callers run it inside a try whose catch performs
|
|
184
|
+
// the rollback (the seat path restores the binding; the mint path hands the
|
|
185
|
+
// minted identity to compensation).
|
|
186
|
+
function wakeRegister(instanceHome, identityHome) {
|
|
187
|
+
const backend = process.env.OATS_BACKEND;
|
|
188
|
+
try {
|
|
189
|
+
run(["aw", "wake", "register", "--home", instanceHome, "--identity-home", identityHome, "--delivery", "session", ...(backend ? ["--backend", backend] : [])], instanceHome, 60000);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
throw new Error(`delivery: session needs an aw with the wake broker CLI (aw wake register), which this aw does not provide (${e.message || e}); install the aweb release that ships aw wake, or use delivery: channel`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function wakeDeregister(instanceHome) {
|
|
195
|
+
try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
|
|
198
|
+
const yamlScalar = (text, key) => {
|
|
199
|
+
const m = String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
|
|
200
|
+
return m ? m[1].trim() : undefined;
|
|
201
|
+
};
|
|
202
|
+
function retainedSeatSpawn(source, takeOver) {
|
|
203
|
+
if (typeof source !== "string" || !source.startsWith("/")) fatal("identity.source must be the absolute path of the legacy .aw directory to retain");
|
|
204
|
+
if (!existsSync(join(source, "signing.key"))) fatal(`identity.source ${source} holds no signing.key, so there is no identity to retain`);
|
|
205
|
+
const lockPath = seatLockPath(source);
|
|
206
|
+
let takenOver;
|
|
207
|
+
if (existsSync(lockPath)) {
|
|
208
|
+
let held; try { held = JSON.parse(readFileSync(lockPath, "utf8")); } catch { held = {}; }
|
|
209
|
+
const holderHome = held.home;
|
|
210
|
+
// Held means the holder's home still exists: retire removes both the lock
|
|
211
|
+
// and the home, so a home that is there is a seat that was never retired.
|
|
212
|
+
// No process liveness is inferred (the spawner's pid says nothing about the
|
|
213
|
+
// runtime). The only escape is the explicit, warned take-over for a seat
|
|
214
|
+
// whose runtime is known to be dead.
|
|
215
|
+
if (holderHome && existsSync(holderHome)) {
|
|
216
|
+
if (takeOver !== true) fatal(`identity at ${source} is already held by ${holderHome} (${lockPath}); a seat is never taken from a holder whose home exists — retire that instance first, or set identity.takeOver: true only if you know its runtime is dead`);
|
|
217
|
+
takenOver = holderHome;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const srcWorkspace = existsSync(join(source, "workspace.yaml")) ? readFileSync(join(source, "workspace.yaml"), "utf8") : "";
|
|
221
|
+
const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
|
|
222
|
+
if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
|
|
223
|
+
const role = yamlScalar(srcWorkspace, "role_name");
|
|
224
|
+
let team = process.env.OATS_TEAM_ID;
|
|
225
|
+
if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
|
|
226
|
+
if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (set team.id in oats-config.yaml, or an active team in ${join(source, "teams.yaml")})`);
|
|
227
|
+
const dest = join(home, ".aw");
|
|
228
|
+
const legacyHome = dirname(source);
|
|
229
|
+
// The lock is taken FIRST: a concurrent second spawn must see it before any
|
|
230
|
+
// byte of the identity is copied.
|
|
231
|
+
// Exclusive creation (wx): two concurrent spawns cannot both pass the
|
|
232
|
+
// existence check and overwrite each other; the loser fails here having
|
|
233
|
+
// copied nothing. A take-over replaces the stale lock first, deliberately.
|
|
234
|
+
if (takenOver) { try { rmSync(lockPath, { force: true }); } catch { /* replaced below */ } }
|
|
235
|
+
try {
|
|
236
|
+
writeFileSync(lockPath, JSON.stringify({ home, instance, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
fatal(`identity at ${source} was taken by another spawn a moment ago (${lockPath} exists); nothing copied`);
|
|
239
|
+
}
|
|
240
|
+
let connected = false;
|
|
241
|
+
const rollback = () => {
|
|
242
|
+
// After `aw workspace connect` the server binding points at the new home;
|
|
243
|
+
// deleting the copy alone would leave the identity bound to nothing. Put
|
|
244
|
+
// the binding back where it was, from the legacy home, then remove the
|
|
245
|
+
// copy; if the restore fails, KEEP the copy so the seat stays recoverable.
|
|
246
|
+
let restored = !connected;
|
|
247
|
+
if (connected) {
|
|
248
|
+
try { run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], legacyHome, 60000); restored = true; }
|
|
249
|
+
catch { restored = false; }
|
|
250
|
+
}
|
|
251
|
+
if (restored) { try { rmSync(dest, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } }
|
|
252
|
+
return restored;
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
256
|
+
chmodSync(dest, 0o700);
|
|
257
|
+
for (const name of IDENTITY_AUTHORITY) {
|
|
258
|
+
const from = join(source, name);
|
|
259
|
+
if (!existsSync(from)) continue; // encryption material may be absent on an identity that never had it
|
|
260
|
+
const to = join(dest, name);
|
|
261
|
+
if (statSync(from).isDirectory()) {
|
|
262
|
+
cpSync(from, to, { recursive: true }); chmodSync(to, 0o700);
|
|
263
|
+
for (const f of readdirSync(to)) { const p = join(to, f); if (statSync(p).isFile()) chmodSync(p, 0o600); } // private keys inside, whatever the source modes were
|
|
264
|
+
} else { copyFileSync(from, to); chmodSync(to, 0o600); }
|
|
265
|
+
}
|
|
266
|
+
for (const forbidden of ["workspace.yaml", "context", "interaction-log.jsonl", "channel-delivered-ids.json", "chat-delivered-ids.json"]) {
|
|
267
|
+
if (existsSync(join(dest, forbidden))) rmSync(join(dest, forbidden), { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], home, 60000);
|
|
270
|
+
connected = true;
|
|
271
|
+
run(["aw", "check", "--online"], home, 60000);
|
|
272
|
+
run(["aw", "heartbeat"], home, 60000);
|
|
273
|
+
const status = run(["aw", "workspace", "status", "--json"], home, 60000);
|
|
274
|
+
// Thrown, not fatal: the catch below rolls the copy and the lock back first.
|
|
275
|
+
// Verified by parsing: the workspace row's path must be this home. The
|
|
276
|
+
// hostname the row records is whatever the binding stored (on hosted
|
|
277
|
+
// teams it need not equal this OS hostname), so it is reported, not judged.
|
|
278
|
+
let st; try { st = JSON.parse(String(status)); } catch { throw new Error(`aw workspace status from ${home} answered no JSON, so the seat is not connected; nothing is briefed`); }
|
|
279
|
+
const ws = st.workspace && typeof st.workspace === "object" ? st.workspace : st;
|
|
280
|
+
const shownPath = String(ws.workspace_path || ws.path || "");
|
|
281
|
+
const same = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
282
|
+
if (!shownPath || !same(shownPath, home)) throw new Error(`aw workspace status from ${home} shows workspace_path ${JSON.stringify(shownPath)} not this home, so the seat is not connected; nothing is briefed`);
|
|
283
|
+
const hostNote = ws.hostname && ws.hostname !== hostname() && ws.hostname.split(".")[0] !== hostname().split(".")[0] ? ` (workspace row hostname ${ws.hostname}, this host ${hostname()})` : "";
|
|
284
|
+
const identityText = readFileSync(join(dest, "identity.yaml"), "utf8");
|
|
285
|
+
const expectedDid = yamlScalar(identityText, "did");
|
|
286
|
+
const expectedAddress = yamlScalar(identityText, "address");
|
|
287
|
+
// The same-identity check the contract is for: `aw whoami --json` from the
|
|
288
|
+
// new home reports the did and address the CLI now acts as (workspace
|
|
289
|
+
// status carries no did); both must equal the copied identity.yaml.
|
|
290
|
+
let who; try { who = JSON.parse(String(run(["aw", "whoami", "--json"], home, 60000))); } catch (e) { throw new Error(`aw whoami from ${home} answered no JSON (${e.message || e}); the seat is not verified`); }
|
|
291
|
+
const shownDid = who.did || who.identity?.did;
|
|
292
|
+
const shownAddress = who.address || who.identity?.address;
|
|
293
|
+
if (expectedDid && shownDid !== expectedDid) throw new Error(`aw whoami shows did ${shownDid || "(none)"}, not the retained identity's ${expectedDid}; the seat is not the same identity`);
|
|
294
|
+
if (expectedAddress && shownAddress !== expectedAddress) throw new Error(`aw whoami shows address ${shownAddress || "(none)"}, not the retained identity's ${expectedAddress}; the seat is not the same identity`);
|
|
295
|
+
const aliasRaw = String(ws.alias || st.alias || (expectedAddress || "").split("/").pop() || instance);
|
|
296
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
|
|
297
|
+
const alias = aliasRaw;
|
|
298
|
+
if (expectedAddress && !expectedAddress.endsWith(`/${alias}`)) throw new Error(`aw workspace status shows alias ${alias}, not the retained identity's address ${expectedAddress}; the seat is not the same identity`);
|
|
299
|
+
writeFileSync(lockPath, JSON.stringify({ home, instance, alias, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
300
|
+
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
301
|
+
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
302
|
+
: undefined;
|
|
303
|
+
const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
|
|
304
|
+
const deliveryBrief = deliveryMode === "session"
|
|
305
|
+
? ` Notification delivery: external (AWEB_DELIVERY=session); until the host wake broker registers this instance NOTHING wakes you: check \`aw mail inbox\` and \`aw chat pending\` at every task boundary.`
|
|
306
|
+
: "";
|
|
307
|
+
if (deliveryMode === "session") wakeRegister(home, dest);
|
|
308
|
+
const warnings = [];
|
|
309
|
+
if (takenOver) warnings.push(`oats-aweb: took over the retained identity from ${takenOver} on identity.takeOver: true; if that runtime was still alive there are now two seats with one key — stop the old one`);
|
|
310
|
+
if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
|
|
311
|
+
out({
|
|
312
|
+
meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, ...(takenOver ? { tookOverFrom: takenOver } : {}) },
|
|
313
|
+
...(env ? { env } : {}),
|
|
314
|
+
brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
|
|
315
|
+
...(launch ? { launch } : {}),
|
|
316
|
+
...(warnings.length ? { warning: warnings.join(" | ") } : {}),
|
|
317
|
+
});
|
|
318
|
+
} catch (e) {
|
|
319
|
+
const restored = rollback();
|
|
320
|
+
fatal(`retained identity could not be seated from ${source}: ${e.message || e}${connected ? (restored ? " (the server binding was restored to the legacy home and the copy removed)" : ` (the server binding still points at ${home} and the copy was KEPT there so the seat is recoverable: run aw workspace connect from ${legacyHome} to restore it, or retry the spawn)`) : ""}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
151
324
|
if (event === "spawn") {
|
|
325
|
+
if (settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
|
|
152
326
|
let minted; // external identity, once `aw team join` succeeds
|
|
153
327
|
const root = awebRoot();
|
|
154
328
|
if (!root) fatal(`no initialized aweb root (.aw) among the bounded candidates (home, its git repo, context repo, workspace ${process.env.OATS_WORKSPACE || "?"}), so no identity could be minted and this instance would have no messaging — run \`oats aweb setup\` for guided onboarding`);
|
|
@@ -173,7 +347,18 @@ if (event === "spawn") {
|
|
|
173
347
|
// so neither their output nor their diagnostics may reach a log.
|
|
174
348
|
const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
|
|
175
349
|
if (!inv?.token || typeof inv.token !== "string") fatal("aw team invite returned no usable token, so no identity could be minted");
|
|
176
|
-
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = parseSecretJson(run(["aw", "team", "join", inv.token, "--name", instance, "--json"], home, 45000, { secrets: [inv.token], secretSafe: true }), "aw team join");
|
|
353
|
+
} catch (e) {
|
|
354
|
+
// A retired alias keeps its certificate until aweb-abim ships, so a
|
|
355
|
+
// re-spawn under the same name is refused by AWID. Say that, and the
|
|
356
|
+
// remedy, instead of relaying a bare join error.
|
|
357
|
+
if (e.aliasConflict) {
|
|
358
|
+
fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — spawn with a fresh --purpose instead`);
|
|
359
|
+
}
|
|
360
|
+
throw e;
|
|
361
|
+
}
|
|
177
362
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) fatal("aw team join returned no usable result, so no identity could be minted", minted);
|
|
178
363
|
// The RESPONSE is not a safe place to take strings from. Suppressing the
|
|
179
364
|
// failure paths does nothing if a successful reply is copied into meta and
|
|
@@ -203,13 +388,21 @@ if (event === "spawn") {
|
|
|
203
388
|
// spawn, which is exactly the silent host mutation the consent gate exists
|
|
204
389
|
// to prevent. By the time this runs the kernel has already proven the plugin
|
|
205
390
|
// is present and enabled, so contributing the flag is safe.
|
|
206
|
-
|
|
391
|
+
// Session delivery: no channel flag, AWEB_DELIVERY=session in the launch
|
|
392
|
+
// environment (declared in the manifest), and the truth about waking.
|
|
393
|
+
const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
|
|
207
394
|
? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
|
|
208
395
|
: undefined;
|
|
396
|
+
const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
|
|
209
397
|
const channelWarning = undefined;
|
|
398
|
+
if (deliveryMode === "session") wakeRegister(home, join(home, ".aw"));
|
|
399
|
+
const deliveryBrief = deliveryMode === "session"
|
|
400
|
+
? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
|
|
401
|
+
: "";
|
|
210
402
|
out({
|
|
211
|
-
meta: { team: joined.team_id, alias },
|
|
212
|
-
|
|
403
|
+
meta: { team: joined.team_id, alias, delivery: deliveryMode },
|
|
404
|
+
...(env ? { env } : {}),
|
|
405
|
+
brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
|
|
213
406
|
...(launch ? { launch } : {}),
|
|
214
407
|
...(mismatch ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : channelWarning ? { warning: channelWarning } : {}),
|
|
215
408
|
});
|
|
@@ -221,6 +414,14 @@ if (event === "spawn") {
|
|
|
221
414
|
}
|
|
222
415
|
} else if (event === "retire") {
|
|
223
416
|
const meta = JSON.parse(process.env.OATS_META || "{}");
|
|
417
|
+
// A retained seat: release the lock and leave the identity alone. Never
|
|
418
|
+
// aw workspace delete (it would soft-delete the standing identity's row)
|
|
419
|
+
// and never team retire; the source .aw stays until a human removes it.
|
|
420
|
+
if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
|
|
421
|
+
if (meta.retained) {
|
|
422
|
+
if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
|
|
423
|
+
out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
|
|
424
|
+
}
|
|
224
425
|
// No alias means the spawn hook never reported an identity: nothing exists to
|
|
225
426
|
// undo, which is completion. An alias WITH no local `.aw` is the opposite —
|
|
226
427
|
// the remote record exists and its key is gone, so the self-delete cannot be
|
|
@@ -233,7 +434,11 @@ if (event === "spawn") {
|
|
|
233
434
|
// Self-delete from inside the home, authenticated by its own key — a remote
|
|
234
435
|
// delete would 409 until the server marks the workspace stale.
|
|
235
436
|
run(["aw", "workspace", "delete", meta.alias], home);
|
|
236
|
-
|
|
437
|
+
// Honest: the workspace row is deleted, but a hosted local member cannot
|
|
438
|
+
// revoke its own AWID certificate (aweb-abim), so the alias is NOT
|
|
439
|
+
// reusable. retired stays true because the cleanup is as complete as the
|
|
440
|
+
// platform allows; the field and the line carry the truth.
|
|
441
|
+
out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a fresh --purpose` });
|
|
237
442
|
} catch (e) {
|
|
238
443
|
// Exit nonzero: during a required-hook rollback this is the signal that
|
|
239
444
|
// compensation did NOT complete, so the spawn is not reported as cleanly
|
|
@@ -40,10 +40,17 @@ Aliases are instance names (e.g. `dev-coordinator-1`). Discovery:
|
|
|
40
40
|
`oats status --team` lists this machine's live instances; `oats aweb roster`
|
|
41
41
|
lists the aweb team across machines.
|
|
42
42
|
|
|
43
|
+
**Notification delivery.** Your instance briefing (TASK.md, the Comms line)
|
|
44
|
+
says how messages reach you. If it carries "Notification delivery: external",
|
|
45
|
+
the native channel is NOT running in this session and, until the host wake
|
|
46
|
+
broker registers you, nothing wakes you: check `aw mail inbox` and
|
|
47
|
+
`aw chat pending` at every task boundary. Otherwise the rule below applies.
|
|
48
|
+
|
|
43
49
|
**Never sleep, poll, or busy-wait for another agent's reply.** Send your
|
|
44
|
-
message, finish your turn, and go idle
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
message, finish your turn, and go idle when a delivery channel is configured
|
|
51
|
+
(you saw `✓ aweb connected` at startup). Native Codex has no aweb channel:
|
|
52
|
+
check inbox and pending chat at task boundaries or when the operator asks,
|
|
53
|
+
as described in TASK.md; incoming messages alone will not wake that session. A `sleep N; aw mail inbox` loop burns tokens, delays the reply,
|
|
47
54
|
and adds nothing. An empty `aw mail inbox` means no UNREAD mail — not that
|
|
48
55
|
messages were lost.
|
|
49
56
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"capability": "oats.aweb",
|
|
3
3
|
"command": "aweb",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.10.0",
|
|
5
5
|
"compatibility": {
|
|
6
|
-
"oats": ">=0.
|
|
6
|
+
"oats": ">=0.22.3"
|
|
7
7
|
},
|
|
8
8
|
"layer": "messaging",
|
|
9
9
|
"description": "Messaging layer via aweb: per-instance team identities + native aw mail/chat skills + cross-machine team roster.",
|
|
@@ -16,15 +16,32 @@
|
|
|
16
16
|
{
|
|
17
17
|
"runtime": "pi",
|
|
18
18
|
"package": "npm:@awebai/pi",
|
|
19
|
-
"why": "the aweb channel extension for pi sessions
|
|
20
|
-
"install": "https://aweb.ai/docs (installed into pi with `pi install npm:@awebai/pi`)"
|
|
19
|
+
"why": "the aweb channel extension for pi sessions \u2014 real-time mail/chat awakenings; without it a pi instance can send with `aw` but is never woken by incoming messages",
|
|
20
|
+
"install": "https://aweb.ai/docs (installed into pi with `pi install npm:@awebai/pi`)",
|
|
21
|
+
"when": {
|
|
22
|
+
"delivery": "channel"
|
|
23
|
+
}
|
|
21
24
|
},
|
|
22
25
|
{
|
|
23
26
|
"runtime": "claude",
|
|
24
27
|
"package": "aweb-channel@awebai-marketplace",
|
|
25
28
|
"marketplace": "awebai/claude-plugins",
|
|
26
|
-
"why": "the aweb channel plugin for Claude Code sessions
|
|
27
|
-
"install": "https://aweb.ai/docs (installs the awebai marketplace and the aweb-channel plugin into Claude Code)"
|
|
29
|
+
"why": "the aweb channel plugin for Claude Code sessions \u2014 real-time mail/chat awakenings; without it a Claude instance can send with `aw` but is never woken by incoming messages",
|
|
30
|
+
"install": "https://aweb.ai/docs (installs the awebai marketplace and the aweb-channel plugin into Claude Code)",
|
|
31
|
+
"when": {
|
|
32
|
+
"delivery": "channel"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"runtime": "pi",
|
|
37
|
+
"package": "npm:@awebai/pi",
|
|
38
|
+
"minVersion": "0.3.10",
|
|
39
|
+
"when": {
|
|
40
|
+
"delivery": "session"
|
|
41
|
+
},
|
|
42
|
+
"why": "an ambient aweb pi extension, if one is installed, must honour AWEB_DELIVERY=session (0.3.10 and later); an older one would open a second event stream beside the wake broker; no extension at all is fine",
|
|
43
|
+
"install": "pi install npm:@awebai/pi@latest",
|
|
44
|
+
"ifInstalled": true
|
|
28
45
|
}
|
|
29
46
|
],
|
|
30
47
|
"skills": [
|
|
@@ -43,5 +60,21 @@
|
|
|
43
60
|
"required": true
|
|
44
61
|
},
|
|
45
62
|
"retire": "bin/oats-aweb.mjs retire"
|
|
46
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"environment": [
|
|
65
|
+
"AWEB_DELIVERY"
|
|
66
|
+
],
|
|
67
|
+
"settings": {
|
|
68
|
+
"delivery": {
|
|
69
|
+
"default": "channel",
|
|
70
|
+
"values": [
|
|
71
|
+
"channel",
|
|
72
|
+
"session"
|
|
73
|
+
],
|
|
74
|
+
"description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"environmentNamespaces": [
|
|
78
|
+
"AWEB_"
|
|
79
|
+
]
|
|
47
80
|
}
|
|
@@ -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.
|
|
@@ -23,7 +26,7 @@
|
|
|
23
26
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
|
24
27
|
import { join, isAbsolute, dirname } from "node:path";
|
|
25
28
|
import { tmpdir } from "node:os";
|
|
26
|
-
import { execFile } from "node:child_process";
|
|
29
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
27
30
|
|
|
28
31
|
const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); process.exit(0); };
|
|
29
32
|
const warn = (m) => out({ warning: `oats-okf: ${String(m).slice(0, 300)}` });
|
|
@@ -66,6 +69,97 @@ function packageRuntimeCli() {
|
|
|
66
69
|
return cli;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** Record-fed harvest (aweb-abfz). An instance that writes no notes still
|
|
73
|
+
* leaves a record: every Claude Code, pi and Codex session on the machine is
|
|
74
|
+
* captured as session turns, and the sessions that ran inside this home are
|
|
75
|
+
* the instance's own. Ask the kernel to capture them and report exact
|
|
76
|
+
* sequence boundaries, compare with the watermark of what was already
|
|
77
|
+
* harvested, and return the windows that are new — or null when nothing is.
|
|
78
|
+
* The watermark advances only when the harvester delivers (it writes the
|
|
79
|
+
* file its briefing hands it), so a failed harvest re-reads the same window. */
|
|
80
|
+
const RECORD_WATERMARK = ".okf-harvest-record.json";
|
|
81
|
+
/** A window is what one harvester can actually read: a first harvest of a
|
|
82
|
+
* long-lived session must not hand it the whole thread (tens of MB on real
|
|
83
|
+
* homes) and then let it advance the watermark past what it never read. The
|
|
84
|
+
* plan sizes each window with an ids-only listing and stops at the turn or
|
|
85
|
+
* byte cap; the rest drains over later harvests, each with a truthful
|
|
86
|
+
* watermark. Overridable through okf settings { "record-window-turns",
|
|
87
|
+
* "record-window-bytes" }. */
|
|
88
|
+
// Sized to ONE tool-output read: harnesses truncate a command's output well
|
|
89
|
+
// under 100 KB (Claude Code around 30 KB), and the byte cap is measured on
|
|
90
|
+
// the JSON the harvester receives, not on the text inside it. A backlog
|
|
91
|
+
// drains over successive harvests; the caps are settings for operators
|
|
92
|
+
// whose harness reads more.
|
|
93
|
+
const DEFAULT_WINDOW_TURNS = 60;
|
|
94
|
+
const DEFAULT_WINDOW_BYTES = 96_000;
|
|
95
|
+
function sizeWindow(cli, thread, afterTurnId, caps) {
|
|
96
|
+
const list = (after) => {
|
|
97
|
+
const args = ["recall", "--thread", thread, "--json", "--ids-only", "--limit", String(caps.turns)];
|
|
98
|
+
if (after) args.push("--after", after);
|
|
99
|
+
const r = spawnSync(cli, args, { encoding: "utf8", env: process.env, timeout: 120000, maxBuffer: 64 * 1024 * 1024 });
|
|
100
|
+
if (r.status !== 0) return { error: String(r.stderr || r.error?.message || `recall exited ${r.status}`).trim().slice(0, 200) };
|
|
101
|
+
try { return { doc: JSON.parse(String(r.stdout || "").trim()) }; } catch (e) { return { error: `recall answered no JSON: ${String(e.message).slice(0, 100)}` }; }
|
|
102
|
+
};
|
|
103
|
+
let { doc, error } = list(afterTurnId);
|
|
104
|
+
let restarted = false;
|
|
105
|
+
// The watermark's boundary turn can leave the thread (a redaction hides it
|
|
106
|
+
// for good). That must not strand the thread: read from the start again,
|
|
107
|
+
// bounded as always, and say so. The harvester's own fallback covers the
|
|
108
|
+
// same case between plan and read.
|
|
109
|
+
if (error && afterTurnId && /--after: no turn/.test(error)) { ({ doc, error } = list(null)); restarted = true; }
|
|
110
|
+
if (error) return { error };
|
|
111
|
+
let bytes = 0; let n = 0;
|
|
112
|
+
for (const t of doc.turns || []) {
|
|
113
|
+
if (n > 0 && bytes + t.bytes > caps.bytes) break; // always at least one turn, so a single huge turn still drains
|
|
114
|
+
bytes += t.bytes; n++;
|
|
115
|
+
}
|
|
116
|
+
if (!n) return { empty: true };
|
|
117
|
+
return { untilTurnId: doc.turns[n - 1].id, newTurns: n, bytes, remaining: (doc.turns.length - n) + (doc.remaining || 0), restarted };
|
|
118
|
+
}
|
|
119
|
+
function planRecordHarvest(instanceHome) {
|
|
120
|
+
const watermarkPath = join(instanceHome, RECORD_WATERMARK);
|
|
121
|
+
let prior = {};
|
|
122
|
+
try { prior = JSON.parse(readFileSync(watermarkPath, "utf8")).threads || {}; } catch { prior = {}; }
|
|
123
|
+
let report;
|
|
124
|
+
try {
|
|
125
|
+
const r = spawnSync(packageRuntimeCli(), ["capture", "--home", instanceHome, "--quiet"], { encoding: "utf8", env: process.env, timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
|
|
126
|
+
if (r.status !== 0) return { unavailable: String(r.stderr || r.error?.message || `capture exited ${r.status}`).trim().slice(0, 200) };
|
|
127
|
+
report = JSON.parse(String(r.stdout || "").trim());
|
|
128
|
+
} catch (e) { return { unavailable: String(e.message || e).slice(0, 200) }; }
|
|
129
|
+
const positive = (v, d) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
|
|
130
|
+
const caps = { turns: positive(settings["record-window-turns"], DEFAULT_WINDOW_TURNS), bytes: positive(settings["record-window-bytes"], DEFAULT_WINDOW_BYTES) };
|
|
131
|
+
const threads = [];
|
|
132
|
+
const problems = [];
|
|
133
|
+
for (const s of report.sessions || []) {
|
|
134
|
+
const seen = prior[s.thread];
|
|
135
|
+
// Nothing new when the last visible turn is the one already harvested;
|
|
136
|
+
// ids, not counts, so a redaction inside the harvested prefix neither
|
|
137
|
+
// hides genuinely new turns nor re-reads old ones.
|
|
138
|
+
if (seen && seen.untilTurnId === s.lastTurnId) continue;
|
|
139
|
+
const win = sizeWindow(packageRuntimeCli(), s.thread, seen?.untilTurnId || null, caps);
|
|
140
|
+
if (win.error) { problems.push(`${s.thread}: ${win.error}`); continue; }
|
|
141
|
+
if (win.empty) { problems.push(`${s.thread}: capture reports new turns after ${seen?.untilTurnId || "the start"} but recall lists none; the two views disagree, nothing planned for it`); continue; }
|
|
142
|
+
if (win.restarted) problems.push(`${s.thread}: the harvested boundary ${seen.untilTurnId} is no longer in the thread (redacted?); reading from the start again`);
|
|
143
|
+
threads.push({ thread: s.thread, source: s.source, afterTurnId: win.restarted ? null : (seen?.untilTurnId || null), untilTurnId: win.untilTurnId, turns: (win.restarted ? 0 : (seen?.turns || 0)) + win.newTurns, newTurns: win.newTurns, bytes: win.bytes, remaining: win.remaining });
|
|
144
|
+
}
|
|
145
|
+
if (!threads.length) return problems.length ? { unavailable: problems.join("; ") } : null;
|
|
146
|
+
const next = { threads: { ...prior } };
|
|
147
|
+
for (const t of threads) next.threads[t.thread] = { untilTurnId: t.untilTurnId, turns: t.turns, harvestedAt: new Date().toISOString() };
|
|
148
|
+
// The exact next watermark is written beside the current one by the
|
|
149
|
+
// package; the harvester's delivery is a rename, nothing retyped.
|
|
150
|
+
const nextPath = join(instanceHome, RECORD_WATERMARK.replace(/\.json$/, ".next.json"));
|
|
151
|
+
writeFileSync(nextPath, JSON.stringify(next, null, 2) + "\n");
|
|
152
|
+
return { threads, watermarkPath, nextPath, watermark: next, unattributed: (report.unattributed || []).length, problems };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Briefing block for record-fed candidates, appended to the harvest task. */
|
|
156
|
+
function recordBrief(plan, cli) {
|
|
157
|
+
if (!plan?.threads?.length) return "";
|
|
158
|
+
const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
159
|
+
const lines = plan.threads.map((t) => ` - ${t.thread} (${t.newTurns} turns, ~${Math.round(t.bytes / 1024)} KB of JSON${t.remaining ? `, ${t.remaining} more wait for the next harvest` : ""}): \`${cli} recall --thread ${q(t.thread)} --json${t.afterTurnId ? ` --after ${q(t.afterTurnId)}` : ""} --until ${q(t.untilTurnId)}\``);
|
|
160
|
+
return `\n- RECORD-FED CANDIDATES (the memory-harvest skill, section "Record-fed candidates"): this instance's own captured session turns since the last harvest, in windows sized for one reading (about ${Math.round(DEFAULT_WINDOW_BYTES / 1024)} KB at most). Read each window with the exact command given, never wider, and read it IN FULL. If your tool output truncates, redirect the command's output to a file in your home and read that file in parts: that is a complete reading, not a wider one. A window you could not read completely is a failed harvest, and a failed harvest leaves the watermark alone.\n${lines.join("\n")}\n If a window command is rejected because its --after id is no longer in the thread, run it again without --after and read from the start; if its --until id is rejected, this harvest has failed (leave the watermark files alone; the next oats okf harvest replans). Extract candidate lessons from them in the same shape as notes (one candidate per insight, provenance = the turn ids it came from), then judge every candidate under the same promotion bar as a note. Session trivia, tool noise and anything derivable from the repo fail the bar; promoting nothing is a normal outcome.\n- When your judgement of every window is COMPLETE, whether or not anything was promoted, and after any delivery it needed, advance the watermark by renaming the prepared file (it records what you read, not what you promoted; a failed or abandoned harvest must leave both files as they are):\n mv '${plan.nextPath}' '${plan.watermarkPath}'`;
|
|
161
|
+
}
|
|
162
|
+
|
|
69
163
|
/** Invoke the versioned package-runtime boundary. Task text crosses the
|
|
70
164
|
* process boundary only through an owner-readable tempfile, removed on every
|
|
71
165
|
* success/failure outcome. */
|
|
@@ -240,10 +334,15 @@ _(the single next action — keep this current; a fresh session on any model res
|
|
|
240
334
|
const skip = (why) => (JSON_MODE ? jsonOk({ harvest: "skipped", reason: why }) : out({ meta: { harvestSpawn: "skipped", why } }));
|
|
241
335
|
if (String(agName).startsWith("memory-harvest")) skip("self (loop guard)");
|
|
242
336
|
const notes = existsSync(notesDir) ? readdirSync(notesDir).filter((f) => f.endsWith(".md")) : [];
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
337
|
+
|
|
338
|
+
// With no notes the record path could still apply, but it needs the same
|
|
339
|
+
// root, identity and context as any spawn; a home missing them answers
|
|
340
|
+
// exactly as before ("no pending notes"), so nothing an operator scripted
|
|
341
|
+
// against that reason changes.
|
|
342
|
+
const prerequisite = (why) => skip(notes.length ? why : "no pending notes");
|
|
343
|
+
if (!root || (!existsSync(root) && !existsSync(join(dirname(root), "local-agents")))) prerequisite("no agents root found above this home");
|
|
344
|
+
if (!inst) prerequisite("no instance identity (run from an instance home)");
|
|
345
|
+
if (!context) prerequisite("no repository context (instance metadata has no repo)");
|
|
247
346
|
const slug = String(inst).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
|
|
248
347
|
// Debounce: one harvester per source instance at a time (canonical sibling
|
|
249
348
|
// local-agents/ plus legacy nested locations). The public spawn boundary
|
|
@@ -254,6 +353,23 @@ _(the single next action — keep this current; a fresh session on any model res
|
|
|
254
353
|
join(root, "tmp-agents", "memory-harvest", "instances", `memory-harvest-${slug}`),
|
|
255
354
|
];
|
|
256
355
|
if (harvesterHomes.some((h) => existsSync(h))) skip("harvester already running for this instance");
|
|
356
|
+
// No notes is no longer the end: the record may hold this instance's own
|
|
357
|
+
// sessions with turns nobody has judged yet (standing, non-coding roles
|
|
358
|
+
// write few notes). --from-record asks for the record even with notes.
|
|
359
|
+
// Planned only now, after every skip above: a capture pass is a real
|
|
360
|
+
// write and index, and "calling it too often is safe" must stay true.
|
|
361
|
+
let recordPlan = null;
|
|
362
|
+
if (notes.length === 0 || process.argv.includes("--from-record")) {
|
|
363
|
+
let planned = null;
|
|
364
|
+
try { planned = planRecordHarvest(home); } catch { planned = null; }
|
|
365
|
+
if (planned?.unavailable) {
|
|
366
|
+
process.stderr.write(`oats-okf: record unavailable${notes.length ? ", harvesting notes only" : ""}: ${planned.unavailable}\n`);
|
|
367
|
+
if (notes.length === 0) skip("no pending notes");
|
|
368
|
+
} else recordPlan = planned;
|
|
369
|
+
for (const line of recordPlan?.problems || []) process.stderr.write(`oats-okf: record: ${line}\n`);
|
|
370
|
+
if (recordPlan?.unattributed) process.stderr.write(`oats-okf: record: ${recordPlan.unattributed} session file(s) carry no working directory and cannot be attributed to any home (oats capture --home <home> lists them)\n`);
|
|
371
|
+
if (notes.length === 0 && !recordPlan) skip("no pending notes");
|
|
372
|
+
}
|
|
257
373
|
// Effective command settings are injected by capability dispatch. No
|
|
258
374
|
// resolved-config read crosses the public package boundary.
|
|
259
375
|
const harvestModel = settings["harvest-model"] || DEFAULT_HARVEST_MODEL;
|
|
@@ -268,7 +384,7 @@ _(the single next action — keep this current; a fresh session on any model res
|
|
|
268
384
|
// harvester judges notes exactly as usual, but the deliverable is DIRECT
|
|
269
385
|
// edits to the canonical soul — no commit, no PR: there is nothing to
|
|
270
386
|
// version. It must not touch the owner's work tree.
|
|
271
|
-
const task = `Harvest the pending notes of live LOCAL-SOUL instance "${inst}" (agent "${agName}") into its soul — by direct edits, no commit.\n\n- Source notes: ${notesDir} (${notes.join(", ")})\n- Soul knowledge bundle to update: ${join(realSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(realSoul, "skills")}\n- This soul is LOCAL (uncommitted, gitignored): edit those soul files IN PLACE. Do NOT run git commit — not for the soul, and not in ./work (the shared tree belongs to the working instance; leave it untouched).\n- Follow your memory-harvest skill for everything else: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir.\n- Then run \`oats retire ${harvName} --self\`.`;
|
|
387
|
+
const task = `Harvest the pending notes of live LOCAL-SOUL instance "${inst}" (agent "${agName}") into its soul — by direct edits, no commit.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Soul knowledge bundle to update: ${join(realSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(realSoul, "skills")}\n- This soul is LOCAL (uncommitted, gitignored): edit those soul files IN PLACE. Do NOT run git commit — not for the soul, and not in ./work (the shared tree belongs to the working instance; leave it untouched).\n- Follow your memory-harvest skill for everything else: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir.\n${recordBrief(recordPlan, packageRuntimeCli())}\n- Then run \`oats retire ${harvName} --self\`.`;
|
|
272
388
|
r = await spawnHarvester(harvestSpawnArgs({
|
|
273
389
|
slug, parent: inst, repo: context, work: "attached", workDir, model: harvestModel,
|
|
274
390
|
}), task);
|
|
@@ -280,7 +396,7 @@ _(the single next action — keep this current; a fresh session on any model res
|
|
|
280
396
|
const soulRepo = gitRootOf(realSoul);
|
|
281
397
|
if (!soulRepo) skip("workspace-mode soul is not inside a git repo — nowhere to deliver a PR");
|
|
282
398
|
const relSoul = realSoul.slice(soulRepo.length + 1);
|
|
283
|
-
const task = `Harvest the pending notes of live WORKSPACE-MODE instance "${inst}" (agent "${agName}") into its soul — delivered as a PR.\n\n- Source notes: ${notesDir} (${notes.join(", ")})\n- Your ./work is a dedicated worktree of the soul's home repo (${soulRepo}), branch memory-harvest/${slug}.\n- Soul knowledge bundle to update: ./work/${join(relSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ./work/${join(relSoul, "skills")}\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir, commit once (prefixed "memory-harvest:")
|
|
399
|
+
const task = `Harvest the pending notes of live WORKSPACE-MODE instance "${inst}" (agent "${agName}") into its soul — delivered as a PR.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Your ./work is a dedicated worktree of the soul's home repo (${soulRepo}), branch memory-harvest/${slug}.\n- Soul knowledge bundle to update: ./work/${join(relSoul, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ./work/${join(relSoul, "skills")}\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir, and commit once (prefixed "memory-harvest:") if anything changed.${recordBrief(recordPlan, packageRuntimeCli())}\n- If you changed anything: push the branch and open a PR (\`git push -u origin memory-harvest/${slug}\` then \`gh pr create --fill\`). Do NOT merge it; the humans/owners of ${soulRepo} review soul changes. If gh is unavailable, push the branch and report the compare URL. A harvest that promoted nothing has nothing to commit, push or open; that is a completed harvest, not a failed one.\n- Finally run \`oats retire ${harvName} --self\` (keep the branch: --self only).`;
|
|
284
400
|
r = await spawnHarvester(harvestSpawnArgs({
|
|
285
401
|
slug, parent: inst, repo: soulRepo, work: "worktree",
|
|
286
402
|
branch: `memory-harvest/${slug}`, model: harvestModel,
|
|
@@ -292,12 +408,12 @@ _(the single next action — keep this current; a fresh session on any model res
|
|
|
292
408
|
const soulTarget = realSoul.startsWith(realRepo + "/")
|
|
293
409
|
? join(workDir, realSoul.slice(realRepo.length + 1))
|
|
294
410
|
: realSoul;
|
|
295
|
-
const task = `Harvest the pending notes of live instance "${inst}" (agent "${agName}") into its soul.\n\n- Source notes: ${notesDir} (${notes.join(", ")})\n- Soul knowledge bundle to update: ${join(soulTarget, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(soulTarget, "skills")}\n- You are ATTACHED to the instance's work tree (./work) — commit your promotions there as a single commit, prefixed "memory-harvest:".\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir (so they are not re-harvested), commit, then run \`oats retire ${harvName} --self\`.`;
|
|
411
|
+
const task = `Harvest the pending notes of live instance "${inst}" (agent "${agName}") into its soul.\n\n- Source notes: ${notes.length ? `${notesDir} (${notes.join(", ")})` : "none pending"}\n- Soul knowledge bundle to update: ${join(soulTarget, "knowledge")}\n- Soul skills dir (for procedure-shaped notes): ${join(soulTarget, "skills")}\n- You are ATTACHED to the instance's work tree (./work) — commit your promotions there as a single commit, prefixed "memory-harvest:".\n- Follow your memory-harvest skill: promote/merge/drop each note, knowledge vs skill routing, index + log discipline, validate the bundle, DELETE processed notes from the source notes/ dir (so they are not re-harvested).${recordBrief(recordPlan, packageRuntimeCli())}\n- Commit if you changed anything (a harvest that promoted nothing has nothing to commit), then run \`oats retire ${harvName} --self\`.`;
|
|
296
412
|
r = await spawnHarvester(harvestSpawnArgs({
|
|
297
413
|
slug, parent: inst, repo: context, work: "attached", workDir, model: harvestModel,
|
|
298
414
|
}), task);
|
|
299
415
|
}
|
|
300
|
-
if (JSON_MODE) jsonOk({ harvest: "spawned", instance: r.instance, window: r.tmux?.window || null });
|
|
416
|
+
if (JSON_MODE) jsonOk({ harvest: "spawned", instance: r.instance, window: r.tmux?.window || null, ...(recordPlan ? { record: { threads: recordPlan.threads.map((t) => t.thread), ...(recordPlan.unattributed ? { unattributed: recordPlan.unattributed } : {}), ...(recordPlan.problems?.length ? { problems: recordPlan.problems } : {}) } } : {}) });
|
|
301
417
|
out({ meta: { harvestSpawn: r.instance, window: r.tmux?.window } });
|
|
302
418
|
} catch (e) {
|
|
303
419
|
if (JSON_MODE) jsonFail(e.code || "E_HARVEST_FAILED", `harvest spawn failed (notes are safe on disk): ${e.message || e}`);
|
|
@@ -43,6 +43,13 @@ are no notes or a harvester is already running — calling it "too often" is
|
|
|
43
43
|
safe; not calling it means your insights never reach the soul, and unwritten
|
|
44
44
|
or unharvested notes are lost when your home is retired).
|
|
45
45
|
|
|
46
|
+
**If you write few notes** (a coordinating or reviewing role, a standing
|
|
47
|
+
session): still run `oats okf harvest` at task boundaries, and at least once
|
|
48
|
+
a day. With no notes pending it harvests your own captured session turns
|
|
49
|
+
since the last harvest instead; the harvester judges them under the same
|
|
50
|
+
bar. It skips when nothing is new. `oats okf harvest --from-record` asks for
|
|
51
|
+
the record even when notes are pending.
|
|
52
|
+
|
|
46
53
|
**Workspace-mode instances**: your soul lives in its own home repo, and your
|
|
47
54
|
`./work` (the workspace) is not where it commits. `oats okf harvest` handles
|
|
48
55
|
this — it promotes your notes in a worktree of the soul's home repo and
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"capability": "oats.okf",
|
|
3
3
|
"command": "okf",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"compatibility": { "oats": ">=0.
|
|
4
|
+
"version": "1.5.0",
|
|
5
|
+
"compatibility": { "oats": ">=0.22.2" },
|
|
6
6
|
"layer": "knowledge",
|
|
7
7
|
"description": "Knowledge layer via OKF: soul bundles, instance memory (STATE.md/log.md/notes/), continuous post-commit harvest into the soul (commit, PR, or direct-edit for local souls), craft + memory skills, validator.",
|
|
8
8
|
"requires": [],
|