@awebai/oats 0.24.7 → 0.24.9

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/bin/oats.mjs CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  approveCapability, approveAvailableCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
31
31
  packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
32
32
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, planInstanceResources, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
33
- findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf,
33
+ findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
34
34
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
35
35
  spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
36
36
  } from "../lib/core.mjs";
@@ -59,12 +59,16 @@ import { CAPTURED_OPERATION_TIMEOUT_MS, runCapturedOperationProcess } from "../l
59
59
  import { approveCapturedCapability } from "../lib/artifact-approvals.mjs";
60
60
  import { loadSetupExpertEdition, SETUP_EXPERT, SETUP_CAPABILITIES } from "../lib/setup-expert-source.mjs";
61
61
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
62
+ import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
63
+ const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
64
+ import { readinessOf, policyOf } from "../lib/readiness.mjs";
65
+ import { readEvents } from "../lib/instance-events.mjs";
62
66
  import { parsePortableSource } from "../lib/source-spec.mjs";
63
67
 
64
68
  const args = process.argv.slice(2);
65
69
  let cmd = args[0];
66
70
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
67
- const KERNEL_COMMANDS = new Set(["prepare", "capture", "config", "create", "doctor", "inspect", "instance", "operation", "soul", "launch-config", "experimental", "onboard", "init", "inject", "install", "list", "catalog", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
71
+ const KERNEL_COMMANDS = new Set(["prepare", "capture", "config", "create", "doctor", "inspect", "instance", "operation", "readiness", "soul", "launch-config", "experimental", "onboard", "init", "inject", "install", "list", "catalog", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
68
72
  const flag = (name) => {
69
73
  const i = args.indexOf(`--${name}`);
70
74
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -596,6 +600,12 @@ function officialMigrationState(legacyLocks, { teamScope, ctx }) {
596
600
  * nearer scope's package of the same id — a provider that never exported it. */
597
601
  const levelRows = (locks, level) => locks.levels.find((l) => l.level === level) || { packages: Object.create(null), capabilities: Object.create(null) };
598
602
 
603
+ /** A capability has an executable surface when its manifest declares commands,
604
+ * hooks or launch environment — the things `oats trust` approves. A
605
+ * data-only capability (skills/injects) has none, and trust is not-applicable. */
606
+ function hasExecutableSurface(manifest) {
607
+ return !!(Object.keys(manifest?.commands || {}).length || Object.keys(manifest?.hooks || {}).length || (manifest?.environment?.length || 0));
608
+ }
599
609
  function capabilityHealth(level, cap, capRow, pkgRow) {
600
610
  const dir = installedCapabilityDir(level, cap.id);
601
611
  if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats install\` to re-materialize it` };
@@ -611,9 +621,7 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
611
621
  try { verifyCapabilityInstallation(dir, cap.id, capRow, pkgRow); }
612
622
  catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
613
623
  }
614
- const executable = Object.keys(cap.manifest?.commands || {}).length
615
- || Object.keys(cap.manifest?.hooks || {}).length
616
- || (cap.manifest?.environment?.length || 0);
624
+ const executable = hasExecutableSurface(cap.manifest);
617
625
  if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats trust ${cap.id}\`` };
618
626
  return { status: "ok", code: null, dir, integrity, detail: null };
619
627
  }
@@ -822,7 +830,7 @@ function homeContexts(home, meta) {
822
830
  * about the soul, not a prompt to infer one. */
823
831
  function soulDeclarations(soulDir) {
824
832
  const file = join(soulDir, "soul.yaml");
825
- const empty = { declarations: { requires: null, defaults: null, knowledge: null, teams: null, resources: null }, provenance: null, problems: [] };
833
+ const empty = { declarations: { requires: null, defaults: null, knowledge: null, teams: null, resources: null, children: null }, provenance: null, problems: [] };
826
834
  if (!existsSync(file)) return empty;
827
835
  let parsed;
828
836
  try { parsed = withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8"))); }
@@ -830,7 +838,7 @@ function soulDeclarations(soulDir) {
830
838
  const section = (key) => (parsed[key] !== undefined && parsed[key] !== null && typeof parsed[key] === "object") ? parsed[key] : (parsed[key] === undefined ? null : parsed[key]);
831
839
  const provenance = section("provenance");
832
840
  return {
833
- declarations: { requires: section("requires"), defaults: section("defaults"), knowledge: section("knowledge"), teams: section("teams"), resources: section("resources") },
841
+ declarations: { requires: section("requires"), defaults: section("defaults"), knowledge: section("knowledge"), teams: section("teams"), resources: section("resources"), children: section("children") },
834
842
  provenance: provenance && typeof provenance === "object" ? {
835
843
  kind: provenance.kind ?? null, source: provenance.source ?? null, revision: provenance.revision ?? null,
836
844
  path: provenance.path ?? null, workspaceRevision: provenance.workspaceRevision ?? null,
@@ -860,8 +868,12 @@ function soulEntry(soul, root, { capability } = {}) {
860
868
  * invoking process's ambient agents-root override must not redirect them
861
869
  * to its own deployment. */
862
870
  function dropAmbientRoot() { delete process.env.PI_AGENTS_ROOT; }
863
- function inspectCmd() {
864
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
871
+ function inspectCmd() { const result = computeInspect(); if (!result) return; if (JSON_MODE) { const { _print, ...data } = result; jsonOk(data); return; } printInspect(result); }
872
+ /** The inspect answer as data — shared by `oats inspect` and `oats readiness`
873
+ * (K5), so the readiness quartet is derived from the SAME capability,
874
+ * activation, trust and soul facts inspect reports, never a second opinion. */
875
+ function computeInspect({ onFail } = {}) {
876
+ const bail = onFail || ((code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg)));
865
877
  dropAmbientRoot();
866
878
  const homeFlag = flag("home");
867
879
  const home = homeFlag === true ? bail("E_BAD_ARGS", "--home needs an absolute instance home") : homeFlag;
@@ -936,8 +948,8 @@ function inspectCmd() {
936
948
  const h = capabilityHealth(p.level, c, rows.capabilities[c.id], rows.packages[p.package]);
937
949
  byId.set(c.id, {
938
950
  id: c.id, package: p.package, version: c.version || null, layer: c.manifest?.layer || null, command: c.manifest?.command || null,
939
- origin: "installed", level: p.level, source: p.source || null, dir: h.dir,
940
- health: { status: h.status, code: h.code, detail: h.detail, installed: !!c.installed, locked: true, trusted: c.trusted === true, integrity: c.integrity || null, installedIntegrity: h.integrity ?? null },
951
+ origin: "installed", level: p.level, source: p.source || null, commit: p.commit ?? rows.packages[p.package]?.commit ?? null, dir: h.dir,
952
+ health: { status: h.status, code: h.code, detail: h.detail, installed: !!c.installed, locked: true, trusted: c.trusted === true, executableSurface: hasExecutableSurface(c.manifest), integrity: c.integrity || null, installedIntegrity: h.integrity ?? null },
941
953
  });
942
954
  }
943
955
  }
@@ -945,13 +957,13 @@ function inspectCmd() {
945
957
  for (const [id, m] of Object.entries(mans)) {
946
958
  if (byId.has(id)) continue;
947
959
  const trust = capabilityTrust(m, ctx);
948
- const executable = Object.keys(m.commands || {}).length || Object.keys(m.hooks || {}).length || (m.environment?.length || 0);
960
+ const executable = hasExecutableSurface(m);
949
961
  let integrity = trust.integrity || null;
950
962
  if (!integrity) { try { integrity = capabilityArtifactIntegrity(m._dir); } catch { integrity = null; } }
951
963
  byId.set(id, {
952
964
  id, package: m._package || null, version: m.version || null, layer: m.layer || null, command: m.command || null,
953
965
  origin: String(m._origin || "").split(":")[0] || "unknown", level: String(m._origin || "").split(":").slice(1).join(":") || null, source: null, dir: m._dir,
954
- health: { status: executable && !trust.trusted ? "untrusted" : "ok", code: executable && !trust.trusted ? "untrusted-surface" : null, detail: executable && !trust.trusted ? (trust.reason || null) : null, installed: true, locked: !!trust.lock, trusted: !!trust.trusted, integrity, installedIntegrity: integrity },
966
+ health: { status: executable && !trust.trusted ? "untrusted" : "ok", code: executable && !trust.trusted ? "untrusted-surface" : null, detail: executable && !trust.trusted ? (trust.reason || null) : null, executableSurface: executable, installed: true, locked: !!trust.lock, trusted: !!trust.trusted, integrity, installedIntegrity: integrity },
955
967
  });
956
968
  }
957
969
  // What is EFFECTIVE for the answer: a home's captured bindings and settings
@@ -1051,7 +1063,12 @@ function inspectCmd() {
1051
1063
  problems: [...(lockError ? [lockError] : []), ...packagedDiagnostics.map((d) => ({ code: d.code, message: d.message, capability: d.capability })),
1052
1064
  ...(meta ? snapshotCaps.filter((c) => !mans[c.id]).map((c) => ({ code: "captured-capability-missing", message: `${c.id} was active when this home was composed but no manifest for it is acquired now`, capability: c.id })) : [])],
1053
1065
  };
1054
- if (JSON_MODE) { jsonOk(result); return; }
1066
+ result._print = { ctx, selectedSoul, home };
1067
+ return result;
1068
+ }
1069
+ function printInspect(result) {
1070
+ const { ctx, selectedSoul, home } = result._print; delete result._print;
1071
+ const { souls, layers, capabilities } = result;
1055
1072
  console.log(`oats inspect — ${shortPath(ctx)}${selectedSoul ? ` soul ${selectedSoul.name}` : ""}${home ? ` home ${shortPath(home)}` : ""}`);
1056
1073
  for (const s of souls) console.log(` soul ${s.name} [${s.kind}${s.capability ? ` ${s.capability}` : ""}] runtime ${s.runtime}${s.model ? ` model ${s.model}` : ""} work ${s.work}${s.editable.fields.length ? "" : " (read-only)"}`);
1057
1074
  for (const l of LAYERS) console.log(` layer ${l}: ${layers[l].id || (layers[l].disabled ? "disabled" : "none")}${layers[l].provenance ? ` (${layers[l].provenance})` : ""}`);
@@ -3116,9 +3133,55 @@ function renderMergeRegion(r) {
3116
3133
  function instanceCmd() {
3117
3134
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
3118
3135
  const sub = args[1], name = args[2];
3119
- const usage = "usage: oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json]";
3120
- if (!["git", "diff"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
3136
+ const usage = "usage: oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--dir <d>] [--json] | oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json] | oats instance stop <instance> (--plan | --apply --plan-revision <rev> --idempotency-key <key>) [--no-recursive] [--grace-ms <n>] [--home <abs>] [--dir <d>] [--json]";
3137
+ if (!["git", "diff", "stop", "events"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
3121
3138
  dropAmbientRoot();
3139
+ if (sub === "events") {
3140
+ // K7: typed producer events, bounded window; nothing inferred.
3141
+ const homeOpt = flag("home"); if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3142
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
3143
+ const limit = flag("limit"); const since = flag("since");
3144
+ if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
3145
+ try {
3146
+ const { resolveInstance } = await_import_lifecycle();
3147
+ let home = homeOpt;
3148
+ if (!home) home = resolveInstance(dirFlag(), root, name).home;
3149
+ const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
3150
+ if (JSON_MODE) { jsonOk(ev); return; }
3151
+ console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
3152
+ for (const e of ev.events) console.log(` ${e.at ?? "?"} ${e.kind.padEnd(20)} ${e.producer}${e.data ? ` ${JSON.stringify(e.data).slice(0, 120)}` : ""}`);
3153
+ return;
3154
+ } catch (e) { return bail(e.code || "E_EVENTS_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined); }
3155
+ }
3156
+ if (sub === "stop") {
3157
+ // K3: plan → apply. The plan is what a confirmation shows; apply carries
3158
+ // its revision back and refuses if reality moved.
3159
+ const homeOpt = flag("home");
3160
+ if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3161
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
3162
+ const recursive = !args.includes("--no-recursive");
3163
+ const wantPlan = args.includes("--plan"), wantApply = args.includes("--apply");
3164
+ if (wantPlan === wantApply) return bail("E_BAD_ARGS", "stop needs exactly one of --plan or --apply");
3165
+ try {
3166
+ if (wantPlan) {
3167
+ const plan = planStop(dirFlag(), root, name, { home: homeOpt, recursive });
3168
+ if (JSON_MODE) { jsonOk(plan); return; }
3169
+ console.log(`stop ${name}${recursive ? " (and recorded children)" : ""} — plan ${plan.planRevision}`);
3170
+ for (const t of plan.targets) console.log(` ${" ".repeat(t.depth)}${t.instance}: session ${t.session.state}${t.work.observed ? `, ${t.work.changed} changed / ${t.work.untracked} untracked on ${t.work.branch ?? "detached"}` : ", work not observed"}${t.midTask === true ? " — mid-task" : t.midTask === "unknown" ? " — activity unknown" : ""}`);
3171
+ for (const n of plan.notes) console.log(` note: ${n}`);
3172
+ console.log(`apply with: oats instance stop ${name} --apply --plan-revision ${plan.planRevision} --idempotency-key <key>`);
3173
+ return;
3174
+ }
3175
+ const rev = flag("plan-revision"), key = flag("idempotency-key"), grace = flag("grace-ms");
3176
+ if (rev === true || key === true || grace === true) return bail("E_BAD_ARGS", usage);
3177
+ const receipt = applyStop(dirFlag(), root, name, { home: homeOpt, recursive, planRevision: rev, idempotencyKey: key, ...(grace !== undefined ? { graceMs: Number(grace) } : {}) });
3178
+ if (JSON_MODE) { jsonOk(receipt); return; }
3179
+ for (const r of receipt.results) console.log(` ${r.instance}: ${r.ok ? (r.stopped ? "stopped" : `already ${r.state}`) : `${r.code} — ${r.message}`}`);
3180
+ console.log(receipt.ok ? `stopped${receipt.replayed ? " (replayed receipt)" : ""}; home, work, transcript and launch configuration retained — restart with \`oats session restart\`` : "some targets are still running; nothing was escalated");
3181
+ if (!receipt.ok) process.exit(1);
3182
+ return;
3183
+ } catch (e) { return bail(e.code || "E_LIFECYCLE_FAILED", e.message, e.plan ? { plan: e.plan } : e.candidates ? { candidates: e.candidates } : undefined); }
3184
+ }
3122
3185
  let home = flag("home");
3123
3186
  if (home === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3124
3187
  if (home !== undefined && !isAbsolute(home)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
@@ -3156,6 +3219,49 @@ function instanceCmd() {
3156
3219
  bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
3157
3220
  }
3158
3221
  }
3222
+ /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
3223
+ function readinessCmd() {
3224
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
3225
+ dropAmbientRoot();
3226
+ // A captured incarnation's readiness comes from its retained resolution, not
3227
+ // from the current configuration this command reads; refuse before inspecting.
3228
+ const homeArg = flag("home");
3229
+ if (homeArg && homeArg !== true) {
3230
+ let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
3231
+ if (capturedMeta?.executionBinding || capturedMeta?.captured) return bail("E_UNSUPPORTED_MODE", `${basename(String(homeArg))} is a captured incarnation: its readiness is the retained resolution's, not the current configuration's (inspect it with oats operation --deployment/--resolution)`, { home: String(homeArg), captured: true });
3232
+ }
3233
+ const inspect = computeInspect({ onFail: bail });
3234
+ if (!inspect) return;
3235
+ const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
3236
+ const verify = args.includes("--verify-signatures");
3237
+ let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
3238
+ const deploymentDir = inspect.scope?.context ?? null;
3239
+ // Echo the exact selector this read was made with, so a consumer can bind the
3240
+ // result to its own admitted target without inventing a revision.
3241
+ // Every field is the argument AS GIVEN (no realpath): a consumer compares it
3242
+ // byte-exact with what it sent. The canonical scope is subject.context.
3243
+ const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
3244
+ const agentsRootArg = given("agents-root"), dirArg = given("dir");
3245
+ const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
3246
+ : soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
3247
+ : { kind: "scope", dir: dirArg };
3248
+ const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
3249
+ if (args.includes("--policy")) {
3250
+ const homeOpt = flag("home");
3251
+ let meta = null;
3252
+ if (homeOpt && homeOpt !== true) { try { meta = JSON.parse(readFileSync(join(homeOpt, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeOpt}: ${e.message}`); } }
3253
+ readiness.policy = policyOf({ instanceMeta: meta, soul: soul ? inspect.souls.find((s) => s.name === soul) : null }).policy;
3254
+ readiness.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
3255
+ }
3256
+ if (JSON_MODE) { jsonOk(readiness); return; }
3257
+ console.log(`readiness — ${readiness.subject.kind === "soul" ? `soul ${readiness.subject.name}` : shortPath(readiness.subject.context)}: ${readiness.summary.ready ? "READY" : `${readiness.summary.fail} failing, ${readiness.summary.unknown} unknown of ${readiness.summary.required} required`}`);
3258
+ for (const [name, check] of Object.entries(readiness.checks)) {
3259
+ console.log(` ${name}: ${check.status}`);
3260
+ for (const i of check.items) console.log(` ${i.status.padEnd(14)} ${i.subject}${i.required ? "" : " (optional)"}${i.reason ? ` — ${i.reason}` : ""}${i.signature ? ` · signature ${i.signature.status}${i.signature.signer?.label ? ` by ${i.signature.signer.label}` : ""}` : ""}${i.remedy ? ` → ${i.remedy}` : ""}`);
3261
+ }
3262
+ if (readiness.policy) console.log(` policy: child spawns ${readiness.policy.childSpawns.allowed ? "allowed" : "disabled"} (${readiness.policy.childSpawns.origin.kind}${readiness.policy.childSpawns.enforced ? ", enforced" : ""}); worktrees ${readiness.policy.worktrees.allowed === null ? "unknown" : readiness.policy.worktrees.allowed ? "allowed" : "not in this work mode"}`);
3263
+ for (const n of readiness.notes) console.log(` note: ${n}`);
3264
+ }
3159
3265
  function catalogCmd() {
3160
3266
  const described = describeOfficialCatalog();
3161
3267
  if (JSON_MODE) { jsonOk(described); return; }
@@ -4066,7 +4172,7 @@ function spawnCmd() {
4066
4172
  };
4067
4173
  checkDirectoryOptions(requestedWork); // before a local soul could be upserted
4068
4174
  const name = args[1];
4069
- if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
4175
+ if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--preview] [--base <ref>] [--model <id>|@native-default] [--allow-child-spawns|--no-child-spawns] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
4070
4176
  // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
4071
4177
  // ANY side effect — including root discovery and local-agent upsert (an
4072
4178
  // --instructions-file spawn must not scaffold/overwrite a local soul before
@@ -4076,7 +4182,21 @@ function spawnCmd() {
4076
4182
  let root;
4077
4183
  try { root = ensureRoot(dirFlag()); }
4078
4184
  catch (e) { bail("E_NO_DEPLOYMENT", e.message || e); throw e; }
4185
+ const isPreview = args.includes("--preview");
4186
+ // --agents-root <abs>: the exact root the soul must live in (as inspect and
4187
+ // readiness take it). With it, no team-soul / capability-agent / importable-
4188
+ // def fallback: the soul is there or the spawn refuses E_SOUL_UNKNOWN.
4189
+ const agentsRootFlag = flag("agents-root");
4190
+ if (agentsRootFlag !== undefined && (agentsRootFlag === true || !isAbsolute(String(agentsRootFlag)))) bail("E_BAD_ARGS", "--agents-root needs an absolute agents root");
4191
+ if (agentsRootFlag !== undefined && realOrResolved(String(agentsRootFlag)) !== realOrResolved(root)) {
4192
+ const teamHit = findTeamAgent(dirFlag(), name), hit = (teamHit?.matches || []).find((m) => realOrResolved(m.root) === realOrResolved(String(agentsRootFlag)));
4193
+ if (!hit) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)} (this scope's root is ${shortPath(root)})`);
4194
+ root = hit.root;
4195
+ }
4079
4196
  let agent = findAgent(root, name);
4197
+ if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
4198
+ if (isPreview && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not in ${shortPath(root)}; a preview never creates or imports a soul (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"})`);
4199
+ if (isPreview && (flag("instructions-file") !== undefined || flag("def-file") !== undefined)) bail("E_BAD_ARGS", "--preview does not take --instructions-file/--def-file: a preview never writes a soul");
4080
4200
  const instrFile = flag("instructions-file");
4081
4201
  const defFile = flag("def-file");
4082
4202
  if (!agent && !instrFile && !defFile) {
@@ -4184,8 +4304,11 @@ function spawnCmd() {
4184
4304
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
4185
4305
  let r;
4186
4306
  try {
4307
+ if (args.includes("--allow-child-spawns") && args.includes("--no-child-spawns")) bail("E_BAD_ARGS", "--allow-child-spawns and --no-child-spawns contradict");
4308
+ if (flag("base") === true) bail("E_BAD_ARGS", "--base needs a ref");
4187
4309
  r = spawnInstance(root, agent, {
4188
4310
  purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
4311
+ ...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
4189
4312
  // Directory execution uses deployment configuration, not an ambient Git
4190
4313
  // checkout (especially when invoked via --dir from a source instance).
4191
4314
  repo: (requestedWork || agent.work) === "directory"
@@ -4193,7 +4316,15 @@ function spawnCmd() {
4193
4316
  work: requestedWork, workDir, runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch,
4194
4317
  launchConfig: valueFlag("launch-config"),
4195
4318
  launch: !args.includes("--no-launch"),
4319
+ // K6: --preview decides everything and touches nothing; --base <ref>
4320
+ // selects a worktree's start point; --model @native-default is the
4321
+ // explicit "runtime's own default" (distinct from omitting --model).
4322
+ ...(args.includes("--preview") ? { preview: true, subject: { soul: name, agentsRoot: agentsRootFlag !== undefined ? String(agentsRootFlag) : null, dir: flag("dir") !== undefined && flag("dir") !== true ? String(flag("dir")) : null } } : {}),
4323
+ ...(flag("base") !== undefined && flag("base") !== true ? { baseRef: flag("base") } : {}),
4324
+ // A confirmed preview binds this apply (K6b): drift → E_DECISION_STALE, nothing created.
4325
+ ...(flag("expect-decision") !== undefined && flag("expect-decision") !== true ? { expectDecision: String(flag("expect-decision")) } : {}),
4196
4326
  });
4327
+ if (args.includes("--preview")) { if (JSON_MODE) { jsonOk(r); return; } console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created`); return; }
4197
4328
  } catch (e) {
4198
4329
  // A typed CLI failure keeps ITS OWN code: re-badging an unsafe-config-key
4199
4330
  // (raised by the readers spawn walks) as E_SPAWN_FAILED tells an agent
@@ -4207,6 +4338,10 @@ function spawnCmd() {
4207
4338
  // An unmet declared requirement is a fact about the soul's configuration
4208
4339
  // (with a remedy), not a spawn-mechanism failure: keep its code and details.
4209
4340
  if (e?.code === "E_REQUIREMENT_INACTIVE") { bail(e.code, e.message, { soul: e.soul, capabilities: e.capabilities, context: e.context, remedy: e.remedy }); throw e; }
4341
+ if (e?.code === "E_CHILD_SPAWNS_DISABLED") { bail(e.code, e.message, { parent: e.parent, policy: e.policy }); throw e; }
4342
+ if (["E_BRANCH_EXISTS", "E_BASE_UNKNOWN"].includes(e?.code)) { bail(e.code, e.message); throw e; }
4343
+ // K6b: the confirmed decision drifted — the fresh decision travels with the refusal so a GUI re-previews.
4344
+ if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
4210
4345
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
4211
4346
  }
4212
4347
  // The instance exists from here on: a failed wake save is reported beside
@@ -4243,9 +4378,23 @@ function spawnCmd() {
4243
4378
 
4244
4379
  function retireCmd() {
4245
4380
  const name = args[1];
4246
- if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--home <path>] [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
4381
+ if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--plan] [--plan-revision <rev> --idempotency-key <key>] [--home <path>] [--self] [--discard-worktree] [--delete-branch] [--keep-dir] [--force] [--json]");
4247
4382
  let homeFlag = flag("home");
4248
4383
  if (homeFlag === true) die("--home needs the instance home path");
4384
+ if (args.includes("--plan")) {
4385
+ // K3: what retirement would touch, with the design's defaults — read-only.
4386
+ dropAmbientRoot();
4387
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_NO_ROOT", e.message) : die(e.message); }
4388
+ try {
4389
+ const plan = planRetire(dirFlag(), root, name, { home: homeFlag });
4390
+ if (args.includes("--json")) { jsonOk(plan); return; }
4391
+ console.log(`retire ${name} — plan ${plan.planRevision}`);
4392
+ console.log(` session ${plan.facts.session.state}; work ${plan.facts.work.observed ? `${plan.facts.work.changed} changed / ${plan.facts.work.untracked} untracked on ${plan.facts.work.branch ?? "detached"}` : `not observed (${plan.facts.work.reason})`}; children ${plan.facts.children.length}; pull request ${plan.facts.pullRequest}`);
4393
+ console.log(` defaults: retain worktree ${plan.defaults.retainWorktree}, delete branch ${plan.defaults.deleteBranch}, stop children ${plan.defaults.stopChildren}`);
4394
+ for (const n of plan.notes) console.log(` note: ${n}`);
4395
+ return;
4396
+ } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined) : die(e.message); }
4397
+ }
4249
4398
  // The calling instance knows its own home: self-retire never needs to
4250
4399
  // disambiguate a same-named twin by hand.
4251
4400
  if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME && (process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name)) homeFlag = process.env.OATS_INSTANCE_HOME;
@@ -4260,7 +4409,39 @@ function retireCmd() {
4260
4409
  if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; (args.includes("--json") ? console.error : console.log)(`(cross-repo: instance homes at ${shortPath(root)})`); }
4261
4410
  }
4262
4411
  const retiringHome = homeFlag || findInstanceHome(root, name);
4263
- const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
4412
+ // K3: a GUI-driven Remove carries the plan revision it showed and an
4413
+ // idempotency key. The revision is revalidated against a fresh plan
4414
+ // (E_PLAN_STALE with that plan attached — re-confirm, never act on the old
4415
+ // one); a retried key replays the recorded receipt instead of retiring twice.
4416
+ const planRev = flag("plan-revision"), idemKey = flag("idempotency-key");
4417
+ if (planRev === true || idemKey === true) die("--plan-revision and --idempotency-key need values");
4418
+ if ((planRev !== undefined) !== (idemKey !== undefined)) die("--plan-revision and --idempotency-key go together");
4419
+ let replayPath = null, childrenStopped = null, expectedBranch;
4420
+ if (planRev !== undefined) {
4421
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idemKey)) die("--idempotency-key: 1-128 chars of [A-Za-z0-9._:-]");
4422
+ // Replay first: after a successful retire the home is gone, so the receipt
4423
+ // (beside the instances dir, keyed by the idempotency key) is the answer.
4424
+ const replay = (dir) => { const p = join(dir, `.oats-retire-receipt.${idemKey}.json`); if (!existsSync(p)) return false; try { const prior = JSON.parse(readFileSync(p, "utf8")); if (prior.retired !== name) return false; if (args.includes("--json")) jsonOk({ ...prior, replayed: true }); else console.log(`retire ${name}: replayed receipt for key ${idemKey}`); return true; } catch { return false; } };
4425
+ for (const a of listAgents(root)) if (replay(join(a._dir, "instances"))) return;
4426
+ let fresh;
4427
+ try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message) : die(e.message); }
4428
+ replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
4429
+ if (fresh.planRevision !== planRev) return args.includes("--json") ? jsonFail("E_PLAN_STALE", `the retire plan changed since it was shown (${planRev} → ${fresh.planRevision}); review the fresh plan`, { plan: fresh }) : die(`the retire plan changed since it was shown; re-run oats retire ${name} --plan`);
4430
+ // The plan promised: recorded children are STOPPED first (bounded, never
4431
+ // escalated) and retained. A child still running after the grace refuses
4432
+ // the retirement — nothing is retired, the receipt names the pid.
4433
+ childrenStopped = [];
4434
+ for (const kid of fresh.facts.children) {
4435
+ try { const s = stopInstanceSession(kid.home, {}); childrenStopped.push({ instance: kid.instance, home: kid.home, ok: true, stopped: s.stopped, alreadyIdle: s.alreadyIdle }); }
4436
+ catch (e) { childrenStopped.push({ instance: kid.instance, home: kid.home, ok: false, code: e.code || "E_SESSION_STOP_FAILED", message: e.message, stillRunning: e.receipt?.stillRunning ?? null }); }
4437
+ }
4438
+ const running = childrenStopped.filter((k) => !k.ok);
4439
+ if (running.length) return args.includes("--json") ? jsonFail("E_CHILDREN_RUNNING", `${running.map((k) => k.instance).join(", ")} ${running.length === 1 ? "is" : "are"} still running after a bounded stop; nothing was retired and nothing was escalated`, { childrenStopped, plan: fresh }) : die(`children still running: ${running.map((k) => k.instance).join(", ")}; nothing retired`);
4440
+ expectedBranch = fresh.facts.work.observed ? fresh.facts.work.branch : undefined;
4441
+ }
4442
+ const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(expectedBranch !== undefined ? { expectedBranch } : {}) });
4443
+ if (childrenStopped) r.childrenStopped = childrenStopped;
4444
+ if (replayPath) { r.planRevision = planRev; r.idempotencyKey = idemKey; r.replayed = false; try { writeFileAtomic(replayPath, JSON.stringify(r, null, 2)); } catch { /* receipt is evidence, not authority */ } }
4264
4445
  // A retired home's wake jobs are forgotten (definitions only; nothing is
4265
4446
  // stopped by this); a deferred self-retire keeps them until the home is gone.
4266
4447
  if (retiringHome && r.removedDir !== false && !r.deferred) { try { const gone = removeWakeForHome(scheduleScopeOf(workspaceOf(root)), retiringHome); if (gone.length) r.wakeSchedulesRemoved = gone; } catch (e) { r.warnings = [...(r.warnings || []), `wake schedules not cleaned: ${e.message}`]; } }
@@ -4362,6 +4543,7 @@ async function sessionCmd() {
4362
4543
  return;
4363
4544
  }
4364
4545
  if (args[1] === "inspect") result = inspectInstanceSession(home);
4546
+ else if (args[1] === "recompose") result = recomposeInstanceInstructions(home, { dryRun: args.includes("--dry-run") });
4365
4547
  else if (args[1] === "start" || args[1] === "restart") {
4366
4548
  const bad = (msg) => { throw Object.assign(new Error(msg), { code: "E_BAD_ARGS" }); };
4367
4549
  const model = flag("model");
@@ -4836,7 +5018,7 @@ function versionCmd() {
4836
5018
  // on it (an older CLI without the surface must fail closed with a
4837
5019
  // reason, not an argument error). `features`: kernel abilities a peer
4838
5020
  // must see before relying on them (retire-home: retire --home).
4839
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations"], scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5021
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2"], instanceGitApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
4840
5022
  return;
4841
5023
  }
4842
5024
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -5278,6 +5460,7 @@ else if (cmd === "config") configCmd();
5278
5460
  else if (cmd === "trust") trust();
5279
5461
  else if (cmd === "list") listCmd();
5280
5462
  else if (cmd === "catalog") catalogCmd();
5463
+ else if (cmd === "readiness") readinessCmd();
5281
5464
  else if (cmd === "instance") instanceCmd();
5282
5465
  else if (cmd === "remove") removeCmd();
5283
5466
  else if (cmd === "migrate") migrateCmd();
@@ -5480,6 +5663,36 @@ Usage:
5480
5663
  oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json]
5481
5664
  bounded diff of one observed file; refuses when
5482
5665
  the tree moved since the observation
5666
+ oats session recompose --home <abs> [--dry-run] [--json]
5667
+ refresh a LIVE home's AGENTS.md from its current
5668
+ canonical soul + context (same composer as spawn);
5669
+ previous text retained; nothing restarted
5670
+ oats instance events <instance> [--limit <n>] [--since <iso>] [--json]
5671
+ typed lifecycle events (spawned, launched, stopped,
5672
+ restarted, retired, worktree-retained…) written by
5673
+ the action that made them true; nothing inferred
5674
+ oats instance stop <instance> --plan [--no-recursive] [--json]
5675
+ what Stop would touch: session state, recorded
5676
+ children, dirty work; a planRevision to apply
5677
+ oats instance stop <instance> --apply --plan-revision <rev> --idempotency-key <key>
5678
+ quiesce (SIGTERM, bounded, never escalated),
5679
+ children first; home/work/launch retained
5680
+ oats readiness [--soul <n> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--json]
5681
+ quartet installed|trusted|configured|enrolled for a scope,
5682
+ a soul, or an instance home (captured homes refuse:
5683
+ their readiness is the retained resolution's)
5684
+ installed | trusted | configured | enrolled, each
5685
+ pass|fail|unknown|not-applicable with items and
5686
+ remedies; signature status per artifact; enforced
5687
+ child-spawn / worktree policy with origins
5688
+ oats retire <instance> --plan [--json] what Remove would touch, with retention defaults
5689
+ oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--delete-branch]
5690
+ with a plan revision: refuses E_PLAN_STALE (fresh plan
5691
+ attached) if facts moved; a repeated key replays
5692
+ retire; a worktree is RETAINED (re-homed under
5693
+ <workspace>/.agents/worktrees/<repo>/<branch>)
5694
+ unless discarded; --delete-branch deletes the
5695
+ worktree's verified branch and implies discard
5483
5696
  oats update <package> [<package>@<ref>] transactional package update: temp fetch,
5484
5697
  [--to <ref>] [--dir <d>] closure validation, diff, lock replace,
5485
5698
  all capability approvals invalidated; a
@@ -2,7 +2,7 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fail, unlock } from '../lib/io.mjs';
4
4
  import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
5
- import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, service, markerPath, views } from '../lib/sources.mjs';
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
7
  import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource } from '../lib/migration.mjs';
8
8
  import { inspect } from '../lib/inspection.mjs';
@@ -121,13 +121,13 @@ else {
121
121
  let s;
122
122
  if(sourceReceipt.mode==='captured') {s=registerCaptured(home,sourceReceipt.receipt);if(s.skipped) {result={meta:{retired:true,reason:'service'}};s=null;}}
123
123
  else {if(!fs.existsSync(markerPath(home))) fail('E_MIGRATION','captured retire requires a durable registered source or explicit helper receipt');s=src();}
124
- if(s) {scheduleSource(s);const r=capture(s,{final:true});result={meta:{retired:r.complete===true,source:s.file,capture:r},brief:'Final input is in durable custody. Delivery remains asynchronous.'};}
124
+ if(s) {scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};}
125
125
  } else if(service(home)) result={meta:{retired:true}};
126
126
  else if(!fs.existsSync(markerPath(home))) {
127
127
  if(['STATE.md','log.md','notes','.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p)))) fail('E_MIGRATION','unregistered/legacy source has memory; explicitly migrate/register before retirement');
128
128
  result={meta:{retired:true,reason:'nothing-to-delete'}};
129
129
  } else {
130
- const s=src();scheduleSource(s);const r=capture(s,{final:true});result={meta:{retired:r.complete===true,source:s.file,capture:r},brief:'Final input is in durable custody. Delivery remains asynchronous.'};
130
+ const s=src();scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};
131
131
  }
132
132
  } else if(event==='harvest') {
133
133
  if(capturedHarvest) {
@@ -245,7 +245,27 @@ export function loadInvocationKnowledgeBinding(env=process.env) {
245
245
  let runtime;try{runtime=sourceRuntimeFromKnowledgeBinding(binding);}catch{invocationError();}
246
246
  return {kind:'captured',file,binding,runtime};
247
247
  }
248
- function problem(code) {return {code};}
248
+ // Closed vocabulary of check-phase reasons: one fixed literal per cause, so the
249
+ // operator learns WHICH qualification failed without any value, path, alias or
250
+ // caught message reaching the wire. Every literal is also in oats.json
251
+ // binding.reasons (byte-exact) — the manifest test pins that.
252
+ const checkReasons=Object.freeze({
253
+ 'action:not-admitted':'check action is not an admitted knowledge operation',
254
+ 'bindings:invalid':'bound runtime bindings file is missing or invalid',
255
+ 'bases:too-many':'more than 64 git knowledge bases declared',
256
+ 'base:stage-failed':'declared knowledge base could not be staged from its git source',
257
+ 'base:not-validated':'declared knowledge base is not a validated knowledge tree',
258
+ 'base:owner-unmet':'knowledge base owner or remote custody requirement not met',
259
+ 'base:source-mismatch':'staged git source does not match the declared knowledge base',
260
+ 'runtime:command-missing':'harvest runtime command is not installed on this host',
261
+ 'runtime:not-qualified':'harvest runtime could not be qualified against the accepted bases',
262
+ });
263
+ function problem(code,reason) {
264
+ if(reason===undefined) return {code};
265
+ if(!Object.hasOwn(checkReasons,reason)) wireError('invalid-binding');
266
+ return {code,message:checkReasons[reason]};
267
+ }
268
+ export const CHECK_REASONS=Object.values(checkReasons);
249
269
  function providerActionName(action) {
250
270
  if(action.kind==='operation' && action.slot===SLOT) return action.name;
251
271
  if(action.kind!=='command') return null;
@@ -261,19 +281,26 @@ function checkPhase(req) {
261
281
  const harvestInvocation=req.input.invocation;
262
282
  const admittedHarvest=name==='harvest' && action.kind==='operation' && action.slot==='knowledge' && action.name==='harvest'
263
283
  && harvestInvocation?.subject.kind==='persistent' && harvestInvocation.instance!==null && !!harvestInvocation.intent;
264
- if(name && (unsupportedCapturedCommands.has(name) || name==='run-source' || (name==='harvest' && !admittedHarvest))) return {status:'needs-configuration',problems:[problem('provider-not-qualified')]};
284
+ if(name && (unsupportedCapturedCommands.has(name) || name==='run-source' || (name==='harvest' && !admittedHarvest))) return {status:'needs-configuration',problems:[problem('provider-not-qualified','action:not-admitted')]};
265
285
  if(action.kind==='hook' && action.name==='soul-scaffold') return {status:'ready',problems:[]};
266
- let bindings;try{bindings=validateBindings(runtime.bindings,runtime.descriptorFile);}catch{return {status:'needs-configuration',problems:[problem('needs-configuration')]};}
286
+ let bindings;try{bindings=validateBindings(runtime.bindings,runtime.descriptorFile);}catch{return {status:'needs-configuration',problems:[problem('needs-configuration','bindings:invalid')]};}
267
287
  const accepted={},gitBases=Object.entries(bindings.bases).filter(([,base])=>base.kind==='git');
268
- if(gitBases.length>64) return {status:'unavailable',problems:[problem('provider-not-qualified')]};
269
- let scratch=null;
288
+ if(gitBases.length>64) return {status:'unavailable',problems:[problem('provider-not-qualified','bases:too-many')]};
289
+ let scratch=null,stage='base';
270
290
  try {
271
291
  if(gitBases.length) scratch=fs.mkdtempSync(join(fs.realpathSync(tmpdir()),'oats-okf-binding-check-'));
272
- for(const [alias,base] of Object.entries(bindings.bases)) accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
292
+ for(const [alias,base] of Object.entries(bindings.bases)) {
293
+ stage=base.kind==='directory'?'validate':'stage';
294
+ accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
295
+ }
296
+ stage='runtime';
273
297
  checkKnowledgeRuntime({rendered:runtime,accepted});
274
298
  } catch(error) {
275
- if(error.code==='E_COMMAND') return {status:'unavailable',problems:[problem('provider-unavailable')]};
276
- if(['E_OWNER','E_BASE','E_VALIDATION','E_DIRECTORY_GIT','E_CONFIRM'].includes(error.code)) return {status:'needs-configuration',problems:[problem('provider-not-qualified')]};
299
+ if(error.code==='E_COMMAND') return {status:'unavailable',problems:[problem('provider-unavailable','runtime:command-missing')]};
300
+ if(error.code==='E_OWNER') return {status:'needs-configuration',problems:[problem('provider-not-qualified','base:owner-unmet')]};
301
+ if(error.code==='E_CONFIRM') return {status:'needs-configuration',problems:[problem('provider-not-qualified','base:source-mismatch')]};
302
+ if(['E_BASE','E_VALIDATION','E_DIRECTORY_GIT'].includes(error.code)) return {status:'needs-configuration',problems:[problem('provider-not-qualified',stage==='stage'?'base:stage-failed':'base:not-validated')]};
303
+ if(stage==='runtime') return {status:'unavailable',problems:[problem('provider-unavailable','runtime:not-qualified')]};
277
304
  return {status:'unavailable',problems:[problem('provider-unavailable')]};
278
305
  } finally {
279
306
  if(scratch) fs.rmSync(scratch,{recursive:true,force:true});
@@ -67,6 +67,7 @@ export function loadBindings(file = settings()['bindings-file'], opts = {}) {
67
67
  export function declaration(soul) {
68
68
  if(fs.existsSync(join(soul,'.okf-cutover.json'))) fail('E_MIGRATION','incomplete explicit migration cutover: rerun its recorded migrate --cutover command');
69
69
  if(fs.existsSync(join(soul,'knowledge'))) fail('E_MIGRATION','legacy soul/knowledge exists: use oats okf migrate to preserve and stage it, then explicit cutover; no automatic loss');
70
+ if(!fs.existsSync(join(soul,'okf.json'))) fail('E_CONFIG',`soul has no okf.json: this soul reads/owns no knowledge yet. Provision it explicitly (oats okf init, or oats okf migrate for a legacy soul), or deactivate oats.okf for this soul; nothing was created`);
70
71
  return validateDeclaration(readJSON(join(soul,'okf.json')));
71
72
  }
72
73
  export function validateDeclaration(d) {
@@ -246,6 +246,21 @@ export function input(source,id) {
246
246
  const value=readJSON(join(dirname(source.file),'inputs',`${id}.json`));
247
247
  if(hash(value)!==id) fail('E_INPUT','durable evidence hash mismatch');return value;
248
248
  }
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. */
251
+ export function settleRetiredSchedule(source) {
252
+ const status=loadStatus(source);
253
+ if(status.schedule?.settled===true) return {status:'already-disabled',id:status.schedule.id};
254
+ if(!status.retired || status.auto || status.schedule?.id===undefined) return {status:'kept'};
255
+ 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};
259
+ } catch(e) {
260
+ updateStatus(source,current=>{current.schedule={...current.schedule,settled:false,settleError:e.message};});
261
+ return {status:'disable-failed',id:status.schedule.id};
262
+ }
263
+ }
249
264
  export function capture(source,{final=false,deadlineMs=85000}={}) {
250
265
  return withLock(join(dirname(source.file),'capture.lock'),()=>{
251
266
  const status=loadStatus(source);
@@ -318,7 +333,15 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
318
333
  }
319
334
  status.lastCapture={status:report.status,complete:report.complete===true,ignored:report.ignored||0,at:new Date().toISOString()};
320
335
  if(report.complete!==true) fail('E_CAPTURE',`capture ${report.status || 'uncertified'}: retain source and retry`);
321
- if(final) {status.retired=true;status.auto=status.auto && !noLaunch;status.retiredAt=new Date().toISOString();}
336
+ if(final) {
337
+ status.retired=true;status.retiredAt=new Date().toISOString();
338
+ // 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
341
+ // the job enabled until the worker drains it (see worker.mjs).
342
+ const drained=status.captured.inputs.every(id=>status.processed.includes(id));
343
+ status.auto=status.auto && !noLaunch && !drained;
344
+ }
322
345
  saveCapture(source,status);return {...status.lastCapture,inputs:status.captured.inputs.length};
323
346
  } catch(e) {
324
347
  status.lastCapture={status:'incomplete',complete:false,error:e.message,at:new Date().toISOString()}; saveCapture(source,status);throw e;
@@ -351,7 +374,9 @@ export function scheduleSource(source) {
351
374
  if(!sameJson(responsible,spec.responsibleHuman)) fail('E_SCHEDULE','captured source schedule responsible human differs');
352
375
  if(actual.execution && (actual.execution.deployment!==source.executionBinding.deployment || actual.execution.resolution?.id!==source.executionBinding.resolution.id)) fail('E_SCHEDULE','captured source schedule execution binding differs');
353
376
  }
354
- updateStatus(source,status=>{status.schedule={id:spec.id,status:'ready',result};});return result;
377
+ // A job already settled off for a retired, drained source stays settled:
378
+ // registration re-verifies the definition but does not forget the switch-off.
379
+ updateStatus(source,status=>{const settled=status.schedule?.settled===true?{settled:true,settledAt:status.schedule.settledAt}:{};status.schedule={id:spec.id,status:'ready',result,...settled};});return result;
355
380
  } catch(e) {
356
381
  updateStatus(source,status=>{status.schedule={...(status.schedule || {}),id:spec.id,status:'failed',error:e.message};});throw e;
357
382
  }