@awebai/oats 0.24.8 → 0.24.10
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 +69 -13
- 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 +1 -1
- package/docs/desktop-cli-api.md +138 -0
- package/docs/release-notes/v0.24.10.md +55 -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 +124 -11
- package/lib/process-group.mjs +12 -0
- package/lib/readiness.mjs +119 -35
- 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, stopInstanceSession, recomposeInstanceInstructions,
|
|
33
|
+
findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions, refreshRetirementBaselineHome,
|
|
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";
|
|
@@ -600,6 +600,12 @@ function officialMigrationState(legacyLocks, { teamScope, ctx }) {
|
|
|
600
600
|
* nearer scope's package of the same id — a provider that never exported it. */
|
|
601
601
|
const levelRows = (locks, level) => locks.levels.find((l) => l.level === level) || { packages: Object.create(null), capabilities: Object.create(null) };
|
|
602
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
|
+
}
|
|
603
609
|
function capabilityHealth(level, cap, capRow, pkgRow) {
|
|
604
610
|
const dir = installedCapabilityDir(level, cap.id);
|
|
605
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` };
|
|
@@ -615,9 +621,7 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
|
|
|
615
621
|
try { verifyCapabilityInstallation(dir, cap.id, capRow, pkgRow); }
|
|
616
622
|
catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
|
|
617
623
|
}
|
|
618
|
-
const executable =
|
|
619
|
-
|| Object.keys(cap.manifest?.hooks || {}).length
|
|
620
|
-
|| (cap.manifest?.environment?.length || 0);
|
|
624
|
+
const executable = hasExecutableSurface(cap.manifest);
|
|
621
625
|
if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats trust ${cap.id}\`` };
|
|
622
626
|
return { status: "ok", code: null, dir, integrity, detail: null };
|
|
623
627
|
}
|
|
@@ -945,7 +949,7 @@ function computeInspect({ onFail } = {}) {
|
|
|
945
949
|
byId.set(c.id, {
|
|
946
950
|
id: c.id, package: p.package, version: c.version || null, layer: c.manifest?.layer || null, command: c.manifest?.command || null,
|
|
947
951
|
origin: "installed", level: p.level, source: p.source || null, commit: p.commit ?? rows.packages[p.package]?.commit ?? null, dir: h.dir,
|
|
948
|
-
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 },
|
|
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 },
|
|
949
953
|
});
|
|
950
954
|
}
|
|
951
955
|
}
|
|
@@ -953,13 +957,13 @@ function computeInspect({ onFail } = {}) {
|
|
|
953
957
|
for (const [id, m] of Object.entries(mans)) {
|
|
954
958
|
if (byId.has(id)) continue;
|
|
955
959
|
const trust = capabilityTrust(m, ctx);
|
|
956
|
-
const executable =
|
|
960
|
+
const executable = hasExecutableSurface(m);
|
|
957
961
|
let integrity = trust.integrity || null;
|
|
958
962
|
if (!integrity) { try { integrity = capabilityArtifactIntegrity(m._dir); } catch { integrity = null; } }
|
|
959
963
|
byId.set(id, {
|
|
960
964
|
id, package: m._package || null, version: m.version || null, layer: m.layer || null, command: m.command || null,
|
|
961
965
|
origin: String(m._origin || "").split(":")[0] || "unknown", level: String(m._origin || "").split(":").slice(1).join(":") || null, source: null, dir: m._dir,
|
|
962
|
-
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 },
|
|
963
967
|
});
|
|
964
968
|
}
|
|
965
969
|
// What is EFFECTIVE for the answer: a home's captured bindings and settings
|
|
@@ -3215,17 +3219,33 @@ function instanceCmd() {
|
|
|
3215
3219
|
bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
|
|
3216
3220
|
}
|
|
3217
3221
|
}
|
|
3218
|
-
/** `oats readiness [--soul <name>] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
|
|
3222
|
+
/** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
|
|
3219
3223
|
function readinessCmd() {
|
|
3220
3224
|
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
3221
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
|
+
}
|
|
3222
3233
|
const inspect = computeInspect({ onFail: bail });
|
|
3223
3234
|
if (!inspect) return;
|
|
3224
3235
|
const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
|
|
3225
3236
|
const verify = args.includes("--verify-signatures");
|
|
3226
3237
|
let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
|
|
3227
3238
|
const deploymentDir = inspect.scope?.context ?? null;
|
|
3228
|
-
|
|
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 });
|
|
3229
3249
|
if (args.includes("--policy")) {
|
|
3230
3250
|
const homeOpt = flag("home");
|
|
3231
3251
|
let meta = null;
|
|
@@ -4162,7 +4182,21 @@ function spawnCmd() {
|
|
|
4162
4182
|
let root;
|
|
4163
4183
|
try { root = ensureRoot(dirFlag()); }
|
|
4164
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
|
+
}
|
|
4165
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");
|
|
4166
4200
|
const instrFile = flag("instructions-file");
|
|
4167
4201
|
const defFile = flag("def-file");
|
|
4168
4202
|
if (!agent && !instrFile && !defFile) {
|
|
@@ -4285,8 +4319,12 @@ function spawnCmd() {
|
|
|
4285
4319
|
// K6: --preview decides everything and touches nothing; --base <ref>
|
|
4286
4320
|
// selects a worktree's start point; --model @native-default is the
|
|
4287
4321
|
// explicit "runtime's own default" (distinct from omitting --model).
|
|
4288
|
-
...(args.includes("--preview") ? { preview: true } : {}),
|
|
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 } } : {}),
|
|
4289
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")) } : {}),
|
|
4326
|
+
// K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
|
|
4327
|
+
...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
|
|
4290
4328
|
});
|
|
4291
4329
|
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; }
|
|
4292
4330
|
} catch (e) {
|
|
@@ -4304,14 +4342,26 @@ function spawnCmd() {
|
|
|
4304
4342
|
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; }
|
|
4305
4343
|
if (e?.code === "E_CHILD_SPAWNS_DISABLED") { bail(e.code, e.message, { parent: e.parent, policy: e.policy }); throw e; }
|
|
4306
4344
|
if (["E_BRANCH_EXISTS", "E_BASE_UNKNOWN"].includes(e?.code)) { bail(e.code, e.message); throw e; }
|
|
4345
|
+
// K6b: the confirmed decision drifted — the fresh decision travels with the refusal so a GUI re-previews.
|
|
4346
|
+
if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
|
|
4347
|
+
if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
|
|
4348
|
+
if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
|
|
4349
|
+
if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
|
|
4307
4350
|
bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
|
|
4308
4351
|
}
|
|
4309
4352
|
// The instance exists from here on: a failed wake save is reported beside
|
|
4310
4353
|
// the full receipt, never hidden, and never causes a second spawn.
|
|
4311
4354
|
let wakeSchedule, wakeScheduleError;
|
|
4312
|
-
if (wake) {
|
|
4355
|
+
if (wake && r.replayed !== true) { // a replayed receipt re-saves nothing
|
|
4313
4356
|
try { wakeSchedule = saveWakeForHome(scheduleScopeOf(workspaceOf(root)), { instance: r.instance, home: r.home, wake }); }
|
|
4314
4357
|
catch (e) { wakeScheduleError = { code: e.code || "E_SCHEDULE_FAILED", message: e.message }; r.warnings = [...(r.warnings || []), `wake schedule NOT saved: ${e.message}`]; }
|
|
4358
|
+
// K6e: record the wake outcome in the home so a same-key replay can report
|
|
4359
|
+
// it instead of leaving "saved or not?" to inference.
|
|
4360
|
+
if (r.spawnIdempotencyKey) {
|
|
4361
|
+
try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: true, saved: !wakeScheduleError, error: wakeScheduleError ?? null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; refreshRetirementBaselineHome(r.home); } catch { /* the receipt still says it */ }
|
|
4362
|
+
}
|
|
4363
|
+
} else if (r.spawnIdempotencyKey && r.replayed !== true) {
|
|
4364
|
+
try { const f = join(r.home, "instance.json"); const m = JSON.parse(readFileSync(f, "utf8")); m.wake = { requested: false, saved: null, error: null }; writeFileSync(f, JSON.stringify(m, null, 2) + "\n"); r.wake = m.wake; refreshRetirementBaselineHome(r.home); } catch { /* nothing to record */ }
|
|
4315
4365
|
}
|
|
4316
4366
|
if (JSON_MODE) {
|
|
4317
4367
|
// Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
|
|
@@ -4325,6 +4375,9 @@ function spawnCmd() {
|
|
|
4325
4375
|
spawnOrigin: r.spawnOrigin, attach: r.attach,
|
|
4326
4376
|
...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
|
|
4327
4377
|
...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
|
|
4378
|
+
// K6b/K6c: what bound this spawn, and whether this receipt is a replay of an earlier one.
|
|
4379
|
+
...(r.decision ? { decision: r.decision } : {}), ...(r.replayed !== undefined ? { replayed: r.replayed } : {}),
|
|
4380
|
+
...(r.wake !== undefined ? { wake: r.wake } : {}), // {requested, saved|null, error}: saved:null = outcome not recorded
|
|
4328
4381
|
launchConfig: r.launch?.launchConfig ?? null, launch: r.launch || null, // already redacted by the kernel
|
|
4329
4382
|
});
|
|
4330
4383
|
return;
|
|
@@ -4980,7 +5033,7 @@ function versionCmd() {
|
|
|
4980
5033
|
// on it (an older CLI without the surface must fail closed with a
|
|
4981
5034
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
4982
5035
|
// must see before relying on them (retire-home: retire --home).
|
|
4983
|
-
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"], instanceGitApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi:
|
|
5036
|
+
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", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 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"] }));
|
|
4984
5037
|
return;
|
|
4985
5038
|
}
|
|
4986
5039
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -5639,7 +5692,10 @@ Usage:
|
|
|
5639
5692
|
oats instance stop <instance> --apply --plan-revision <rev> --idempotency-key <key>
|
|
5640
5693
|
quiesce (SIGTERM, bounded, never escalated),
|
|
5641
5694
|
children first; home/work/launch retained
|
|
5642
|
-
oats readiness [--soul <n>] [--home <abs>] [--verify-signatures] [--policy] [--json]
|
|
5695
|
+
oats readiness [--soul <n> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--json]
|
|
5696
|
+
quartet installed|trusted|configured|enrolled for a scope,
|
|
5697
|
+
a soul, or an instance home (captured homes refuse:
|
|
5698
|
+
their readiness is the retained resolution's)
|
|
5643
5699
|
installed | trusted | configured | enrolled, each
|
|
5644
5700
|
pass|fail|unknown|not-applicable with items and
|
|
5645
5701
|
remedies; signature status per artifact; enforced
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, oats, command, fail, relPath } from './io.mjs';
|
|
4
|
-
import { loadSource, loadStatus, saveStatus, updateStatus, capture, input, markerPath, homeSource } from './sources.mjs';
|
|
4
|
+
import { loadSource, loadStatus, saveStatus, updateStatus, capture, input, markerPath, homeSource, settleRetiredSchedule } from './sources.mjs';
|
|
5
5
|
import {capturedSource,qualifyCapturedWorker,assertCapturedRun,capturedScaffold,retainCapturedWorkerCustody,assertCapturedWorkerHome,capturedStart} from './captured-worker.mjs';
|
|
6
6
|
import { metadata, splitRef } from './config.mjs';
|
|
7
7
|
import { stageBase, validateBase, allowedChanges, verifyGitScope, gitPublish, directoryPublish, journalPath, baseLock, recoveryStage, reconcileDirectoryIntent, gitRecoveryState } from './stores.mjs';
|
|
@@ -63,7 +63,10 @@ export function runSource(source,{noLaunch=false,manual=false,capturedInvocation
|
|
|
63
63
|
const previous=status.pendingRejudgment?readRun(source,status.pendingRejudgment):null;
|
|
64
64
|
if(previous) checkRecoveryGuards(source,previous);
|
|
65
65
|
const ids=previous?previous.inputs:status.captured.inputs.filter(id=>!status.processed.includes(id));
|
|
66
|
-
if(!ids.length)
|
|
66
|
+
if(!ids.length) {
|
|
67
|
+
if(status.retired && !status.finalCaptureUncertified) {updateStatus(source,current=>{current.auto=false;});settleRetiredSchedule(source);}
|
|
68
|
+
return status.finalCaptureUncertified?{status:'source-unavailable',processedCapturedInput:true,finalCaptureComplete:false}:{status:'empty',processed:true};
|
|
69
|
+
}
|
|
67
70
|
const selected=[];let bytes=0;
|
|
68
71
|
for(const id of ids) {const n=Buffer.byteLength(JSON.stringify(input(source,id)));if(selected.length && bytes+n>192000) break;selected.push(id);bytes+=n;}
|
|
69
72
|
if(!source.decl.owns.length) fail('E_OWNER','source has evidence but owns no destination; retained for explicit ownership routing');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"capability": "oats.okf",
|
|
3
3
|
"command": "okf",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.3",
|
|
5
5
|
"compatibility": {
|
|
6
6
|
"oats": ">=0.24.4"
|
|
7
7
|
},
|
|
@@ -62,7 +62,16 @@
|
|
|
62
62
|
"setting state-dir must be a normalized absolute host path",
|
|
63
63
|
"setting harvest-runtime is required (pi, claude or codex)",
|
|
64
64
|
"setting harvest-runtime must be pi, claude or codex",
|
|
65
|
-
"setting harvest-model must be null or a non-empty string"
|
|
65
|
+
"setting harvest-model must be null or a non-empty string",
|
|
66
|
+
"check action is not an admitted knowledge operation",
|
|
67
|
+
"bound runtime bindings file is missing or invalid",
|
|
68
|
+
"more than 64 git knowledge bases declared",
|
|
69
|
+
"declared knowledge base could not be staged from its git source",
|
|
70
|
+
"declared knowledge base is not a validated knowledge tree",
|
|
71
|
+
"knowledge base owner or remote custody requirement not met",
|
|
72
|
+
"staged git source does not match the declared knowledge base",
|
|
73
|
+
"harvest runtime command is not installed on this host",
|
|
74
|
+
"harvest runtime could not be qualified against the accepted bases"
|
|
66
75
|
]
|
|
67
76
|
},
|
|
68
77
|
"inject": "injects/okf.md",
|
|
@@ -76,13 +85,17 @@
|
|
|
76
85
|
"command": "bin/oats-okf.mjs spawn",
|
|
77
86
|
"required": true,
|
|
78
87
|
"inputs": {
|
|
79
|
-
"sourceReceipt": {
|
|
88
|
+
"sourceReceipt": {
|
|
89
|
+
"version": 1
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
92
|
},
|
|
82
93
|
"retire": {
|
|
83
94
|
"command": "bin/oats-okf.mjs retire",
|
|
84
95
|
"inputs": {
|
|
85
|
-
"sourceReceipt": {
|
|
96
|
+
"sourceReceipt": {
|
|
97
|
+
"version": 1
|
|
98
|
+
}
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
},
|
|
@@ -99,8 +112,18 @@
|
|
|
99
112
|
"context": "home",
|
|
100
113
|
"description": "Capture this source into durable custody and request an independent worker.",
|
|
101
114
|
"args": [
|
|
102
|
-
{
|
|
103
|
-
|
|
115
|
+
{
|
|
116
|
+
"name": "native-request",
|
|
117
|
+
"flag": "--native-request",
|
|
118
|
+
"required": false,
|
|
119
|
+
"description": "Captured operations require an explicit absolute backend-only native request JSON; provider owns the worker task."
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "worker-mode",
|
|
123
|
+
"flag": "--worker-mode",
|
|
124
|
+
"required": false,
|
|
125
|
+
"description": "Captured worker mode: prepare or launch (default launch); legacy behavior is unchanged when absent."
|
|
126
|
+
}
|
|
104
127
|
]
|
|
105
128
|
}
|
|
106
129
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
|
|
4
4
|
|
|
5
|
-
**Last update:** 2026-09-22
|
|
5
|
+
**Last update:** 2026-09-22 19:10Z · **v0.24.9 PUBLISHED** (tag `04a4f709`; both packages on npm, tarball shasum `6333c01c`; bump PR79 → main `a5cc390e`; artifact probe: readiness/preview API 2/bound apply/E_DECISION_STALE/retire retention/no-git verify all pass, caller alive, soul intact) · **6b READ MERGED** (PR78) · K6b ✅ · PR77 ✅ · engineer → **6b APPLY companion proposal** → 7b → 8 · open contracts: K11, attach-knowledge, auto-PR, branch enumeration
|
|
6
6
|
|
|
7
7
|
Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
|
|
8
8
|
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -239,6 +239,92 @@ executable, runtime packages, child-spawn policy) and returns what the spawn
|
|
|
239
239
|
knowledge provider (05 excluded, attach stays), auto-PR intent (ADE-owned,
|
|
240
240
|
P1).
|
|
241
241
|
|
|
242
|
+
### Preview API 2 (0.24.9+, feature `spawn-preview-2`) — the safe-mode fence
|
|
243
|
+
|
|
244
|
+
**API 1 previews wrote before they returned** (a refused child spawn appended an
|
|
245
|
+
event to the parent; a Herdr backend could be started; an unknown soul could be
|
|
246
|
+
imported from an importable def). A consumer must therefore gate on
|
|
247
|
+
**`spawnPreviewApi === 2` AND `features.includes("spawn-preview-2")`** — API 1 is
|
|
248
|
+
the pre-fix marker and is never accepted for dispatch.
|
|
249
|
+
|
|
250
|
+
- **No writes, success or refusal.** A preview appends no event, starts no
|
|
251
|
+
daemon (`backendStatus {name, installed, started:false}` reports what it
|
|
252
|
+
observed), and never creates/updates a soul (`E_SOUL_UNKNOWN` instead of an
|
|
253
|
+
import; `--instructions-file`/`--def-file` refused with `E_BAD_ARGS`). Test:
|
|
254
|
+
the deployment tree is byte-identical after a success, a refusal and an
|
|
255
|
+
unknown-soul preview.
|
|
256
|
+
- **Exact root**: `spawn <soul> --agents-root <abs>` binds the soul to that root
|
|
257
|
+
(as inspect/readiness take it) — no team-soul / capability-agent / importable-
|
|
258
|
+
def fallback; mismatch → `E_SOUL_UNKNOWN`. The preview echoes
|
|
259
|
+
`subject {soul, agentsRoot|null, dir|null}` **as given, byte-exact**.
|
|
260
|
+
- **Decision binding**: `decision {instance, home, branch, base{ref,oid},
|
|
261
|
+
revision}` (24-hex). Apply with `spawn … --expect-decision <revision>`: the
|
|
262
|
+
kernel recomputes name/home/branch/base under the same placement path and
|
|
263
|
+
refuses **`E_DECISION_STALE`** with `details.decision` (the fresh one) on ANY
|
|
264
|
+
drift — no auto-suffix, no silent re-base, nothing created. A GUI re-previews
|
|
265
|
+
and re-confirms; it never second-guesses names or paths. Without the flag the
|
|
266
|
+
CLI keeps its legacy auto-suffix for humans.
|
|
267
|
+
- **Bounded preflight**: every native probe a preview runs (`pi --list-models`,
|
|
268
|
+
`pi list`, `claude plugin list`) shares ONE budget (20 s default), runs in its
|
|
269
|
+
own process group and is group-killed on timeout; `preflight {status:
|
|
270
|
+
complete|timeout, budgetMs, elapsedMs}` says which. A hanging runtime cannot
|
|
271
|
+
hang a preview.
|
|
272
|
+
- **Confirmed apply contract** (0.24.10+, feature `spawn-apply-2`,
|
|
273
|
+
`spawnApplyApi: 1`) — what a GUI may promise at "Confirm spawn":
|
|
274
|
+
- `decision` gains **`effective {repo, work, runtime, model, launchConfig,
|
|
275
|
+
yolo, backend, childSpawns, relation{kind, anchor{instance, agentsRoot}}}`**
|
|
276
|
+
and `revision` hashes placement + effective. An inherited default that would
|
|
277
|
+
change what launches (the soul's model edited between preview and apply,
|
|
278
|
+
say) → `E_DECISION_STALE`. A GUI does not re-resolve anything itself.
|
|
279
|
+
- **No effect before the fence**: backend presence and `ensureHerdr` run only
|
|
280
|
+
AFTER a successful `--expect-decision` binding and after the placement
|
|
281
|
+
reservation. A stale apply with `--backend herdr` starts nothing. (The
|
|
282
|
+
parent-policy refusal still appends `child-spawn-refused` to the PARENT's
|
|
283
|
+
log on a non-preview apply — that is an audit of a real refusal, not an
|
|
284
|
+
effect on the target.)
|
|
285
|
+
- **Exclusive placement**: the home is reserved with a non-recursive `mkdir`
|
|
286
|
+
immediately after the decision check; a concurrent spawn that lost refuses
|
|
287
|
+
**`E_PLACEMENT_TAKEN`** having touched nothing. Two concurrent applies of
|
|
288
|
+
one decision yield exactly one home. There is no wider lock; this
|
|
289
|
+
reservation is the guarantee.
|
|
290
|
+
- Gate confirmation AND the exec owner on `spawn-preview-2` +
|
|
291
|
+
`spawn-apply-2` + `spawn-idempotency`; a legacy local request on such a CLI
|
|
292
|
+
is refused by the Desktop (`E_PLAN_REQUIRED`), not routed around the fence.
|
|
293
|
+
- **Replay custody** (0.24.10+, feature **`spawn-idempotency-2`** — gate on
|
|
294
|
+
this, not on `spawn-idempotency`, whose replay could be blocked by
|
|
295
|
+
`E_BRANCH_EXISTS`): key recovery runs **first**, right after the name is
|
|
296
|
+
decided and before any placement/branch/base/preflight/backend work — so a
|
|
297
|
+
retry of a spawn that created its explicit branch still reaches its receipt.
|
|
298
|
+
The key-bearing home records `spawnCompleted:false` at its first write and
|
|
299
|
+
`true` only after launch + lineage + final events; a same-key retry of an
|
|
300
|
+
unfinished spawn refuses **`E_SPAWN_INCOMPLETE`** (`details.{instance, home,
|
|
301
|
+
launched}`; remedy is the session surface, never another spawn). The key
|
|
302
|
+
lives in the home by design: durable across the GUI's restart, gone with a
|
|
303
|
+
retired home — after a retire, "check result" is a roster question. The wake
|
|
304
|
+
outcome is recorded (`wake {requested, saved, error}`) and returned on
|
|
305
|
+
replay; `saved:null` means *not recorded* (crash in the interval) — render
|
|
306
|
+
"Agent created; wake outcome unavailable — check Schedules", never
|
|
307
|
+
saved/not-saved without the record.
|
|
308
|
+
- **Retention stays clean**: the completion marker and the wake record are
|
|
309
|
+
kernel writes to `instance.json` made after the spawn's retirement baseline;
|
|
310
|
+
the kernel re-stamps the baseline's home fingerprint after each, so a fresh
|
|
311
|
+
keyed home retires with **no** `changed instance-home bytes` — only the
|
|
312
|
+
agent's own changes ever read as work to recover.
|
|
313
|
+
- **Idempotent apply** (0.24.10+, feature `spawn-idempotency`): `spawn …
|
|
314
|
+
--expect-decision <rev> --idempotency-key <key>` records the key and the
|
|
315
|
+
decision in the new home's `instance.json`; a **retry with the same key**
|
|
316
|
+
replays the recorded receipt (`replayed: true`, same instance/home, no second
|
|
317
|
+
spawn, no wake re-saved) — found by key across the soul's instances, never by
|
|
318
|
+
name (the planned name may have been auto-suffixed past it, which is exactly
|
|
319
|
+
the retry case). The same key with a *different* decision refuses
|
|
320
|
+
**`E_IDEMPOTENCY_CONFLICT`** (`details.instance/home` of the prior spawn); a
|
|
321
|
+
different key with a fresh decision is a genuinely new confirmation. Mint the
|
|
322
|
+
key server-side on the first confirmation and keep it for that intent's
|
|
323
|
+
retries (as 2c does); a lost response is a replay, never a guess by name.
|
|
324
|
+
- Still absent (named follow-ups, not parity-done): attach-knowledge node refs
|
|
325
|
+
(provider contract), auto-PR (P1/ADE write approval), branch enumeration
|
|
326
|
+
(producer seam).
|
|
327
|
+
|
|
242
328
|
## Readiness quartet, signatures, enforced policy (`oats readiness`, `readinessApi: 1`, OATS 0.24.8+)
|
|
243
329
|
|
|
244
330
|
`oats readiness [--soul <name>] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json`
|
|
@@ -298,6 +384,58 @@ instance's recorded (enforced) one; with only `--soul` it is the declaration
|
|
|
298
384
|
(`enforced: false`). It is a lifecycle-authority claim, not an OS sandbox —
|
|
299
385
|
the UI says so.
|
|
300
386
|
|
|
387
|
+
### Slice-5 producer pins (0.24.9+; all additive — gate items on field presence)
|
|
388
|
+
|
|
389
|
+
- **`configured` is EFFECTIVE activation.** `activation.enabled` is the resolved
|
|
390
|
+
verdict for the subject; a capability *declared* for the soul but disabled is
|
|
391
|
+
`fail` with reason `declared for soul <n> but disabled (…)`. Declaration is
|
|
392
|
+
never activation.
|
|
393
|
+
- **Trust is not-applicable for data-only capabilities.** The inspect row now
|
|
394
|
+
carries `health.executableSurface` (manifest commands/hooks/launch env — what
|
|
395
|
+
`oats trust` approves). No surface → `trusted` item `not-applicable`, reason
|
|
396
|
+
`no executable surface`, whatever the lock records. This is why a fresh
|
|
397
|
+
`oats trust <package> --all-capabilities` "skipped" `oats.core` and readiness
|
|
398
|
+
still said fail before 0.24.9.
|
|
399
|
+
- **Typed linkage on every item**: `capability {id, level, scope}` and
|
|
400
|
+
`origin {kind: requires|declares|default|inventory, target}`; plus
|
|
401
|
+
`summary.byCapability[] {capability, origin, required, checks{installed,
|
|
402
|
+
trusted, configured, enrolled}, ownReady, ready}` — the SAME items regrouped,
|
|
403
|
+
no second observation. `ownReady` is the capability's own four verdicts;
|
|
404
|
+
`ready` is `ownReady` AND no **subject-level blocker** — items that belong to
|
|
405
|
+
no capability (workspace membership, soul declarations) are listed in
|
|
406
|
+
`summary.subjectBlockers[] {check, subject, status}` and block every row.
|
|
407
|
+
A per-capability row never says ready while the subject is blocked, and a
|
|
408
|
+
row's verdict is never promoted to the subject's `summary.ready`. Render
|
|
409
|
+
per-capability rows from this; never parse subjects.
|
|
410
|
+
- **Selector echo**: `subject.selector` = the arguments **as given, byte-exact,
|
|
411
|
+
no realpath** — `{kind:"scope", dir|null}` · `{kind:"soul", soul,
|
|
412
|
+
agentsRoot|null, dir|null}` · `{kind:"home", home, soul, agentsRoot|null}`.
|
|
413
|
+
Compare with what you sent, byte for byte; never filesystem-normalize a
|
|
414
|
+
response path. The canonical scope is `subject.context` (may differ from
|
|
415
|
+
`dir`, e.g. `/var` vs `/private/var` on macOS).
|
|
416
|
+
- **Unreadable member document** (`oats.yaml` unreadable, or `workspace:`
|
|
417
|
+
present but not a mapping) → `enrolled` item `unknown` with
|
|
418
|
+
`evidence.file`, never `not-applicable`. A declared backlink stays `unknown`
|
|
419
|
+
with reason `reciprocal admission not observed …` until the CLI fetches the
|
|
420
|
+
workspace's members (K11).
|
|
421
|
+
- **Captured homes refuse**: `readiness --home <captured>` →
|
|
422
|
+
`E_UNSUPPORTED_MODE` (`details.captured: true`) before any current-config
|
|
423
|
+
interpretation.
|
|
424
|
+
- **`--agents-root <abs>`** is accepted with `--soul` (and with `--home`), as
|
|
425
|
+
inspect takes it — pin the exact root you admitted.
|
|
426
|
+
- **Signature verification (feature `readiness-verify`)**: `--verify-signatures`
|
|
427
|
+
is bounded custody — **one total budget per readiness read** (120 s default)
|
|
428
|
+
shared by every capability's fetch and verify (an exhausted budget refuses the
|
|
429
|
+
remaining capabilities with `budget-exhausted`, no fetch), each Git child in
|
|
430
|
+
its own process group and the **whole group** SIGKILLed on timeout or failure,
|
|
431
|
+
scratch repositories removed on normal exit and on SIGINT/SIGTERM/SIGHUP, `GIT_CONFIG_GLOBAL
|
|
432
|
+
=/dev/null` + no system config + no prompts/askpass, **only https/ssh**
|
|
433
|
+
transports. `signature.failure` is `null` or `{code}` from the closed set
|
|
434
|
+
`transport-not-allowed | fetch-failed | fetch-timeout | budget-exhausted |
|
|
435
|
+
verifier-failed | verifier-timeout | cannot-check`; `signature.reason` is a
|
|
436
|
+
fixed sentence, **never stderr**. Gate the *Verify signatures…* action on the
|
|
437
|
+
feature name; keep it an explicit user action.
|
|
438
|
+
|
|
301
439
|
## Lifecycle plans — Stop and Remove (`lifecycleApi: 1`, OATS 0.24.8+)
|
|
302
440
|
|
|
303
441
|
The Desktop's Stop and Remove confirmations render **plans**: a read-only
|