@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 +233 -20
- package/capabilities/oats-okf/bin/oats-okf.mjs +3 -3
- package/capabilities/oats-okf/lib/binding-wire.mjs +35 -8
- package/capabilities/oats-okf/lib/config.mjs +1 -0
- package/capabilities/oats-okf/lib/sources.mjs +27 -2
- package/capabilities/oats-okf/lib/worker.mjs +5 -2
- package/capabilities/oats-okf/oats.json +29 -6
- package/docs/design/2026-09-20-redesign-program-board.md +2 -2
- package/docs/design/2026-09-22-desktop-parity-seams.md +4 -4
- package/docs/desktop-cli-api.md +392 -3
- package/docs/release-notes/v0.24.7.md +2 -2
- package/docs/release-notes/v0.24.8.md +107 -0
- package/docs/release-notes/v0.24.9.md +67 -0
- package/docs/workspace-adoption.md +4 -1
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +263 -21
- package/lib/instance-events.mjs +76 -0
- package/lib/instance-git.mjs +21 -0
- package/lib/instance-lifecycle.mjs +181 -0
- package/lib/portable-soul.mjs +5 -1
- package/lib/process-group.mjs +12 -0
- package/lib/readiness.mjs +225 -0
- package/lib/schedule.mjs +22 -3
- package/package-catalog.json +1 -1
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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(
|
|
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) {
|
|
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
|
-
|
|
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
|
}
|