@awebai/oats 0.25.4 → 0.25.5

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.
@@ -39,7 +39,7 @@
39
39
  import { execFileSync } from "node:child_process";
40
40
  import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
41
41
  import { hostname } from "node:os";
42
- import { join, dirname, resolve, delimiter } from "node:path";
42
+ import { join, dirname, resolve, delimiter, isAbsolute } from "node:path";
43
43
  import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
44
44
  import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
45
45
  import { runCapturedNative } from "../lib/captured-native.mjs";
@@ -50,9 +50,11 @@ import { parseBindingJson } from "../lib/binding-wire.mjs";
50
50
  * property of one helper staying correct forever, while argv removes the class.
51
51
  * This hook is a REQUIRED spawn hook, so it gates every spawn, which is reason
52
52
  * enough not to rely on quoting. */
53
- const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false } = {}) => {
53
+ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env: extraEnv, unsetEnv = [] } = {}) => {
54
54
  try {
55
- return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
55
+ const childEnv = extraEnv || unsetEnv.length ? { ...process.env, ...(extraEnv || {}) } : undefined;
56
+ for (const name of unsetEnv) if (childEnv) delete childEnv[name];
57
+ return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout, ...(childEnv ? { env: childEnv } : {}) }).trim();
56
58
  } catch (e) {
57
59
  // execFileSync puts the WHOLE ARGV in e.message ("Command failed: aw team
58
60
  // join <token> …"). This hook's failures are reported by the kernel and land
@@ -113,6 +115,17 @@ const warn = (m) => out({ warning: `oats-aweb: ${String(m).slice(0, 300)}` });
113
115
  * nonzero so the kernel rolls the spawn back. `meta` carries whatever external
114
116
  * state already exists (e.g. a joined identity) so retire can undo it. */
115
117
  const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(m).slice(0, 300)}` }, 1);
118
+ const parseAwJson = (text, what) => {
119
+ const trimmed = String(text ?? "").trim();
120
+ if (!trimmed) throw new Error(`${what} returned no JSON result`);
121
+ try { return JSON.parse(trimmed); } catch { /* may have progress before JSON */ }
122
+ const lines = trimmed.split(/\r?\n/);
123
+ for (let i = 0; i < lines.length; i++) {
124
+ if (!lines[i].trimStart().startsWith("{")) continue;
125
+ try { return JSON.parse(lines.slice(i).join("\n")); } catch { /* keep looking */ }
126
+ }
127
+ throw new Error(`${what} returned no JSON result`);
128
+ };
116
129
 
117
130
  // Any selected snapshot enters the captured consumer BEFORE legacy settings,
118
131
  // root discovery or identity handling. Invalid-present never falls back.
@@ -150,6 +163,15 @@ const deliveryMode = (() => {
150
163
  const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
151
164
  return v === "session" ? "session" : "channel";
152
165
  })();
166
+ const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
167
+ const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
168
+ if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
169
+ const payloadTeam = () => {
170
+ const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
171
+ const fromEnv = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined;
172
+ return { team: fromSettings || fromEnv, payload: fromSettings, env: fromEnv };
173
+ };
174
+ const identityMeta = ({ mode = "local", alias, team, address = null, resident = null, grant }) => ({ mode, alias, team, address: address || null, resident: resident || null, ...(grant ? { grant } : {}) });
153
175
 
154
176
  /**
155
177
  * The aweb root (minting authority). BOUNDED candidates — the deployment's team
@@ -231,6 +253,126 @@ function wakeRegister(instanceHome, identityHome) {
231
253
  function wakeDeregister(instanceHome) {
232
254
  try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
233
255
  }
256
+ const DEFAULT_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send"];
257
+ const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
258
+ function resolveResidentCustody(name) {
259
+ if (!name) fatal(`identity.mode "global" requires identity.resident; set ${residentKeyHint("<name>")} to the absolute custody directory for that resident identity`);
260
+ const residents = settings.residents && typeof settings.residents === "object" && !Array.isArray(settings.residents) ? settings.residents : {};
261
+ const custody = residents[name];
262
+ if (typeof custody !== "string" || !isAbsolute(custody) || !existsSync(join(custody, ".aw", "identity.yaml"))) {
263
+ fatal(`identity.mode "global" resident ${JSON.stringify(name)} is not resolvable; set ${residentKeyHint(name)} to an absolute custody directory whose .aw/identity.yaml exists`);
264
+ }
265
+ return custody;
266
+ }
267
+ function grantShow(custody, grantId) {
268
+ const raw = run(["aw", "id", "grant", "show", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
269
+ return parseAwJson(raw, "aw id grant show");
270
+ }
271
+ const revokeMaybeApplied = (error) => /may have applied|context deadline exceeded|timed out|timeout/i.test(String(error?.message || error));
272
+ function revokeGrant(custody, grantId) {
273
+ try {
274
+ const raw = run(["aw", "id", "grant", "revoke", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
275
+ return parseAwJson(raw, "aw id grant revoke");
276
+ } catch (e) {
277
+ if (revokeMaybeApplied(e)) {
278
+ try {
279
+ const shown = grantShow(custody, grantId);
280
+ const status = String(shown.status || shown.grant?.status || "").toLowerCase();
281
+ if (status === "revoked" || shown.revoked === true) return { grant_id: grantId, status: "revoked", verifiedByShow: true };
282
+ } catch { /* fall through to original revoke error */ }
283
+ }
284
+ throw e;
285
+ }
286
+ }
287
+ function recoverGrantHome(grantHome) {
288
+ try {
289
+ const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
290
+ const scalar = (key) => {
291
+ const m = text.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
292
+ return m ? m[1].trim() : undefined;
293
+ };
294
+ return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at") };
295
+ } catch { return {}; }
296
+ }
297
+ function globalGrantSpawn() {
298
+ const { team, payload, env: envTeam } = payloadTeam();
299
+ if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team (or OATS_TEAM_ID/OATS_TEAM_NAME) before minting a grant");
300
+ const resident = String(identitySettings.resident || "");
301
+ const custody = resolveResidentCustody(resident);
302
+ const grantHome = join(home, ".aweb-identity");
303
+ if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
304
+ const scopes = Array.isArray(identitySettings.scopes) && identitySettings.scopes.length ? identitySettings.scopes.map(String) : DEFAULT_GRANT_SCOPES;
305
+ const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
306
+ let meta;
307
+ const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
308
+ const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
309
+ try {
310
+ const raw = run(["aw", "id", "grant", "mint", "--scope", scopes.join(","), "--ttl", ttl, "--label", `oats:${instance}`, "--out", grantHome, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
311
+ let minted;
312
+ try { minted = parseAwJson(raw, "aw id grant mint"); }
313
+ catch (parseError) {
314
+ const recovered = recoverGrantHome(grantHome);
315
+ if (recovered.grantId) {
316
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes } }) };
317
+ try { revokeGrant(custody, recovered.grantId); failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, revoked it, and removed the grant home`); }
318
+ catch (revokeError) { failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, but revoke failed: ${revokeError.message || revokeError}`); }
319
+ }
320
+ throw parseError;
321
+ }
322
+ const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
323
+ const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
324
+ const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
325
+ const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
326
+ if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
327
+ if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
328
+ const alias = typeof minted.alias === "string" && minted.alias ? minted.alias : resident;
329
+ const address = typeof minted.address === "string" && minted.address ? minted.address : null;
330
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes } }) };
331
+ if (mintedTeam !== team) {
332
+ try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
333
+ catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
334
+ }
335
+ const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
336
+ ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
337
+ : undefined;
338
+ const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: grantHome };
339
+ if (deliveryMode === "session") {
340
+ try { wakeRegister(home, grantHome); }
341
+ catch (e) {
342
+ try { revokeGrant(custody, grantId); failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; grant revoked and grant home removed`); }
343
+ catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
344
+ }
345
+ }
346
+ const warnings = [];
347
+ if (payload && envTeam && payload !== envTeam) warnings.push(`oats-aweb: settings.oats.aweb.team ${payload} differs from OATS team ${envTeam}; using payload team`);
348
+ const deliveryBrief = deliveryMode === "session"
349
+ ? ` 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.`
350
+ : "";
351
+ out({
352
+ meta,
353
+ env,
354
+ brief: `Comms: you act as resident aweb identity "${alias}" on team ${mintedTeam} through a session grant for ${resident}; scopes: ${scopes.join(", ")}; expires: ${expiresAt}. Root keys are not in this home, and identity lifecycle commands are not yours to run.${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
355
+ ...(launch ? { launch } : {}),
356
+ ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
357
+ });
358
+ } catch (e) {
359
+ if (meta?.identity?.grant?.id) { try { revokeGrant(custody, meta.identity.grant.id); } catch { /* retire compensation gets meta */ } cleanup(); }
360
+ fatal(`identity grant minting failed: ${e.message || e}`, meta);
361
+ }
362
+ }
363
+ function globalGrantRetire(meta) {
364
+ 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"); }
365
+ const id = meta.identity?.grant?.id;
366
+ if (!id) out({ meta: { retired: false, reason: "nothing-to-revoke" } });
367
+ const resident = meta.identity?.resident;
368
+ const custody = resolveResidentCustody(resident);
369
+ try {
370
+ revokeGrant(custody, id);
371
+ out({ meta: { retired: true, identityRevoked: true, grant: id } });
372
+ } catch (e) {
373
+ out({ meta: { retired: false, reason: "grant-revoke-failed", grant: id }, warning: `oats-aweb: grant ${id} was not revoked (${e.message || e}); it still expires at ${meta.identity?.grant?.expiresAt || "its TTL"}` }, 1);
374
+ }
375
+ }
234
376
  const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
235
377
  /** The alias a home's .aw/workspace.yaml records under memberships (indented),
236
378
  * or undefined. Read only when the hook has no alias of its own. */
@@ -357,7 +499,7 @@ function retainedSeatSpawn(source, takeOver) {
357
499
  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`);
358
500
  if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
359
501
  out({
360
- meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, ...(takenOver ? { tookOverFrom: takenOver } : {}) },
502
+ meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team, address: shownAddress || expectedAddress || null }), ...(takenOver ? { tookOverFrom: takenOver } : {}) },
361
503
  ...(env ? { env } : {}),
362
504
  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).`,
363
505
  ...(launch ? { launch } : {}),
@@ -370,7 +512,9 @@ function retainedSeatSpawn(source, takeOver) {
370
512
  }
371
513
 
372
514
  if (event === "spawn") {
373
- if (settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
515
+ if (identityMode === "global" && identitySettings.source) fatal('identity.mode "global" cannot be combined with identity.source; use identity.mode "local" with identity.source for a retained seat, or identity.mode "global" with identity.resident for a resident grant');
516
+ if (identityMode === "global") globalGrantSpawn();
517
+ if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
374
518
  let minted; // external identity, once `aw team join` succeeds
375
519
  const root = awebRoot();
376
520
  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`);
@@ -380,7 +524,9 @@ if (event === "spawn") {
380
524
  // team happens to be active at mint time — and verify the joined cert matches.
381
525
  // The instance name IS the discoverable alias (the team roster doubles as the
382
526
  // cross-machine instance directory).
383
- let team = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME;
527
+ const resolvedTeam = payloadTeam();
528
+ let team = resolvedTeam.team;
529
+ const teamPayloadMismatch = resolvedTeam.payload && resolvedTeam.env && resolvedTeam.payload !== resolvedTeam.env;
384
530
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
385
531
  if (!team) fatal("cannot determine target team (no config team block, no active team at root), so no identity could be minted — set a team: block in oats-config.yaml, or activate a team at the aweb root");
386
532
  // A bare team name (no namespace) resolves against the root's memberships.
@@ -437,7 +583,7 @@ if (event === "spawn") {
437
583
  run(["aw", "init", "--do-not-touch-agents-md"], home);
438
584
  const alias = joined.alias;
439
585
  const mismatch = joined.team_id !== team
440
- ? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : "";
586
+ ? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : teamPayloadMismatch ? ` [WARNING: settings team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team]` : "";
441
587
  // Runtime integration: for Claude Code sessions the aweb-channel plugin
442
588
  // carries real-time push events. This hook does NOT install it. The plugin
443
589
  // is a DECLARED runtime requirement (oats.json), consented once at
@@ -458,11 +604,11 @@ if (event === "spawn") {
458
604
  ? ` 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.`
459
605
  : "";
460
606
  out({
461
- meta: { team: joined.team_id, alias, delivery: deliveryMode },
607
+ meta: { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) },
462
608
  ...(env ? { env } : {}),
463
609
  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.`,
464
610
  ...(launch ? { launch } : {}),
465
- ...(mismatch ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : channelWarning ? { warning: channelWarning } : {}),
611
+ ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : teamPayloadMismatch ? { warning: `oats-aweb: settings.oats.aweb.team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team` } : channelWarning ? { warning: channelWarning } : {}),
466
612
  });
467
613
  } catch (e) {
468
614
  // A join may already have created a REMOTE identity before the failure.
@@ -480,6 +626,7 @@ if (event === "spawn") {
480
626
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
481
627
  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}` : ""}` });
482
628
  }
629
+ if (meta.identity?.mode === "global") globalGrantRetire(meta);
483
630
  // No alias means the spawn hook never reported an identity: nothing exists to
484
631
  // undo, which is completion. An alias WITH no local `.aw` is the opposite —
485
632
  // the remote record exists and its key is gone, so the self-delete cannot be
@@ -4,6 +4,13 @@ Your messaging layer is **aweb**. You have (or will be minted) a team-scoped
4
4
  aweb identity — alias = your instance name — on your deployment's team (see
5
5
  `instance.json` / your TASK.md briefing for the team).
6
6
 
7
+ Some instances serve a resident global identity through an expiring session
8
+ grant instead of holding their own root keys. In that mode, your TASK.md and
9
+ `instance.json` identify the resident alias, scopes, and expiry; root keys are
10
+ not in your home, and mint/revoke/join/identity-lifecycle commands are not yours
11
+ to run. If `aw mail` or `aw chat` says the grant expired or was revoked, stop
12
+ messaging and report the condition to your coordinator/human.
13
+
7
14
  **Load the skills at the right moments — do not work from memory:**
8
15
 
9
16
  - **Before your first `aw mail`/`aw chat` of a session**, load the
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.aweb",
3
3
  "command": "aweb",
4
- "version": "1.11.2",
4
+ "version": "1.12.0",
5
5
  "compatibility": {
6
6
  "oats": ">=0.24.4"
7
7
  },
@@ -124,7 +124,8 @@
124
124
  "retire": "bin/oats-aweb.mjs retire"
125
125
  },
126
126
  "environment": [
127
- "AWEB_DELIVERY"
127
+ "AWEB_DELIVERY",
128
+ "AWEB_IDENTITY_HOME"
128
129
  ],
129
130
  "settings": {
130
131
  "delivery": {
@@ -134,6 +135,18 @@
134
135
  "session"
135
136
  ],
136
137
  "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."
138
+ },
139
+ "team": {
140
+ "description": "Target aweb team id for identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID/OATS_TEAM_NAME; if both are set and differ the hook warns and uses this setting."
141
+ },
142
+ "identity": {
143
+ "default": {
144
+ "mode": "local"
145
+ },
146
+ "description": "Identity selection. mode=local (default) mints an instance-local team identity or uses the existing identity.source retained-seat flow. mode=global acts as a named resident identity through a session grant; requires identity.resident and optional scopes/ttl."
147
+ },
148
+ "residents": {
149
+ "description": "Host-owned map for global mode: resident name to absolute custody directory whose .aw holds the resident root keys and team certificate. Put this only in oats-local.yaml settings.oats.aweb.residents; committed workspace or soul files must never carry custody paths."
137
150
  }
138
151
  },
139
152
  "environmentNamespaces": [
@@ -4,7 +4,7 @@ import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fa
4
4
  import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
5
5
  import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, views } from '../lib/sources.mjs';
6
6
  import { runSource, complete, retry, readRun, requireQualifiedHelper } from '../lib/worker.mjs';
7
- import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource } from '../lib/migration.mjs';
7
+ import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource, forgetMigration } from '../lib/migration.mjs';
8
8
  import { inspect } from '../lib/inspection.mjs';
9
9
  import { loadInvocationKnowledgeBinding } from '../lib/binding-wire.mjs';
10
10
  import { loadCapturedOkfInvocation, loadOkfSourceReceiptInput, assertOkfInvocationAction, requireOkfAdmittedAction, assertOkfSourceContext, assertOkfRegisteredSourceReplay } from '../lib/invocation-context.mjs';
@@ -20,6 +20,7 @@ oats okf init --base ALIAS --nodes FILE [--output PATH | --confirm] [--json]
20
20
  oats okf migrate --legacy PATH --base ALIAS --node NODE --output PATH [--json]
21
21
  oats okf migrate --deliver FILE | --cutover FILE --soul-dir PATH [--json]
22
22
  oats okf migrate --source-home PATH [--json]
23
+ oats okf migrate --forget ID [--json]
23
24
  oats okf unlock --lock PATH --token TOKEN [--json]
24
25
  Captured workers use oats operation run knowledge:harvest with SOURCE --deployment/--resolution/--home,
25
26
  --arg native-request=ABS_BACKEND_ONLY_JSON and optional --arg worker-mode=prepare|launch.
@@ -167,6 +168,7 @@ else {
167
168
  } else if(event==='init') result=initBase(loadBindings(),flags.base,flags.nodes,flags.output,{confirm:!!flags.confirm});
168
169
  else if(event==='migrate') {
169
170
  if(flags['source-home']) result=migrateSource(loadBindings(),flags['source-home']);
171
+ else if(flags.forget) result=forgetMigration(loadBindings(),flags.forget);
170
172
  else if(flags.deliver) result=deliverMigration(resolve(flags.deliver));
171
173
  else if(flags.cutover) result=cutoverMigration(resolve(flags.cutover),flags['soul-dir']);
172
174
  else result=migrate(loadBindings(),{legacy:flags.legacy,alias:flags.base,node:flags.node,output:flags.output});
@@ -7,12 +7,16 @@ function keys(value, allowed, label, code='E_CONFIG') {
7
7
  }
8
8
  export function settings() {
9
9
  const s = JSON.parse(process.env.OATS_SETTINGS || '{}');
10
- keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model'],'OATS_SETTINGS');
10
+ keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout'],'OATS_SETTINGS');
11
+ if(s['git-timeout']!==undefined && (!Number.isInteger(s['git-timeout']) || s['git-timeout']<1)) fail('E_CONFIG','git-timeout must be a positive integer number of seconds');
11
12
  if(s['state-dir']!==undefined && (typeof s['state-dir']!=='string' || !isAbsolute(s['state-dir']) || resolve(s['state-dir'])!==s['state-dir'])) fail('E_CONFIG','state-dir must be a normalized absolute path');
12
13
  if(s['harvest-runtime']!==undefined && !['pi','claude','codex'].includes(s['harvest-runtime'])) fail('E_CONFIG','invalid harvest-runtime');
13
14
  if(s['harvest-model']!==undefined && (typeof s['harvest-model']!=='string' || !s['harvest-model'].trim())) fail('E_CONFIG','harvest-model must be a nonempty string');
14
15
  return s;
15
16
  }
17
+ /** Time budget for Git operations that talk to a remote (clone, fetch, push,
18
+ * ls-remote). Local object reads keep the short exec default. */
19
+ export function gitTimeoutMs() { return (settings()['git-timeout'] ?? 600)*1000; }
16
20
  export function noGit(path) {
17
21
  for (let p = safePath(path); ; p = dirname(p)) {
18
22
  if (fs.existsSync(join(p, '.git')) || (fs.existsSync(join(p, 'HEAD')) && fs.existsSync(join(p, 'objects')) && fs.existsSync(join(p, 'refs')))) fail('E_DIRECTORY_GIT', `directory store is in Git custody: ${p}; use kind git`);
@@ -70,7 +70,7 @@ export function unlock(path, token) {
70
70
  try { process.kill(o.pid, 0); fail('E_LOCKED', 'lock owner is still alive'); } catch (e) { if(e.code !== 'ESRCH') throw e; }
71
71
  fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); return { unlocked: path };
72
72
  }
73
- export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
73
+ export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_SOUL_ID','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
74
74
  export function cleanEnv(env = process.env) {
75
75
  return Object.fromEntries(Object.entries(env).filter(([k]) => !/^(OATS_(?!HOME_DIR$|PACKAGE_CATALOG$)|PI_AGENT|GIT_)/.test(k)));
76
76
  }
@@ -30,10 +30,16 @@ export function migrate(bindings,{legacy,alias,node,output}) {
30
30
  if(paths.some(path=>overlaps(path,output))) fail('E_PATH','migration stage overlaps configured accepted base or coordination artifacts');
31
31
  }
32
32
  const original=tree(legacy);
33
+ // Stage the accepted base before any record exists: a base that cannot be
34
+ // read leaves nothing behind. From the record on, a failure is recorded in
35
+ // it rather than left as an unexplained directory.
36
+ const stage=stageBase(base,output);
33
37
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
34
38
  const id=randomUUID();const dir=join(bindings.stateDir,'migrations',id);fs.mkdirSync(dir,{recursive:true,mode:0o700});
35
39
  save(join(dir,'legacy.json'),original); // byte-preserving backup BEFORE any delivery
36
- const stage=stageBase(base,output); const spec=stage.meta.nodes[node];if(!spec) fail('E_OWNER','migration node must be explicitly provisioned first');
40
+ const recordFile=join(dir,'migration.json');
41
+ try {
42
+ const spec=stage.meta.nodes[node];if(!spec) fail('E_OWNER','migration node must be explicitly provisioned first');
37
43
  const prefix=spec.path+'/';
38
44
  if(Object.keys(stage.files).some(p=>p.startsWith(prefix) && ![prefix+'index.md',prefix+'log.md'].includes(p))) fail('E_MIGRATION','target node is not empty; merge migration requires human judgment');
39
45
  const after={...stage.files};for(const p of Object.keys(after)) if(p.startsWith(prefix)) delete after[p];
@@ -48,8 +54,23 @@ export function migrate(bindings,{legacy,alias,node,output}) {
48
54
  // Replace only the empty pre-provisioned node, not unrelated accepted bytes.
49
55
  fs.rmSync(join(stage.root,spec.path),{recursive:true});materialize(stage.root,after);validateBase(stage.root,base);
50
56
  const proposalFile=join(dir,'proposal.json');const proposal={version:1,run:id,created:new Date().toISOString(),file:proposalFile,before:stage.files,after};save(proposalFile,proposal);
51
- const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(join(dir,'migration.json'),record);
52
- return {status:'staged',migration:join(dir,'migration.json'),originalPreserved:true,stage:stage.root};
57
+ const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(recordFile,record);
58
+ return {status:'staged',migration:recordFile,originalPreserved:true,stage:stage.root};
59
+ } catch(e) {
60
+ save(recordFile,{version:1,id,alias,node,base,legacy,originalDigest:digest(original),receipt:{status:'failed',error:e.message,code:e.code||'E_MIGRATION',at:new Date().toISOString()}});
61
+ throw e;
62
+ }
63
+ }
64
+ /** Remove a migration record that never delivered anything (staged or failed).
65
+ * The backup it holds is the legacy bundle, which still exists at its source;
66
+ * a delivered record is custody and stays. */
67
+ export function forgetMigration(bindings,id) {
68
+ identifier(id);const dir=join(bindings.stateDir,'migrations',id);const file=join(dir,'migration.json');
69
+ if(!fs.existsSync(dir)) fail('E_MIGRATION',`unknown migration ${id}`);
70
+ const status=fs.existsSync(file)?readJSON(file).receipt?.status:'incomplete';
71
+ if(!['failed','staged','incomplete'].includes(status)) fail('E_MIGRATION',`migration ${id} is ${status}: a delivered migration is custody and cannot be forgotten`);
72
+ fs.rmSync(dir,{recursive:true,force:true});
73
+ return {status:'forgotten',id,was:status};
53
74
  }
54
75
  export function deliverMigration(file) {
55
76
  safePath(file);const m=readJSON(file);const proposal=readJSON(m.proposal);if(hash(proposal)!==m.proposalHash) fail('E_MIGRATION','migration proposal changed');
@@ -195,6 +195,7 @@ export function register(home) {
195
195
  const soul=fs.realpathSync(process.env.OATS_SOUL || join(home,'soul'));
196
196
  const work=fs.existsSync(join(home,'work'))?fs.realpathSync(join(home,'work')):join(home,'work');
197
197
  const decl=declaration(soul);
198
+ const soulId=process.env.OATS_SOUL_ID || null;
198
199
  const bindings=loadBindings(undefined,{sourceHome:home,sourceWork:work});
199
200
  const context=fs.realpathSync(process.env.OATS_CONTEXT || meta.repo || fail('E_CONFIG','source requires durable config context'));
200
201
  if(overlaps(home,context) && context.startsWith(home)) fail('E_PATH','config context cannot be in disposable home');
@@ -203,11 +204,7 @@ export function register(home) {
203
204
  if(!agent || !instance) fail('E_SOURCE','source instance/agent required');
204
205
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
205
206
  const ownersFile=join(bindings.stateDir,'owners.json');
206
- withLock(join(bindings.stateDir,'owners.lock'),()=>{
207
- const owners=fs.existsSync(ownersFile)?readJSON(ownersFile):{};
208
- if(Object.hasOwn(owners,decl.owner) && owners[decl.owner]!==soul) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
209
- owners[decl.owner]=soul;save(ownersFile,owners);
210
- });
207
+ pinOwner(ownersFile,decl.owner,{id:soulId,soulName:agent,path:soul});
211
208
  const id=randomUUID(); const dir=join(bindings.stateDir,'sources',id);
212
209
  // Copy only the role document, never instance.json wholesale, launch recipes,
213
210
  // environment, credentials, source worktree, or third-party message stores.
@@ -235,6 +232,24 @@ export function register(home) {
235
232
  }
236
233
  return finishRegistration({...source,file});
237
234
  }
235
+ /** Pin a stable owner id to the soul it identifies. The kernel names a soul by
236
+ * identity (OATS_SOUL_ID: repository key plus soul name) so the pin survives
237
+ * the per-commit soul copies a workspace deployment materializes; a classic
238
+ * soul keeps the resolved path. A row written by an earlier version as a path
239
+ * under agents/<same soul name>/(soul|souls/<commit>) is rewritten to the
240
+ * identity once; any other mismatch is a different soul and is refused. */
241
+ export function pinOwner(ownersFile,owner,{id,soulName,path}) {
242
+ const value=id || path;
243
+ return withLock(join(dirname(ownersFile),'owners.lock'),()=>{
244
+ const owners=fs.existsSync(ownersFile)?readJSON(ownersFile):{};
245
+ if(!obj(owners)) fail('E_OWNER','invalid owner registry');
246
+ const prior=Object.hasOwn(owners,owner)?owners[owner]:undefined;
247
+ const samePath=typeof prior==='string' && new RegExp(`/agents/${soulName.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}/(soul|souls/[^/]+)$`).test(prior);
248
+ if(prior!==undefined && prior!==value && !(id && samePath)) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
249
+ if(prior!==value) {owners[owner]=value;save(ownersFile,owners);}
250
+ return value;
251
+ });
252
+ }
238
253
  function enqueue(source,status,payload) {
239
254
  const id=hash(payload);const path=join(dirname(source.file),'inputs',`${id}.json`);
240
255
  if(!fs.existsSync(path)) save(path,payload);
@@ -246,19 +261,35 @@ export function input(source,id) {
246
261
  const value=readJSON(join(dirname(source.file),'inputs',`${id}.json`));
247
262
  if(hash(value)!==id) fail('E_INPUT','durable evidence hash mismatch');return value;
248
263
  }
249
- /** Switch a retired source's job off once nothing is pending. Idempotent; a
250
- * scheduler failure is recorded, never thrown — the evidence is already safe. */
264
+ /** Take a drained, retired source's job out of the kernel scheduler. The job is
265
+ * first switched off, then removed: the definition it carried is kept as
266
+ * evidence in this source's own schedule.json, so nothing about the run is
267
+ * lost, and `oats schedule list` stops accumulating dead okf-<id> rows. A job
268
+ * that is still running (or has unresolved effects) stays disabled and is
269
+ * removed on the worker's next settle. Idempotent; a scheduler failure is
270
+ * recorded, never thrown — the evidence is already safe. */
251
271
  export function settleRetiredSchedule(source) {
252
272
  const status=loadStatus(source);
253
- if(status.schedule?.settled===true) return {status:'already-disabled',id:status.schedule.id};
273
+ if(status.schedule?.removed===true) return {status:'already-removed',id:status.schedule.id};
254
274
  if(!status.retired || status.auto || status.schedule?.id===undefined) return {status:'kept'};
275
+ const id=status.schedule.id;
276
+ const mark=(patch)=>updateStatus(source,current=>{current.schedule={...current.schedule,...patch};});
277
+ try {
278
+ if(status.schedule.settled!==true) {
279
+ oats(['schedule','disable',id,'--dir',source.context,'--json'],source.context);
280
+ mark({settled:true,settledAt:new Date().toISOString(),settleError:undefined});
281
+ }
282
+ } catch(e) {
283
+ if(e.code!=='E_SCHEDULE_UNKNOWN') {mark({settled:false,settleError:e.message});return {status:'disable-failed',id};}
284
+ }
255
285
  try {
256
- oats(['schedule','disable',status.schedule.id,'--dir',source.context,'--json'],source.context);
257
- updateStatus(source,current=>{current.schedule={...current.schedule,settled:true,settledAt:new Date().toISOString()};});
258
- return {status:'disabled',id:status.schedule.id};
286
+ oats(['schedule','remove',id,'--dir',source.context,'--json'],source.context);
287
+ mark({removed:true,removedAt:new Date().toISOString(),removeError:undefined});
288
+ return {status:'removed',id};
259
289
  } catch(e) {
260
- updateStatus(source,current=>{current.schedule={...current.schedule,settled:false,settleError:e.message};});
261
- return {status:'disable-failed',id:status.schedule.id};
290
+ if(e.code==='E_SCHEDULE_UNKNOWN') {mark({removed:true,removedAt:new Date().toISOString(),removeError:undefined});return {status:'already-removed',id};}
291
+ mark({removed:false,removeError:e.message});
292
+ return {status:e.code==='E_SCHEDULE_RUNNING'?'disabled-pending-removal':'remove-failed',id};
262
293
  }
263
294
  }
264
295
  export function capture(source,{final=false,deadlineMs=85000}={}) {
@@ -336,8 +367,9 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
336
367
  if(final) {
337
368
  status.retired=true;status.retiredAt=new Date().toISOString();
338
369
  // A retired source whose every captured input is already processed has
339
- // no further work: its schedule is switched off now (never deleted —
340
- // the job definition stays as evidence). Anything still pending keeps
370
+ // no further work: its schedule is switched off and removed from the
371
+ // kernel scheduler (settleRetiredSchedule); the definition stays as
372
+ // evidence in this source's schedule.json. Anything still pending keeps
341
373
  // the job enabled until the worker drains it (see worker.mjs).
342
374
  const drained=status.captured.inputs.every(id=>status.processed.includes(id));
343
375
  status.auto=status.auto && !noLaunch && !drained;
@@ -349,10 +381,14 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
349
381
  });
350
382
  }
351
383
  export function scheduleSource(source) {
384
+ // A retired, drained source whose job was already taken out of the scheduler
385
+ // has nothing left to run: do not recreate the job (retire is re-entrant).
386
+ const current=loadStatus(source);
387
+ if(current.retired && current.schedule?.removed===true) return current.schedule.result;
352
388
  const captured=source.registration?.schemaVersion===1 && source.registration.kind==='captured';
353
389
  const argv=captured?['oats','okf','run-source','--source',source.file,'--deployment',source.executionBinding.deployment,'--resolution',source.executionBinding.resolution.id,'--json']
354
390
  :['oats','okf','run-source','--source',source.file,'--soul',source.agent,'--json'];
355
- const spec={id:`okf-${source.id}`,kind:'command',enabled:loadStatus(source).auto,cron:source.bindings.cron,tz:source.bindings.tz,cwd:source.context,argv,
391
+ const spec={id:`okf-${source.id}`,kind:'command',enabled:current.auto,cron:source.bindings.cron,tz:source.bindings.tz,cwd:source.context,argv,
356
392
  ...(captured?{definitionVersion:2,recurrencePolicy:'capture',responsibleHuman:source.responsibleHuman}: {})};
357
393
  const file=join(dirname(source.file),'schedule.json');
358
394
  try {
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, exec, cleanEnv, fail, relPath, overlaps, resolve } from './io.mjs';
6
- import { metadata, noGit } from './config.mjs';
6
+ import { metadata, noGit, gitTimeoutMs } from './config.mjs';
7
7
  const validator = fileURLToPath(new URL('../skills/okf/scripts/okf-validate.mjs', import.meta.url));
8
8
  // Never let local replace refs reinterpret frozen OIDs, including inside Git's
9
9
  // transport subprocesses. Override even an explicitly supplied command env.
@@ -17,11 +17,19 @@ export function validateBase(root, base) {
17
17
  if(result.errors.length || result.warnings.length) fail('E_VALIDATION', [...result.errors,...result.warnings].join('; '));
18
18
  return {files,meta,digest:digest(files)};
19
19
  }
20
- function rawBlob(cwd,oid) {
21
- const result=spawnSync('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-C',cwd,'cat-file','blob',oid],{cwd,env:gitEnv(),timeout:30000,maxBuffer:16*1024*1024});
20
+ /** Write a blob straight to a file descriptor: no in-memory buffer, so object
21
+ * size never limits what a base may hold. The remote budget applies because a
22
+ * partial clone fetches a missing blob on its first read. */
23
+ function writeBlob(cwd,oid,target,mode) {
24
+ const fd=fs.openSync(target,'wx',mode);
25
+ let result; try { result=spawnSync('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-C',cwd,'cat-file','blob',oid],{cwd,env:gitEnv(),timeout:gitTimeoutMs(),stdio:['ignore',fd,'pipe']}); } finally { fs.closeSync(fd); }
22
26
  if(result.error || result.status!==0) fail('E_COMMAND','Git object read failed');
23
- return result.stdout;
27
+ fs.chmodSync(target,mode);
24
28
  }
29
+ /** A tree entry the store never materializes: outside the knowledge base root.
30
+ * Its absence from a staging tree is by construction; its presence is a
31
+ * worker's doing and is judged like any other change. */
32
+ const outsideBase=(base,p)=>!(base.root==='.' || p===base.root || p.startsWith(base.root+'/'));
25
33
  function materializeGitObjects(base,dest,head) {
26
34
  immutableCommit(dest,head);
27
35
  const entries=gitTreeEntries(dest,head);
@@ -35,20 +43,32 @@ function materializeGitObjects(base,dest,head) {
35
43
  // Index/HEAD updates do not apply content filters. They preserve the normal
36
44
  // Git worktree needed by existing diff/publication guards without checkout.
37
45
  git(dest,['read-tree',head]);git(dest,['update-ref','--no-deref','HEAD',head]);
46
+ // Only the knowledge base is materialized: the index carries the whole tree
47
+ // for publication, but bytes outside the base root are never read, so the
48
+ // size of the rest of the repository does not matter. The scope checks know
49
+ // that an entry outside the root is expected to be absent (see outsideBase).
38
50
  for(const [p,entry] of entries) {
51
+ if(outsideBase(base,p)) continue;
39
52
  const target=safePath(join(dest,p));fs.mkdirSync(dirname(target),{recursive:true});
40
- if(entry.mode==='160000') {fs.mkdirSync(target);continue;}
41
- const bytes=rawBlob(dest,entry.oid);
42
- if(entry.mode==='120000') fs.symlinkSync(bytes.toString('utf8'),target);
43
- else {fs.writeFileSync(target,bytes,{flag:'wx',mode:entry.mode==='100755'?0o755:0o644});fs.chmodSync(target,entry.mode==='100755'?0o755:0o644);}
53
+ writeBlob(dest,entry.oid,target,entry.mode==='100755'?0o755:0o644);
44
54
  }
45
55
  }
46
56
  function clone(base, dest, selectedHead) {
47
57
  safePath(dest);
48
58
  if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
49
59
  fs.mkdirSync(dirname(dest),{recursive:true});
50
- git(dirname(dest),['clone','--no-hardlinks','--no-checkout','--',base.repository,dest]);
51
- git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`]);
60
+ // Fetch only what the store reads: the accepted branch, trees now and blobs
61
+ // on demand. Only a remote that does not offer object filtering gets a plain
62
+ // single-branch clone instead; any other failure is the failure it is. The
63
+ // ancestry the publication checks walk is present either way.
64
+ const cloneArgs=['clone','--no-hardlinks','--no-checkout','--single-branch','--branch',base.acceptedBranch];
65
+ try { git(dirname(dest),[...cloneArgs,'--filter=blob:none','--',base.repository,dest],{timeout:gitTimeoutMs()}); }
66
+ catch(e) {
67
+ if(e.code!=='E_COMMAND' || !/filter/i.test(e.message)) throw e;
68
+ fs.rmSync(dest,{recursive:true,force:true});
69
+ git(dirname(dest),[...cloneArgs,'--',base.repository,dest],{timeout:gitTimeoutMs()});
70
+ }
71
+ git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
52
72
  const head=selectedHead ?? git(dest,['rev-parse','FETCH_HEAD']);
53
73
  verifyRemote(base,dest);
54
74
  materializeGitObjects(base,dest,head);
@@ -142,7 +162,9 @@ function withVerificationIndex(cwd,fn) {
142
162
  }
143
163
  export function verifyGitScope(base, dest, baseline, { checkModes = true } = {}) {
144
164
  return withVerificationIndex(dest,read=>{
145
- const names=read(['diff','--name-only','-z',baseline,'--']).split('\0').filter(Boolean);
165
+ // Entries outside the root are never materialized, so their absence is
166
+ // not a deletion; a present outside file that differs still is a change.
167
+ const names=read(['diff','--name-only','-z',baseline,'--']).split('\0').filter(Boolean).filter(p=>!(outsideBase(base,p) && !fs.existsSync(join(dest,p))));
146
168
  // A restored working file can conceal a staged, unauthorized index entry.
147
169
  names.push(...read(['diff','--cached','--name-only','-z',baseline,'--']).split('\0').filter(Boolean));
148
170
  names.push(...read(['ls-files','--others','--exclude-standard','-z']).split('\0').filter(Boolean));
@@ -303,7 +325,7 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
303
325
  const baseline=immutableCommit(cwd,stage.head);
304
326
  verifyPublicationTree(base,cwd,baseline.tree,baseline.tree,proposal.before);
305
327
  // Baseline must still be accepted. Never rebase model output without rejudging it.
306
- git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`]);
328
+ git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
307
329
  const accepted=git(cwd,['rev-parse','FETCH_HEAD']);
308
330
  if(accepted!==stage.head) {
309
331
  // A known or uncertain previously created PR may be reconciled, but a
@@ -350,13 +372,13 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
350
372
  const publication=immutableCommit(cwd,receipt.commit);
351
373
  if(publication.parents.length!==1 || publication.parents[0]!==stage.head) fail('E_CONFIRM','publication commit must have exactly the frozen baseline as its parent');
352
374
  verifyPublicationTree(base,cwd,baseline.tree,publication.tree,proposal.after,proposal.before);
353
- const remoteTip=()=>git(cwd,['ls-remote','--heads','origin',`refs/heads/${branch}`]).split(/\s/)[0] || null;
375
+ const remoteTip=()=>git(cwd,['ls-remote','--heads','origin',`refs/heads/${branch}`],{timeout:gitTimeoutMs()}).split(/\s/)[0] || null;
354
376
  let tip=remoteTip();
355
377
  if(tip && tip!==receipt.commit) fail('E_PR','publication branch has unexpected commit; never force push');
356
378
  if(tip!==receipt.commit) {
357
379
  beforePublish();
358
380
  receipt.status='push-intent'; persist();
359
- try { verifyRemote(base,cwd);git(cwd,['push','--no-follow-tags','--recurse-submodules=no','origin',`${receipt.commit}:refs/heads/${branch}`]); }
381
+ try { verifyRemote(base,cwd);git(cwd,['push','--no-follow-tags','--recurse-submodules=no','origin',`${receipt.commit}:refs/heads/${branch}`],{timeout:gitTimeoutMs()}); }
360
382
  catch(e) { receipt.status='push-unknown'; receipt.error=e.message; persist(); throw e; }
361
383
  tip=remoteTip(); if(tip!==receipt.commit) {receipt.status='push-unknown';persist();fail('E_CONFIRM','pushed head not confirmed');}
362
384
  }
@@ -375,7 +397,7 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
375
397
  const pr=matching[0];receipt.pr=pr;receipt.status='delivered';receipt.deliveredAt ||= new Date().toISOString();persist();
376
398
  if(pr.state==='CLOSED' && !pr.mergedAt) {receipt.status='rejected';persist();fail('E_PR','PR closed without merge; retained proposal requires operator review');}
377
399
  if(pr.mergedAt) {
378
- git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`]);
400
+ git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
379
401
  const merge=pr.mergeCommit?.oid;
380
402
  if(!merge) fail('E_CONFIRM','merged PR lacks merge commit');
381
403
  git(cwd,['merge-base','--is-ancestor',merge,'FETCH_HEAD']);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.okf",
3
3
  "command": "okf",
4
- "version": "2.1.3",
4
+ "version": "2.1.4",
5
5
  "compatibility": {
6
6
  "oats": ">=0.24.4"
7
7
  },
@@ -26,6 +26,9 @@
26
26
  },
27
27
  "state-dir": {
28
28
  "description": "Absolute host-owned durable state directory for portable bindings. It is never derived from an instance home."
29
+ },
30
+ "git-timeout": {
31
+ "description": "Seconds allowed for each Git operation that talks to a remote (clone, fetch, push, ls-remote); default 600. Local object reads keep a short fixed limit. Raise it for large repositories behind slow links."
29
32
  }
30
33
  },
31
34
  "agents": [
@@ -121,8 +121,9 @@ captured ProviderBinding as execution authority:
121
121
  `payload.bindings`. Workspace, adoption, and operator values remain separate
122
122
  inputs to the shared resolver; OKF does not select their precedence.
123
123
  - Durable placement is explicit selected settings: physical absolute
124
- `bindings-file` and `state-dir`, plus selected `harvest-runtime` and optional
125
- `harvest-model`. Never derive state from an instance home.
124
+ `bindings-file` and `state-dir`, plus selected `harvest-runtime`, optional
125
+ `harvest-model` and optional `git-timeout` (seconds for remote Git
126
+ operations, default 600). Never derive state from an instance home.
126
127
  - Provider codecs run only after exact retained executable approval. Their
127
128
  populated binding is not proof of readiness, enrollment, credentials, privacy
128
129
  or publication authority. Respect typed non-ready results.
@@ -262,6 +262,17 @@ return `meta`, `brief`, `warning`, or runtime-specific `launch` arguments. A
262
262
  **spawn hook only** may also return an `env` object for the launched process;
263
263
  returning `env` from retire or soul-scaffold is an explicit contract error.
264
264
 
265
+ A **launch hook** runs at every start and restart of a home for each provider
266
+ captured at spawn (under its captured settings). Its `launch` arguments and
267
+ `env` replace that provider's previous contribution whole. Its `meta`, when
268
+ returned, replaces that provider's entry in `instance.json.capabilityMeta`
269
+ after the start succeeds — the same record the spawn hook wrote and the retire
270
+ hook later reads as `OATS_META` — so a provider that re-issues a credential at
271
+ start (a renewed session grant, for example) leaves the CURRENT one on record. A
272
+ launch hook that answers without `meta` keeps its previous entry; a start whose
273
+ preparation fails changes nothing. (Kernel ≥ 0.25.5; earlier kernels collected
274
+ launch `meta` and discarded it.)
275
+
265
276
  Hook environment values are strings, at most 8192 UTF-8 bytes, with no NUL or
266
277
  newlines. Names use the portable environment grammar and must belong to an
267
278
  unambiguous vendor namespace. Only a dotted capability ID participates: its
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
4
4
 
5
- **Last update:** 2026-09-23 20:00Z · **0.25.0–0.25.3 PUBLISHED** (workspace model A–C; team-review fixes; operator-rebuild round; OATS_SOUL_ID) · **OKF 2.1.4 pending human GO** (owner pin by id, clone/timeout, retire-schedule seam; Antares writes the PRs) · Phase D plan next · **Desktop engineer paused by the human** (10B-0 uncommitted foundation preserved; resume is the human's call; 0.24.14 to be cut from a maintenance branch off `e5cdaf95` when its PR lands) · parity pipeline ⏸.
5
+ **Last update:** 2026-09-23 20:00Z · **0.25.0–0.25.4 PUBLISHED** (workspace model A–C; team-review fixes; operator-rebuild round; OATS_SOUL_ID; quarantine-retry fix) · **OKF 2.1.4 content complete on oats-okf main, tag pending human GO** · **oats.aweb 1.12.0 (PR #107) in rehearsal round 4; decision 27 (per-spawn identity) proposed to the human** · Phase D plan next · **Desktop engineer paused by the human** (10B-0 uncommitted foundation preserved; resume is the human's call; 0.24.14 to be cut from a maintenance branch off `e5cdaf95` when its PR lands) · parity pipeline ⏸.
6
6
 
7
7
  Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
8
8
 
@@ -565,6 +565,18 @@ is a member; a soul that lives in it may need a work clone). Under an explicit
565
565
  `oats-local.yaml` `standalone:` header the next steps say the view is standalone
566
566
  and list only that repo.
567
567
 
568
+ ### 0.25.5 — launch-hook `meta` is persisted
569
+
570
+ `runLifecycleHooks("launch")` collected each capability's `meta` and the
571
+ start/restart path discarded it (only `contributions` and `env` were consumed).
572
+ From 0.25.5 a successful start merges `res.meta` per capability into
573
+ `instance.json.capabilityMeta` — the record spawn writes and retire reads as
574
+ `OATS_META`. A hook answering without `meta` keeps its prior entry; a failed
575
+ launch preparation writes nothing. No new field, flag or hook event; this is
576
+ the documented hook return finally honoured (decision 27, K3′). Driver: a
577
+ provider renewing a session grant at every start would otherwise leave the
578
+ original grant id on record and retire would revoke the wrong grant.
579
+
568
580
  ### 0.25.3 — `OATS_SOUL_ID` (stable soul identity for providers)
569
581
 
570
582
  The per-commit soul cache (0.25.1, M1) made `realpath(<home>/soul)` change with every
@@ -85,10 +85,11 @@ can spawn. `owns`/`reads` are responsibility/context, not ACLs. See
85
85
  [knowledge](knowledge.md) for the **prepared** version scope, provisioning and
86
86
  commands, and [migration](knowledge-migration.md) before updating v1.
87
87
 
88
- **`oats.aweb`** fills `messaging`: mints an instance identity at spawn,
89
- removes it at retire, contributes the aweb messaging and team skills, wires
90
- the channel plugin so sessions are woken by mail, and exposes
91
- `oats aweb roster` and `oats aweb setup`. Requires the `aw` CLI.
88
+ **`oats.aweb`** fills `messaging`: mints an instance identity at spawn (local
89
+ mode) or grants an instance an expiring session as a resident global identity
90
+ (global mode), removes or revokes it at retire, contributes the aweb messaging
91
+ and team skills, wires the channel plugin so sessions are woken by mail, and
92
+ exposes `oats aweb roster` and `oats aweb setup`. Requires the `aw` CLI.
92
93
 
93
94
  **`oats.jira`** fills `tasks`: the `jira-tasks` protocol and an advisory
94
95
  spawn hook. Requires `acli`; settings commonly include `site` and `project`.
@@ -135,6 +136,9 @@ remove it on `retire`; supply the roster; teach send, reply, chat, and "read
135
136
  the event first" in the inject and skill; contribute launch arguments so the
136
137
  session is woken; enforce the soul type's `reach` on both sides; state
137
138
  whether the address outlives the instance; and keep task coordination out.
139
+ Any messaging provider emits `identity: { mode, alias, team, address|null,
140
+ resident|null, grant?: { id, expiresAt, scopes } }` in its spawn meta;
141
+ `oats.aweb` is the reference implementation.
138
142
 
139
143
  **Tasks.** Teach claim, update, block, hand off, and complete; identify the
140
144
  instance to the tracker in a way that survives it; keep conversation out.
@@ -197,12 +201,57 @@ warning naming the fresh-purpose remedy. On an older `aw` the pre-1.36.1
197
201
  report stands (`aliasReusable: false`, warning naming aweb-abim), because
198
202
  that CLI cannot revoke the certificate.
199
203
 
200
- ## oats.aweb settings (1.10.0)
204
+ ## oats.aweb settings (1.12.0)
201
205
 
202
206
  Set in `oats-local.yaml` under `settings.oats.aweb.<key>` (host-owned), in the
203
207
  soul's `messaging:` payload (true of every instance), or per spawn with
204
- `oats spawn … --provider oats.aweb <key>=<value>`.
205
-
208
+ `oats spawn … --provider oats.aweb <key>=<value>`. The effective payload is
209
+ merged in order: workspace messaging, `byTeam[team]`, soul messaging,
210
+ `oats-local.yaml` `settings.oats.aweb`, then per-spawn `--provider` values.
211
+ `residents` is host-file-only: put custody paths only in `oats-local.yaml`,
212
+ never in a committed workspace or soul file (current kernels document this rule
213
+ but do not yet enforce provenance in the hook payload).
214
+
215
+ - `team: <team id>`. The payload team wins over `OATS_TEAM_ID`/
216
+ `OATS_TEAM_NAME`; if both are set and differ, the hook warns and uses the
217
+ payload. Workspace v2 spawns can have an empty `OATS_TEAM_ID`, so set this in
218
+ the payload for global grants.
219
+ - `identity.mode: local | global` (default `local`). Any other value is fatal.
220
+ Local mode is the historical behavior: a spawned team identity is minted for
221
+ the instance, or `identity.source` uses the existing retained-seat flow below.
222
+ Its spawn meta includes `identity: { mode: "local", alias, team, address:
223
+ null, resident: null }` beside the existing top-level `alias`, `team`, and
224
+ `delivery` keys.
225
+ - `identity.mode: global` makes the instance act as a resident global identity
226
+ through an aweb session grant; it never mints a new global identity and never
227
+ copies root keys into the instance home. `identity.resident` is required and
228
+ resolves through `residents.<name>` to an absolute custody directory whose
229
+ `.aw/identity.yaml` already exists. Missing or unresolved residents fail with
230
+ the `oats-local.yaml settings.oats.aweb.residents.<name>` key to set. Optional
231
+ `identity.scopes` defaults to exactly `[mail.read, mail.send, chat.read,
232
+ chat.send]`; optional `identity.ttl` defaults to `8h` (aw accepts `60s` to
233
+ `720h`). Spawn runs `aw id grant mint --scope <comma-list> --ttl <ttl>
234
+ --label oats:<instance> --out <home>/.aweb-identity --json` from the custody
235
+ directory with `AWEB_IDENTITY_HOME` removed from the child environment: in aw
236
+ 1.36.1, grant commands are not identity-home-aware and intentionally refuse
237
+ both `--identity-home` and external `AWEB_IDENTITY_HOME`, so cwd selects the
238
+ custody identity. The hook parses the whole JSON document because aw `--json`
239
+ output is indented across lines, with a fallback to the first brace-prefixed
240
+ block when progress lines precede it; it then verifies the minted grant's
241
+ `team_id` and returns
242
+ `env.AWEB_IDENTITY_HOME=<home>/.aweb-identity`. If the minted team differs,
243
+ the hook revokes the grant and keeps nothing. As of aw 1.36.1, receiving,
244
+ wake registration and `aw whoami` work through a grant, but sending mail or
245
+ chat through a grant is rejected by the server with 422 (`from_did must match
246
+ the authenticated sender`) because the aw client signs with the grant-key DID
247
+ where the server expects the resident's. Retire revokes
248
+ `meta.identity.grant.id` through the custody directory; with no grant id it
249
+ reports `nothing-to-revoke`. A failed revoke exits nonzero and reports the TTL
250
+ expiry.
251
+ - `residents: { <name>: /abs/custody/dir }` is the host-owned map for global
252
+ mode. Each custody directory's `.aw` holds the resident identity root keys and
253
+ team certificate. Do not put this map in committed source; the hook cannot
254
+ distinguish payload provenance.
206
255
  - `delivery: channel | session` (default `channel`). `session` hands
207
256
  notification delivery to the host wake broker: `AWEB_DELIVERY=session` in
208
257
  the launch environment (declared by the manifest), no Claude channel flag,
@@ -48,8 +48,9 @@ capabilities plus `oats.core`), so a public soul stays usable.
48
48
  Two teams that need two different messaging identities (an open-source team
49
49
  and a hosted-operations team, say) stay in ONE workspace: `team:` is a label,
50
50
  and the provider payload is addressed by label under `messaging.byTeam` (§2).
51
- Read §8b before relying on it: the kernel merges `byTeam`, but oats.aweb 1.11.2
52
- does not yet read the `team` it delivers.
51
+ Read §8b before relying on it: oats.aweb 1.12.0 mints into the `team` the
52
+ payload names, but the `.aw` root it mints FROM is still found by search and
53
+ must hold that team's membership (1.11.2 ignored `team` altogether).
53
54
 
54
55
  ## 2. Write `oats-workspace.yaml` v2 in the host repo
55
56
 
@@ -76,8 +77,8 @@ members:
76
77
  - git:github.com/acme/platform
77
78
  packages:
78
79
  oats.framework: v1.1.3
79
- oats.okf: v2.1.3
80
- oats.aweb: v1.11.2
80
+ oats.okf: v2.1.4
81
+ oats.aweb: v1.12.0
81
82
  teams:
82
83
  global: { description: Org-wide }
83
84
  engineering: { description: Platform }
@@ -136,7 +137,7 @@ they enumerate `souls/*/soul.yaml`. A soul left under `agents/` is invisible to
136
137
  | 0.24 | v2 |
137
138
  |---|---|
138
139
  | `schemaVersion: 1` | `schemaVersion: 2` |
139
- | `requires.knowledge: { capability: oats.okf, source: git:…@v2.1.3#oats-package }` | `capabilities: { oats.okf: { from: package } }` — or nothing, if the workspace default already says so |
140
+ | `requires.knowledge: { capability: oats.okf, source: git:…@v2.1.4#oats-package }` | `capabilities: { oats.okf: { from: package } }` — or nothing, if the workspace default already says so |
140
141
  | `requires.capabilities.<cap>: { source: git:… }` | `<cap>: { from: package }` (published) or `<cap>: { from: here }` / `{ from: <repo key> }` (a member capability) |
141
142
  | `source: repo:…` / `path:` | `{ from: here }` |
142
143
  | `defaults.capabilities` | fold into `capabilities:`; use `off` to remove a workspace default |
@@ -217,8 +218,8 @@ messaging identity per team (§1), a soul that loses its label silently lands
217
218
  outside every team-addressed payload; nothing refuses it. Label the membership
218
219
  when a whole repo belongs to one team, and the soul when it does not.
219
220
 
220
- **Per-soul memory-harvest opt-out:** not available in OKF 2.1.3 — an OKF 2.1.4
221
- item. Neither `okf.json` (`version`, `owner`, `owns`, `reads`) nor the settings
221
+ **Per-soul memory-harvest opt-out:** not available in OKF 2.1.3 or 2.1.4 — a
222
+ later OKF item. Neither `okf.json` (`version`, `owner`, `owns`, `reads`) nor the settings
222
223
  payload (`bindings-file`, `state-dir`, `harvest-runtime`, `harvest-model`) has a
223
224
  key that keeps a soul registered for reads while excluding it from harvest. A
224
225
  soul that must not be harvested today says `knowledge: none` (no OKF at all for
@@ -327,7 +328,7 @@ Member capabilities need no approval: membership is the trust.
327
328
  **Non-interactive approval (CI, scripted rebuilds):**
328
329
 
329
330
  ```bash
330
- oats sync --approve oats.okf@v2.1.3 --approve oats.aweb@v1.11.2
331
+ oats sync --approve oats.okf@v2.1.4 --approve oats.aweb@v1.12.0
331
332
  ```
332
333
 
333
334
  `--approve <id>@<version>` is repeatable and approves **exactly** the entry the
@@ -405,11 +406,11 @@ machine-level setting would give the seat to EVERY instance of every messaging
405
406
  soul on that machine, and a seat can be held once. The Desktop's
406
407
  confirmed apply carries the same map.
407
408
 
408
- ## 8b. Where the team `.aw` lives now (oats.aweb 1.11.2), and what `byTeam` does today
409
+ ## 8b. Where the team `.aw` lives now, and what `byTeam` does today
409
410
 
410
411
  A freshly minted identity (every spawn without `identity.source`) needs an
411
412
  **initialised aweb root**: a directory holding `.aw` with a team membership to
412
- mint into. oats.aweb 1.11.2's spawn hook looks for `.aw` among these, first hit
413
+ mint into. oats.aweb's spawn hook (1.11.2 and 1.12.0 alike) looks for `.aw` among these, first hit
413
414
  wins: the declared team scope (`OATS_TEAM_SCOPE`, from the removed
414
415
  `oats-config.yaml` `team:` block — **empty under v2**), the instance home, the
415
416
  git repo containing the home, the resolution context (the soul's work repo) and
@@ -0,0 +1,42 @@
1
+ # OATS 0.25.5
2
+
3
+ Patch release: one lifecycle-contract defect fixed; two provider releases
4
+ pinned in the official catalog.
5
+
6
+ ## Fixed
7
+
8
+ - **Launch-hook `meta` was collected and discarded.** A capability's `launch`
9
+ hook may return `meta` like the spawn hook does, but the start/restart path
10
+ consumed only its `launch` arguments and `env`, so `instance.json.capabilityMeta`
11
+ kept the spawn-time value forever. A provider that re-issues a credential at
12
+ every start (e.g. a renewed messaging session grant) would have left the
13
+ ORIGINAL id on record, and retire — which reads that record as `OATS_META` —
14
+ would have revoked the wrong one while the live one ran to its TTL. After a
15
+ successful start the kernel now merges each capability's launch `meta` over
16
+ its entry; a hook answering without `meta` keeps its prior entry; a failed
17
+ launch preparation changes nothing. Regression test in
18
+ `test/session-restart.test.mjs`; contract paragraph in `docs/capabilities.md`.
19
+
20
+ No new flags, fields or hook events. `oats version --json` unchanged apart from
21
+ the version.
22
+
23
+ ## Catalog
24
+
25
+ - **oats.okf → v2.1.4** (mirror in `capabilities/oats-okf` synced, published
26
+ tag `2af47ad3`): sparse staging to `base.root` from a single-branch partial
27
+ clone with streamed object reads (large repositories and large files outside
28
+ the knowledge base no longer matter), `git-timeout` binding key (600 s for
29
+ remote operations), migration record hygiene + `oats okf migrate --forget`,
30
+ owners keyed by `OATS_SOUL_ID` with a one-time rewrite of same-name path pins
31
+ (closes the `E_OWNER`-after-member-commit regression; needs kernel ≥ 0.25.3),
32
+ and retire removes a drained source's `okf-<id>` scheduler job.
33
+ - **oats.aweb → v1.12.0** (byte-identical to `capabilities/oats-aweb`):
34
+ resident identity session grants — `identity.mode: local | global`, `team`
35
+ read from the payload (so `messaging.byTeam.<label>.team` is now the per-label
36
+ identity), host-only `residents` map, and the messaging-layer meta key
37
+ `identity` on every spawn. Known limitation: sending mail/chat through a
38
+ grant is rejected by the aweb server as of aw 1.36.1 (client bug; receive,
39
+ wake and whoami work). Guide truths in `docs/workspaces.md` and
40
+ `docs/rebuild-to-v2.md` updated from "1.11.2 ignores `team`" to what 1.12.0
41
+ does.
42
+
@@ -29,7 +29,7 @@ two roles never collapse: `oats-okf`, `oats-aweb`, `oats-jira`, `oats-linear`,
29
29
  their `oats-package/` is consumed only as a package: `from: package`, pinned
30
30
  in the workspace's `packages:`, locked and approved per version. The framework's
31
31
  own souls therefore say `oats.okf: { from: package }` even though `oats-okf` is
32
- a member. A bare version in `packages:` (`oats.okf: v2.1.3`) resolves through
32
+ a member. A bare version in `packages:` (`oats.okf: v2.1.4`) resolves through
33
33
  the catalog; a package outside it is written `git:<repo>@<ref>`.
34
34
 
35
35
  ## Join it on your machine
@@ -61,7 +61,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
61
61
 
62
62
  packages: # the ONLY versioned things
63
63
  oats.framework: v1.1.3 # bare version → resolves through the official catalog
64
- oats.okf: v2.1.3
64
+ oats.okf: v2.1.4
65
65
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
66
66
 
67
67
  teams: # labels, declared once so they cannot drift
@@ -383,13 +383,16 @@ a label under `byTeam` that is not declared in `teams:` is `E_WORKSPACE_SCHEMA`.
383
383
  **`byTeam` is kernel-merged; whether a provider honours what arrives is the
384
384
  provider's.** `spawn --preview` shows the merged `settings.<cap>` so the
385
385
  delivery is verifiable, and `instance.json.providers.<cap>` records it — but
386
- oats.aweb **1.11.2 does not read `team` from its payload** (it resolves the
387
- team from the removed `oats-config.yaml` `team:` block, else the active team at
388
- the `.aw` root it finds), so for 1.11.2 `byTeam` is a recorded intent, not a
389
- per-label identity; the per-repo `.aw` placement in
390
- [rebuild-to-v2.md §8b](rebuild-to-v2.md#8b-where-the-team-aw-lives-now-oatsaweb-1112-and-what-byteam-does-today)
391
- is the working alternative. An oats.aweb release that reads `team` from the
392
- payload closes the gap without a workspace edit (release notes will name it).
386
+ **oats.aweb 1.12.0 reads `team` from its payload** and mints into exactly that
387
+ team (`--team-id`), warning when the payload disagrees with an `OATS_TEAM_*`
388
+ value — so `byTeam.<label>.team` IS the per-label identity. What the payload
389
+ does not change is **where the `.aw` root is found**: the hook still searches
390
+ the bounded candidates in
391
+ [rebuild-to-v2.md §8b](rebuild-to-v2.md#8b-where-the-team-aw-lives-now-and-what-byteam-does-today)
392
+ and that root must hold a membership of the named team (the deployment's `.aw`
393
+ joined to every team its labels name is the simple layout). On oats.aweb
394
+ 1.11.2 `team` was ignored (the root's active team won), so `byTeam` there is
395
+ a recorded intent only.
393
396
  A store (`stores: { <name>: <repo ref> }`) names a repository; where a
394
397
  knowledge base lives inside it is the knowledge provider's own concern — for
395
398
  OKF 2.1.3 that is the **bindings file** (`bases.<alias>.repository` + `root`,
package/lib/core.mjs CHANGED
@@ -6097,7 +6097,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
6097
6097
  const inst = instance || meta?.instance || basename(home);
6098
6098
  const command = renderLaunchRecipe(recipe, { home, instance: inst });
6099
6099
  const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.runtime) ? "frozen" : "config") : "config";
6100
- return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen };
6100
+ return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
6101
6101
  }
6102
6102
  /** The environment a planned launch runs under: the host's base, the
6103
6103
  * capabilities' validated env, the configuration's literals and its
@@ -8296,6 +8296,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
8296
8296
  // prepares the target runtime under its CAPTURED settings.
8297
8297
  const ctx = contextDir || meta.repo;
8298
8298
  const withLaunchHook = [];
8299
+ let hookMeta;
8299
8300
  for (const p of capturedProviders(meta, frozen)) {
8300
8301
  const manifest = ctx ? capabilityManifest(p.id, ctx) : undefined;
8301
8302
  if (!manifest) throw oatsError("E_LAUNCH_PREPARATION", `${p.id} was part of this home's launch at spawn but is no longer installed in the scope; reinstall it (oats install) or respawn the instance; nothing was stopped`);
@@ -8328,6 +8329,12 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
8328
8329
  refreshed.push(c.capability);
8329
8330
  }
8330
8331
  Object.assign(env, res.env || {});
8332
+ // A launch hook's `meta` is part of its documented return (the same shape
8333
+ // spawn persists as capabilityMeta). It was collected and then dropped
8334
+ // here, so a provider that re-issues a credential at every start — a
8335
+ // renewed session grant, for instance — left the ORIGINAL id on record and
8336
+ // retire undid the wrong one. Carry it to the caller; the start records it.
8337
+ hookMeta = res.meta && Object.keys(res.meta).length ? res.meta : undefined;
8331
8338
  }
8332
8339
  const unprepared = contributions.filter((c) => c.capability !== null && !refreshed.includes(c.capability) && c.launch && c.launch[frozen.runtime] !== undefined && c.launch[runtime] === undefined).map((c) => c.capability);
8333
8340
  const legacyArgs = contributions.filter((c) => c.capability === null && c.launch && Object.keys(c.launch).length);
@@ -8335,7 +8342,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
8335
8342
  if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.runtime} launch arguments at spawn and none for ${runtime}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
8336
8343
  const launch = {};
8337
8344
  for (const c of contributions) { if (c.capability === null && refreshed.length) continue; for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
8338
- return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed };
8345
+ return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed, ...(hookMeta ? { meta: hookMeta } : {}) };
8339
8346
  }
8340
8347
 
8341
8348
  /** Start a stopped instance again in its existing home: no new home, no
@@ -8639,7 +8646,7 @@ export function startInstanceSession(home, o = {}) {
8639
8646
  // The independent receipt first (retire and session consult it), then the
8640
8647
  // mutable metadata; both tmp+rename. A failure between them is what the
8641
8648
  // pending receipt exists for.
8642
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
8649
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId, hookMeta }, clearPending = true) => {
8643
8650
  checkRoots();
8644
8651
  const baselinePath = retirementBaselinePath(realHome);
8645
8652
  let baseline;
@@ -8654,7 +8661,10 @@ export function startInstanceSession(home, o = {}) {
8654
8661
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
8655
8662
  if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
8656
8663
  const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
8657
- ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
8664
+ ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
8665
+ // Launch-hook meta lands per capability over the spawn's record; a hook
8666
+ // that answered without meta keeps its previous entry (retire reads it).
8667
+ ...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
8658
8668
  if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
8659
8669
  else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
8660
8670
  if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
@@ -8753,7 +8763,7 @@ export function startInstanceSession(home, o = {}) {
8753
8763
  const resolvedCfg = resolveOatsConfig(context, meta.agent);
8754
8764
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
8755
8765
  const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
8756
- launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
8766
+ launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo, ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
8757
8767
  command = launchPlan.command; model = launchPlan.model;
8758
8768
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
8759
8769
  const resolved = resolveModelPreference(String(o.model), runtime);
@@ -8770,6 +8780,7 @@ export function startInstanceSession(home, o = {}) {
8770
8780
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
8771
8781
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
8772
8782
  const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
8783
+ ...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}),
8773
8784
  ...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
8774
8785
  let target = receipt.target;
8775
8786
  let state = { present: false, state: "not-launched" };
@@ -3,12 +3,12 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v2.1.3",
6
+ "ref": "v2.1.4",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.11.2",
11
+ "ref": "v1.12.0",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.25.4",
3
+ "version": "0.25.5",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",