@awebai/oats 0.24.8 → 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 +52 -11
- 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 +86 -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 +60 -9
- 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
|
@@ -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,10 @@ 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")) } : {}),
|
|
4290
4326
|
});
|
|
4291
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; }
|
|
4292
4328
|
} catch (e) {
|
|
@@ -4304,6 +4340,8 @@ function spawnCmd() {
|
|
|
4304
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; }
|
|
4305
4341
|
if (e?.code === "E_CHILD_SPAWNS_DISABLED") { bail(e.code, e.message, { parent: e.parent, policy: e.policy }); throw e; }
|
|
4306
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; }
|
|
4307
4345
|
bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
|
|
4308
4346
|
}
|
|
4309
4347
|
// The instance exists from here on: a failed wake save is reported beside
|
|
@@ -4980,7 +5018,7 @@ function versionCmd() {
|
|
|
4980
5018
|
// on it (an older CLI without the surface must fail closed with a
|
|
4981
5019
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
4982
5020
|
// 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:
|
|
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"] }));
|
|
4984
5022
|
return;
|
|
4985
5023
|
}
|
|
4986
5024
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -5639,7 +5677,10 @@ Usage:
|
|
|
5639
5677
|
oats instance stop <instance> --apply --plan-revision <rev> --idempotency-key <key>
|
|
5640
5678
|
quiesce (SIGTERM, bounded, never escalated),
|
|
5641
5679
|
children first; home/work/launch retained
|
|
5642
|
-
oats readiness [--soul <n>] [--home <abs>] [--verify-signatures] [--policy] [--json]
|
|
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)
|
|
5643
5684
|
installed | trusted | configured | enrolled, each
|
|
5644
5685
|
pass|fail|unknown|not-applicable with items and
|
|
5645
5686
|
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 17:30Z · main `1b8349e5` · Slice 5 ✅ (PR71) · security follow-up ✅ (PR73) · K5 pins ✅ (PR74/75) · **K6b ✅ (PR76: `spawnPreviewApi 2` / `spawn-preview-2` — side-effect-free preview, `--agents-root`, `--expect-decision`/`E_DECISION_STALE`, bounded preflight)** · **PR77 ✅ killGroup pid-0 guard (HIGH; engineer-found)** · OKF 2.1.3 ✅ · engineer → **6b READ wiring** (API 2 gate) → 6b apply companion (proposal) → 7b → 8 · open contracts: K11 admission, attach-knowledge (OKF node refs), auto-PR (P1 write approval), branch enumeration · **0.24.9** after 6b read (release-notes file FIRST)
|
|
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,40 @@ 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
|
+
- Still absent (named follow-ups, not parity-done): attach-knowledge node refs
|
|
273
|
+
(provider contract), auto-PR (P1/ADE write approval), branch enumeration
|
|
274
|
+
(producer seam).
|
|
275
|
+
|
|
242
276
|
## Readiness quartet, signatures, enforced policy (`oats readiness`, `readinessApi: 1`, OATS 0.24.8+)
|
|
243
277
|
|
|
244
278
|
`oats readiness [--soul <name>] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json`
|
|
@@ -298,6 +332,58 @@ instance's recorded (enforced) one; with only `--soul` it is the declaration
|
|
|
298
332
|
(`enforced: false`). It is a lifecycle-authority claim, not an OS sandbox —
|
|
299
333
|
the UI says so.
|
|
300
334
|
|
|
335
|
+
### Slice-5 producer pins (0.24.9+; all additive — gate items on field presence)
|
|
336
|
+
|
|
337
|
+
- **`configured` is EFFECTIVE activation.** `activation.enabled` is the resolved
|
|
338
|
+
verdict for the subject; a capability *declared* for the soul but disabled is
|
|
339
|
+
`fail` with reason `declared for soul <n> but disabled (…)`. Declaration is
|
|
340
|
+
never activation.
|
|
341
|
+
- **Trust is not-applicable for data-only capabilities.** The inspect row now
|
|
342
|
+
carries `health.executableSurface` (manifest commands/hooks/launch env — what
|
|
343
|
+
`oats trust` approves). No surface → `trusted` item `not-applicable`, reason
|
|
344
|
+
`no executable surface`, whatever the lock records. This is why a fresh
|
|
345
|
+
`oats trust <package> --all-capabilities` "skipped" `oats.core` and readiness
|
|
346
|
+
still said fail before 0.24.9.
|
|
347
|
+
- **Typed linkage on every item**: `capability {id, level, scope}` and
|
|
348
|
+
`origin {kind: requires|declares|default|inventory, target}`; plus
|
|
349
|
+
`summary.byCapability[] {capability, origin, required, checks{installed,
|
|
350
|
+
trusted, configured, enrolled}, ownReady, ready}` — the SAME items regrouped,
|
|
351
|
+
no second observation. `ownReady` is the capability's own four verdicts;
|
|
352
|
+
`ready` is `ownReady` AND no **subject-level blocker** — items that belong to
|
|
353
|
+
no capability (workspace membership, soul declarations) are listed in
|
|
354
|
+
`summary.subjectBlockers[] {check, subject, status}` and block every row.
|
|
355
|
+
A per-capability row never says ready while the subject is blocked, and a
|
|
356
|
+
row's verdict is never promoted to the subject's `summary.ready`. Render
|
|
357
|
+
per-capability rows from this; never parse subjects.
|
|
358
|
+
- **Selector echo**: `subject.selector` = the arguments **as given, byte-exact,
|
|
359
|
+
no realpath** — `{kind:"scope", dir|null}` · `{kind:"soul", soul,
|
|
360
|
+
agentsRoot|null, dir|null}` · `{kind:"home", home, soul, agentsRoot|null}`.
|
|
361
|
+
Compare with what you sent, byte for byte; never filesystem-normalize a
|
|
362
|
+
response path. The canonical scope is `subject.context` (may differ from
|
|
363
|
+
`dir`, e.g. `/var` vs `/private/var` on macOS).
|
|
364
|
+
- **Unreadable member document** (`oats.yaml` unreadable, or `workspace:`
|
|
365
|
+
present but not a mapping) → `enrolled` item `unknown` with
|
|
366
|
+
`evidence.file`, never `not-applicable`. A declared backlink stays `unknown`
|
|
367
|
+
with reason `reciprocal admission not observed …` until the CLI fetches the
|
|
368
|
+
workspace's members (K11).
|
|
369
|
+
- **Captured homes refuse**: `readiness --home <captured>` →
|
|
370
|
+
`E_UNSUPPORTED_MODE` (`details.captured: true`) before any current-config
|
|
371
|
+
interpretation.
|
|
372
|
+
- **`--agents-root <abs>`** is accepted with `--soul` (and with `--home`), as
|
|
373
|
+
inspect takes it — pin the exact root you admitted.
|
|
374
|
+
- **Signature verification (feature `readiness-verify`)**: `--verify-signatures`
|
|
375
|
+
is bounded custody — **one total budget per readiness read** (120 s default)
|
|
376
|
+
shared by every capability's fetch and verify (an exhausted budget refuses the
|
|
377
|
+
remaining capabilities with `budget-exhausted`, no fetch), each Git child in
|
|
378
|
+
its own process group and the **whole group** SIGKILLed on timeout or failure,
|
|
379
|
+
scratch repositories removed on normal exit and on SIGINT/SIGTERM/SIGHUP, `GIT_CONFIG_GLOBAL
|
|
380
|
+
=/dev/null` + no system config + no prompts/askpass, **only https/ssh**
|
|
381
|
+
transports. `signature.failure` is `null` or `{code}` from the closed set
|
|
382
|
+
`transport-not-allowed | fetch-failed | fetch-timeout | budget-exhausted |
|
|
383
|
+
verifier-failed | verifier-timeout | cannot-check`; `signature.reason` is a
|
|
384
|
+
fixed sentence, **never stderr**. Gate the *Verify signatures…* action on the
|
|
385
|
+
feature name; keep it an explicit user action.
|
|
386
|
+
|
|
301
387
|
## Lifecycle plans — Stop and Remove (`lifecycleApi: 1`, OATS 0.24.8+)
|
|
302
388
|
|
|
303
389
|
The Desktop's Stop and Remove confirmations render **plans**: a read-only
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# OATS v0.24.9 — readiness pins, side-effect-free spawn preview (API 2), process-group safety, Desktop Readiness view
|
|
2
|
+
|
|
3
|
+
Kernel/Pi/Desktop **0.24.9**. Tag `v0.24.9` → the commit carrying these notes;
|
|
4
|
+
the version-bump commit lands after the tag. Consumers gate on `oats version
|
|
5
|
+
--json` `features[]` names and API integers — never on the version.
|
|
6
|
+
|
|
7
|
+
## Kernel
|
|
8
|
+
|
|
9
|
+
- **Spawn preview API 2** (feature `spawn-preview-2`, `spawnPreviewApi: 2`).
|
|
10
|
+
API 1 previews **wrote before they returned** — a refused child spawn appended
|
|
11
|
+
an event to the parent, a Herdr backend could be started, an unknown soul
|
|
12
|
+
could be imported from an importable def — and nothing bound a later spawn to
|
|
13
|
+
the previewed decision. API 2: a preview touches nothing, success or refusal
|
|
14
|
+
(proven by a byte-identical deployment tree); `spawn --agents-root <abs>`
|
|
15
|
+
binds the exact root with no fallback and the preview echoes `subject` as
|
|
16
|
+
given; `decision {instance, home, branch, base, revision}` + `spawn
|
|
17
|
+
--expect-decision <rev>` refuses **`E_DECISION_STALE`** with the fresh
|
|
18
|
+
decision on any drift (no auto-suffix, no silent re-base, nothing created);
|
|
19
|
+
every native probe shares one bounded preflight budget and is process-group
|
|
20
|
+
killed on timeout (`preflight {status, budgetMs, elapsedMs}`). **Gate on API
|
|
21
|
+
2 — API 1 is the pre-fix marker.**
|
|
22
|
+
- **Readiness pins** (`oats readiness`): `configured` is *effective* activation
|
|
23
|
+
(a capability declared for the soul but disabled is a fail that says so);
|
|
24
|
+
data-only capabilities (skills/inject, no commands/hooks/env) report trust
|
|
25
|
+
**not-applicable** — the inspect row carries `health.executableSurface`;
|
|
26
|
+
every item is typed (`capability {id, level, scope}`, `origin {kind,
|
|
27
|
+
target}`) and `summary.byCapability[]` regroups the same items with
|
|
28
|
+
`ownReady` vs `ready` (never ready under a `summary.subjectBlockers[]` item
|
|
29
|
+
such as unknown membership); `subject.selector` echoes the arguments as given,
|
|
30
|
+
byte-exact; an unreadable member document is `unknown`, not not-applicable;
|
|
31
|
+
`readiness --home <captured>` refuses `E_UNSUPPORTED_MODE`; `--agents-root`
|
|
32
|
+
documented. **Signature verification** (feature `readiness-verify`) is
|
|
33
|
+
bounded custody: one budget per read, process-group kill, cleanup on
|
|
34
|
+
SIGINT/SIGTERM/SIGHUP, `GIT_CONFIG_GLOBAL=/dev/null`, https/ssh only, a closed
|
|
35
|
+
`signature.failure.code` — never stderr.
|
|
36
|
+
- **Process-group safety (high).** Bounded-custody code killed a child's
|
|
37
|
+
process group on timeout with `process.kill(-child.pid)`; a **failed** spawn
|
|
38
|
+
(binary missing → `ENOENT`) reports `pid: 0`, and `kill(-0)` signals the
|
|
39
|
+
caller's own process group — `oats readiness --verify-signatures` without
|
|
40
|
+
`git` would have SIGKILLed the operator's shell/tmux/Desktop backend. One
|
|
41
|
+
guarded helper now refuses non-positive PIDs at every kill site.
|
|
42
|
+
- **OKF 2.1.3** mirrored and pinned in the official catalog: per-cause `check`
|
|
43
|
+
reasons, a named remedy when a soul has no `okf.json` (was a raw ENOENT from
|
|
44
|
+
the required spawn hook), retired drained sources switch their `okf-<id>` job
|
|
45
|
+
off.
|
|
46
|
+
|
|
47
|
+
## Desktop
|
|
48
|
+
|
|
49
|
+
- **Readiness** (frame 09/04): Workspace header entry and first-run
|
|
50
|
+
invitation; the quartet from `oats readiness` with items, remedies (display
|
|
51
|
+
only), *View policy*, *Skip for now* (presentation only). Verify signatures
|
|
52
|
+
and Enrol are shown unavailable with the exact reason until their contracts
|
|
53
|
+
land. Effective-readiness section per scope on the Capabilities view.
|
|
54
|
+
- **Normalized route classification**: one classifier decides every specialized
|
|
55
|
+
IPC route from the normalized pathname — dot-segment, percent-encoded,
|
|
56
|
+
backslash, tab and CRLF aliases can no longer skip a route's frame/epoch
|
|
57
|
+
guard.
|
|
58
|
+
- Spawn modal: kernel **preview** (API 2) for the instance name, home, worktree,
|
|
59
|
+
branch and resolved base — *Suggest* asks the kernel for its default
|
|
60
|
+
candidate; preview-only choices block legacy submission rather than being
|
|
61
|
+
dropped. Apply companion, attach-knowledge, auto-PR and branch enumeration are
|
|
62
|
+
named follow-ups, not parity-done.
|
|
63
|
+
|
|
64
|
+
## Upgrade
|
|
65
|
+
|
|
66
|
+
`npm i -g @awebai/oats@0.24.9`, then `oats doctor`. Desktops on an older CLI
|
|
67
|
+
show the new controls as unavailable until the CLI advertises them.
|
|
@@ -107,7 +107,10 @@ onboarding and legacy roster/knowledge cutover remain separate.
|
|
|
107
107
|
production store or grants are supplied. An acceptance fixture is parent-owned
|
|
108
108
|
and cannot be counted as production knowledge adoption.
|
|
109
109
|
- Current authored expert editions require knowledge **oats.okf@2.1.2** and
|
|
110
|
-
messaging **oats.aweb@1.11.2** (both OATS >=0.24.4), not optional defaults.
|
|
110
|
+
messaging **oats.aweb@1.11.2** (both OATS >=0.24.4), not optional defaults.
|
|
111
|
+
The official catalog now offers **oats.okf 2.1.3** (per-cause `check` reasons,
|
|
112
|
+
a named remedy for a soul without `okf.json`, retired sources switch their
|
|
113
|
+
job off); editions move to it when their owner re-reviews them. These published revisions
|
|
111
114
|
are **not proof that their combined bindings/runtime profile is ready**. The provider
|
|
112
115
|
owner supplies that evidence and any subsequently reviewed compatible revision.
|
|
113
116
|
Do not replace either requirement with none or erase a read edge to launch.
|
package/docs/workspaces.md
CHANGED
|
@@ -71,7 +71,7 @@ name: domain-expert
|
|
|
71
71
|
requires:
|
|
72
72
|
knowledge:
|
|
73
73
|
capability: oats.okf
|
|
74
|
-
source: git:github.com/awebai/oats-okf@v2.1.
|
|
74
|
+
source: git:github.com/awebai/oats-okf@v2.1.3#oats-package
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
This illustrates software selection, not complete OKF provisioning: the chosen capability also needs its own valid knowledge declaration, bindings and accepted base.
|
package/lib/core.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
|
|
27
27
|
* (attached as soul default is for service agents — spawn must supply workDir)
|
|
28
28
|
*/
|
|
29
|
-
import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
|
|
29
|
+
import { execFileSync, execSync, spawn as spawnProcess, spawnSync } from "node:child_process";
|
|
30
30
|
import {
|
|
31
31
|
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
32
32
|
} from "node:fs";
|
|
@@ -41,6 +41,7 @@ import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapt
|
|
|
41
41
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
42
42
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
43
43
|
import { appendEvent } from "./instance-events.mjs";
|
|
44
|
+
import { killGroup } from "./process-group.mjs";
|
|
44
45
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
45
46
|
|
|
46
47
|
import { oatsError } from "./errors.mjs";
|
|
@@ -129,6 +130,30 @@ function legacyOperationalSkills(soulDir) {
|
|
|
129
130
|
// ---------- shell helpers ----------
|
|
130
131
|
function sh(cmdline) { return execSync(cmdline, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
|
|
131
132
|
function shTry(cmdline) { try { return sh(cmdline); } catch { return undefined; } }
|
|
133
|
+
/** A native PROBE (a runtime binary asked about its catalogue or packages) under
|
|
134
|
+
* a preview: bounded by what is left of the shared preflight budget, run in its
|
|
135
|
+
* own process group and group-killed on timeout. Cheap lookups (`command -v`)
|
|
136
|
+
* are not probes and never draw from the budget. Outside a preview: shTry. */
|
|
137
|
+
function probeTry(cmdline) {
|
|
138
|
+
if (!previewPreflightBudget) return shTry(cmdline);
|
|
139
|
+
const left = previewPreflightBudget.deadline - Date.now();
|
|
140
|
+
if (left <= 0) { previewPreflightBudget.exhausted = true; return undefined; }
|
|
141
|
+
const r = spawnSync("/bin/sh", ["-c", cmdline], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/bin/false" } });
|
|
142
|
+
if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; return undefined; }
|
|
143
|
+
return r.status === 0 ? String(r.stdout).trim() : undefined;
|
|
144
|
+
}
|
|
145
|
+
let previewPreflightBudget = null;
|
|
146
|
+
/** execFileSync for a native probe: under a preview budget, the timeout is what
|
|
147
|
+
* is left of it and the child is group-killed; otherwise the caller's timeout. */
|
|
148
|
+
function probeExecFile(file, args, options = {}) {
|
|
149
|
+
if (!previewPreflightBudget) return execFileSync(file, args, options);
|
|
150
|
+
const left = previewPreflightBudget.deadline - Date.now();
|
|
151
|
+
if (left <= 0) { previewPreflightBudget.exhausted = true; throw Object.assign(new Error("preflight budget exhausted"), { code: "E_PREFLIGHT_BUDGET" }); }
|
|
152
|
+
const r = spawnSync(file, args, { ...options, timeout: Math.min(left, options.timeout ?? left), killSignal: "SIGKILL", detached: true });
|
|
153
|
+
if (r.error || r.signal) { killGroup(r); if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGKILL") previewPreflightBudget.exhausted = true; throw r.error || Object.assign(new Error("probe killed"), { code: "E_PREFLIGHT_BUDGET" }); }
|
|
154
|
+
if (r.status !== 0) throw Object.assign(new Error(`probe exited ${r.status}`), { status: r.status, stdout: r.stdout });
|
|
155
|
+
return r.stdout;
|
|
156
|
+
}
|
|
132
157
|
function shIn(cwd, cmdline, timeout = 45000) {
|
|
133
158
|
return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
|
|
134
159
|
}
|
|
@@ -4327,7 +4352,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
|
|
|
4327
4352
|
try {
|
|
4328
4353
|
// The SELECTED executable answers (a wrapper or another binary), with
|
|
4329
4354
|
// pi's own controlled list subcommand only: never a launch argument.
|
|
4330
|
-
const out =
|
|
4355
|
+
const out = probeExecFile(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
|
|
4331
4356
|
const rows = [];
|
|
4332
4357
|
// pi dims the path with chalk; strip any escapes before matching.
|
|
4333
4358
|
const lines = out.replace(/\u001b\[[0-9;]*m/g, "").split("\n");
|
|
@@ -4418,7 +4443,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
|
|
|
4418
4443
|
list: (env = process.env, opts = {}) => {
|
|
4419
4444
|
let out;
|
|
4420
4445
|
try {
|
|
4421
|
-
out =
|
|
4446
|
+
out = probeExecFile(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
|
|
4422
4447
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 60000, env });
|
|
4423
4448
|
} catch { return []; }
|
|
4424
4449
|
let rows;
|
|
@@ -5422,7 +5447,7 @@ export function resolveModelPreference(model, runtime = "pi") {
|
|
|
5422
5447
|
const [provider, ...rest] = bare.split("/");
|
|
5423
5448
|
const id = rest.join("/");
|
|
5424
5449
|
if (!id) return pref; // bare pattern (no provider) — let pi resolve it
|
|
5425
|
-
const out =
|
|
5450
|
+
const out = probeTry(`pi --list-models ${shq(id)} 2>/dev/null`) || "";
|
|
5426
5451
|
const found = out.split("\n").some((line) => {
|
|
5427
5452
|
const cols = line.trim().split(/\s+/);
|
|
5428
5453
|
return cols[0] === provider && cols[1] === id;
|
|
@@ -5865,7 +5890,14 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
5865
5890
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
5866
5891
|
// Launch selection: a named configuration (explicit, or the soul's
|
|
5867
5892
|
// launch-config default), or none; the runtime and model follow from it.
|
|
5868
|
-
|
|
5893
|
+
// K6b: a preview's native probes (model catalogue, runtime packages) share
|
|
5894
|
+
// ONE budget from here to the return; each probe is group-killed on timeout.
|
|
5895
|
+
const preflightStarted = Date.now(), preflightBudgetMs = o.preview === true ? (Number(process.env.OATS_PREVIEW_PREFLIGHT_BUDGET_MS) || 20000) : undefined;
|
|
5896
|
+
let preflight = { status: "complete", budgetMs: preflightBudgetMs ?? null };
|
|
5897
|
+
if (o.preview === true) previewPreflightBudget = { deadline: preflightStarted + preflightBudgetMs };
|
|
5898
|
+
let launchSelection;
|
|
5899
|
+
try { launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } }); }
|
|
5900
|
+
catch (e) { previewPreflightBudget = null; throw e; }
|
|
5869
5901
|
const launchConfig = launchSelection.config;
|
|
5870
5902
|
const runtime = launchSelection.runtime;
|
|
5871
5903
|
const model = launchSelection.model;
|
|
@@ -6100,7 +6132,9 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6100
6132
|
const parentMeta = parentHome && existsSync(join(parentHome, "instance.json")) ? JSON.parse(readFileSync(join(parentHome, "instance.json"), "utf8")) : anchorMeta;
|
|
6101
6133
|
const policy = childPolicyOf(parentMeta);
|
|
6102
6134
|
if (policy.allowed === false) {
|
|
6103
|
-
|
|
6135
|
+
// A preview touches nothing — not even the parent's event log; the
|
|
6136
|
+
// typed refusal IS the preview's answer.
|
|
6137
|
+
if (parentHome && o.preview !== true) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
|
|
6104
6138
|
throw Object.assign(oatsError("E_CHILD_SPAWNS_DISABLED", `${parentInstance} does not allow child spawns (policy origin: ${policy.origin?.kind ?? "recorded"}${policy.origin?.detail ? ` — ${policy.origin.detail}` : ""}); nothing was spawned. Spawn without a parent relation, or respawn the parent with --allow-child-spawns.`),
|
|
6105
6139
|
{ parent: parentInstance, policy });
|
|
6106
6140
|
}
|
|
@@ -6220,8 +6254,11 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6220
6254
|
// spawn hooks run, which happens after the home exists.
|
|
6221
6255
|
if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
|
|
6222
6256
|
const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
|
|
6223
|
-
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
|
|
6224
|
-
|
|
6257
|
+
if (launch && o.preview !== true && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
|
|
6258
|
+
// Preview never starts a backend daemon: it reports reachability as observed
|
|
6259
|
+
// (binary present?) and leaves the socket alone.
|
|
6260
|
+
const herdrBase = launch && backend === "herdr" && o.preview !== true ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
6261
|
+
if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
|
|
6225
6262
|
const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
|
|
6226
6263
|
|
|
6227
6264
|
if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
|
|
@@ -6243,8 +6280,13 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6243
6280
|
plannedBase = { ref: baseRef, oid: baseOid };
|
|
6244
6281
|
}
|
|
6245
6282
|
if (o.preview === true) {
|
|
6283
|
+
const decision = { instance, home, branch: plannedBranch, base: plannedBase };
|
|
6284
|
+
decision.revision = createHash("sha256").update(canonicalJson(decision)).digest("hex").slice(0, 24);
|
|
6246
6285
|
return {
|
|
6247
|
-
spawnPreviewApi:
|
|
6286
|
+
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6287
|
+
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
6288
|
+
decision, preflight,
|
|
6289
|
+
backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
|
|
6248
6290
|
runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
6249
6291
|
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
6250
6292
|
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
@@ -6252,6 +6294,15 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6252
6294
|
skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
|
|
6253
6295
|
};
|
|
6254
6296
|
}
|
|
6297
|
+
if (o.expectDecision !== undefined) {
|
|
6298
|
+
// A confirmed preview binds THIS apply: same name, home, branch and base
|
|
6299
|
+
// oid, recomputed here under the same placement path. Any drift is a typed
|
|
6300
|
+
// refusal carrying the fresh decision — nothing is created, nothing is
|
|
6301
|
+
// auto-suffixed or silently re-based.
|
|
6302
|
+
const fresh = { instance, home, branch: plannedBranch, base: plannedBase };
|
|
6303
|
+
fresh.revision = createHash("sha256").update(canonicalJson(fresh)).digest("hex").slice(0, 24);
|
|
6304
|
+
if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
|
|
6305
|
+
}
|
|
6255
6306
|
mkdirSync(home, { recursive: true });
|
|
6256
6307
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
6257
6308
|
// package preflight, both of which shell out — a window in which anything able
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Kill a detached child's whole process group (e.g. git + its remote helper /
|
|
2
|
+
* ssh, or a runtime probe + what it forked). ONLY for a child that was actually
|
|
3
|
+
* spawned: a failed spawn (ENOENT) reports `pid: 0`, and `process.kill(-0)` /
|
|
4
|
+
* `process.kill(0)` address the CALLER's own process group — the operator's
|
|
5
|
+
* shell, tmux session or Desktop backend. Returns whether anything was signalled. */
|
|
6
|
+
export function killGroup(child, signal = "SIGKILL") {
|
|
7
|
+
const pid = child?.pid;
|
|
8
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
9
|
+
try { process.kill(-pid, signal); } catch { /* already gone */ }
|
|
10
|
+
try { process.kill(pid, signal); } catch { /* already gone */ }
|
|
11
|
+
return true;
|
|
12
|
+
}
|
package/lib/readiness.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* executable approval, activation, runtime-package requirements, soul
|
|
8
8
|
* declarations) — never a second opinion. Unknown is unknown; "Ready" is the
|
|
9
9
|
* consumer's word and only when every required check passes. */
|
|
10
|
-
import {
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { killGroup } from "./process-group.mjs";
|
|
11
12
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
12
13
|
import { tmpdir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
@@ -29,26 +30,67 @@ const item = (subject, status, { required = true, reason = null, producer = "ker
|
|
|
29
30
|
/** Verified Git signature of a source commit, named signer or nothing.
|
|
30
31
|
* Requires network (fetch) — only when the caller asks (`verify: true`);
|
|
31
32
|
* otherwise `unknown` with the reason. Never a URL, owner or hash as signer. */
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
export const SIGNATURE_FAILURES = Object.freeze(["transport-not-allowed", "fetch-failed", "fetch-timeout", "budget-exhausted", "verifier-failed", "verifier-timeout", "cannot-check", "no-source"]);
|
|
34
|
+
const ALLOWED_TRANSPORTS = /^(https:\/\/|ssh:\/\/|git@[^/:]+:)/;
|
|
35
|
+
/** Verified Git signature of a source commit, named signer or nothing.
|
|
36
|
+
* Requires network (fetch) — only when the caller asks (`verify: true`);
|
|
37
|
+
* otherwise `unknown` with the reason. Never a URL, owner or hash as signer.
|
|
38
|
+
* Bounded custody: one total budget per call (`budgetMs`, default 60s) shared
|
|
39
|
+
* by fetch and verify; each Git child runs in its own process group and is
|
|
40
|
+
* killed with the group on timeout; the scratch repository is removed on every
|
|
41
|
+
* exit including signals; Git reads NO global/system config and cannot prompt;
|
|
42
|
+
* only https/ssh transports are fetched. Failures carry a closed `failure`
|
|
43
|
+
* code — never stderr. */
|
|
44
|
+
/** One verification budget for a whole readiness read: every signatureOf call
|
|
45
|
+
* in that read draws from it, so a target with N capabilities is bounded by the
|
|
46
|
+
* TOTAL (default 120 s), not N × per-call. */
|
|
47
|
+
export function verificationBudget(totalMs = 120000) {
|
|
48
|
+
const started = Date.now();
|
|
49
|
+
return { totalMs, remaining: () => totalMs - (Date.now() - started) };
|
|
50
|
+
}
|
|
51
|
+
const scratchDirs = new Set();
|
|
52
|
+
let signalCleanupInstalled = false;
|
|
53
|
+
function installSignalCleanup() {
|
|
54
|
+
if (signalCleanupInstalled) return; signalCleanupInstalled = true;
|
|
55
|
+
const sweep = () => { for (const d of scratchDirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } } scratchDirs.clear(); };
|
|
56
|
+
process.once("exit", sweep);
|
|
57
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(sig, () => { sweep(); process.exit(128 + (sig === "SIGINT" ? 2 : sig === "SIGTERM" ? 15 : 1)); });
|
|
58
|
+
}
|
|
59
|
+
export function signatureOf({ url, commit }, { verify = false, budgetMs = 60000, budget = null } = {}) {
|
|
60
|
+
if (!url || !commit || commit === "local") return { status: "not-applicable", signer: null, reason: commit === "local" ? "path-installed capability has no source commit" : "no source recorded", failure: null };
|
|
61
|
+
if (!verify) return { status: "unknown", signer: null, reason: "signature verification needs a network fetch; pass --verify-signatures", failure: null };
|
|
62
|
+
if (!ALLOWED_TRANSPORTS.test(url)) return { status: "unknown", signer: null, reason: "source transport is not https or ssh; not fetched", failure: { code: "transport-not-allowed" } };
|
|
63
|
+
const shared = budget ?? verificationBudget(budgetMs);
|
|
64
|
+
if (shared.remaining() <= 0) return { status: "unknown", signer: null, reason: "the verification budget was exhausted", failure: { code: "budget-exhausted" } };
|
|
65
|
+
installSignalCleanup();
|
|
66
|
+
const dir = mkdtempSync(join(tmpdir(), "oats-sig-")); scratchDirs.add(dir);
|
|
67
|
+
const cleanup = () => { scratchDirs.delete(dir); try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
68
|
+
const env = { PATH: process.env.PATH ?? "", HOME: dir, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_SSH_COMMAND: "ssh -o BatchMode=yes", LC_ALL: "C" };
|
|
69
|
+
const git = (args, stage) => {
|
|
70
|
+
const left = shared.remaining();
|
|
71
|
+
if (left <= 0) throw Object.assign(new Error("budget"), { failure: "budget-exhausted" });
|
|
72
|
+
const child = spawnSync("git", ["-C", dir, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always", ...args],
|
|
73
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env, shell: false });
|
|
74
|
+
if (child.error?.code === "ETIMEDOUT" || child.signal === "SIGKILL") { killGroup(child); throw Object.assign(new Error(stage), { failure: `${stage}-timeout` }); }
|
|
75
|
+
if (child.error || child.status !== 0) { killGroup(child); throw Object.assign(new Error(stage), { failure: `${stage}-failed` }); }
|
|
76
|
+
return child.stdout;
|
|
77
|
+
};
|
|
38
78
|
try {
|
|
39
|
-
git(["init", "-q"]);
|
|
40
|
-
|
|
41
|
-
catch (e) { return { status: "unknown", signer: null, reason: `fetch failed: ${String(e.stderr ?? e.message ?? "").trim().slice(0, 200) || "unknown"}` }; }
|
|
79
|
+
git(["init", "-q"], "verifier");
|
|
80
|
+
git(["fetch", "-q", "--depth", "1", url, commit], "fetch");
|
|
42
81
|
// %G? : G good, B bad, U good-untrusted, X expired, Y expired key, R revoked, E cannot check, N none
|
|
43
|
-
const out = git(["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", commit]).trim();
|
|
82
|
+
const out = git(["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", commit], "verifier").trim();
|
|
44
83
|
const [code, signerName, keyId, fingerprint] = out.split("\0");
|
|
45
|
-
if (code === "N") return { status: "unsigned", signer: null, reason: "commit carries no signature" };
|
|
46
|
-
if (code === "G") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: null };
|
|
47
|
-
if (code === "U") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: "good signature from a key not marked trusted in the local keyring", trust: "untrusted-key" };
|
|
48
|
-
if (code === "E") return { status: "unknown", signer: null, reason: "signature present but cannot be checked (missing public key)" };
|
|
49
|
-
return { status: "invalid", signer: null, reason: { B: "bad signature", X: "good signature that has expired", Y: "good signature made by an expired key", R: "good signature made by a revoked key" }[code] ||
|
|
50
|
-
} catch (e) {
|
|
51
|
-
|
|
84
|
+
if (code === "N") return { status: "unsigned", signer: null, reason: "commit carries no signature", failure: null };
|
|
85
|
+
if (code === "G") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: null, failure: null };
|
|
86
|
+
if (code === "U") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: "good signature from a key not marked trusted in the local keyring", trust: "untrusted-key", failure: null };
|
|
87
|
+
if (code === "E") return { status: "unknown", signer: null, reason: "signature present but cannot be checked (missing public key)", failure: { code: "cannot-check" } };
|
|
88
|
+
return { status: "invalid", signer: null, reason: { B: "bad signature", X: "good signature that has expired", Y: "good signature made by an expired key", R: "good signature made by a revoked key" }[code] || "verifier reported an unrecognised state", failure: null };
|
|
89
|
+
} catch (e) {
|
|
90
|
+
const code = SIGNATURE_FAILURES.includes(e.failure) ? e.failure : "verifier-failed";
|
|
91
|
+
const reasons = { "fetch-failed": "the source could not be fetched", "fetch-timeout": "the fetch exceeded the verification budget", "budget-exhausted": "the verification budget was exhausted", "verifier-failed": "the local verifier failed", "verifier-timeout": "the local verifier exceeded the verification budget" };
|
|
92
|
+
return { status: "unknown", signer: null, reason: reasons[code], failure: { code } };
|
|
93
|
+
} finally { cleanup(); }
|
|
52
94
|
}
|
|
53
95
|
|
|
54
96
|
function sourceOfCapability(cap, catalog) {
|
|
@@ -61,37 +103,58 @@ function sourceOfCapability(cap, catalog) {
|
|
|
61
103
|
}
|
|
62
104
|
|
|
63
105
|
/** The quartet for a scope or one soul, from an inspect result. */
|
|
64
|
-
export function readinessOf(inspect, { soul = null, verifySignatures = false, catalog = null, deploymentDir = null, memberDocument = null } = {}) {
|
|
106
|
+
export function readinessOf(inspect, { soul = null, verifySignatures = false, catalog = null, deploymentDir = null, memberDocument = null, selector = null, verificationBudgetMs = 120000 } = {}) {
|
|
65
107
|
const caps = inspect.capabilities || [];
|
|
108
|
+
const budget = verifySignatures ? verificationBudget(verificationBudgetMs) : null;
|
|
66
109
|
const soulEntry = soul ? (inspect.souls || []).find((s) => s.name === soul) : null;
|
|
67
|
-
const
|
|
68
|
-
const
|
|
110
|
+
const declaredRequires = soulEntry?.declarations?.requires?.capabilities ? Object.keys(soulEntry.declarations.requires.capabilities) : null;
|
|
111
|
+
const required = new Set(declaredRequires ?? caps.filter((c) => c.activation?.enabled).map((c) => c.id));
|
|
112
|
+
const declaredForSoul = (c) => !!c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`));
|
|
113
|
+
const relevant = soul ? caps.filter((c) => required.has(c.id) || declaredForSoul(c)) : caps;
|
|
114
|
+
// Typed linkage for consumers (frame-level per-capability rows): WHICH
|
|
115
|
+
// capability, at which config level/scope, and WHY it is in this quartet.
|
|
116
|
+
const capabilityOf = (c) => ({ id: c.id, level: c.activation?.level ?? null, scope: c.activation?.target ?? null });
|
|
117
|
+
const originOf = (c) => declaredRequires?.includes(c.id) ? { kind: "requires", target: `soul:${soul}` }
|
|
118
|
+
: soul && declaredForSoul(c) ? { kind: "declares", target: `soul:${soul}` }
|
|
119
|
+
: c.activation?.enabled ? { kind: "default", target: c.activation?.target ?? "global" } : { kind: "inventory", target: null };
|
|
120
|
+
const typed = (c) => ({ capability: capabilityOf(c), origin: originOf(c) });
|
|
69
121
|
|
|
70
122
|
// installed — the artifact's bytes are present and locked with matching integrity
|
|
71
123
|
const installed = relevant.map((c) => {
|
|
72
124
|
const ok = c.health?.installed === true && (c.health.integrity == null || c.health.installedIntegrity == null || c.health.integrity === c.health.installedIntegrity);
|
|
73
125
|
return item(c.id, ok ? "pass" : c.health?.installed === false ? "fail" : "unknown", { required: required.has(c.id), producer: "oats list",
|
|
74
126
|
reason: ok ? null : c.health?.installed === false ? "not acquired" : c.health?.code || "integrity drift", evidence: { version: c.version ?? null, integrity: c.health?.integrity ?? null, origin: c.origin ?? null },
|
|
75
|
-
remedy: ok ? null : `oats install ${c.package || c.id}
|
|
127
|
+
remedy: ok ? null : `oats install ${c.package || c.id}`, ...typed(c) });
|
|
76
128
|
});
|
|
77
|
-
for (const id of required) if (!caps.some((c) => c.id === id)) installed.push(item(id, "fail", { producer: "soul declaration", reason: "declared by the soul but not in the inventory", remedy: `oats install <package providing ${id}
|
|
129
|
+
for (const id of required) if (!caps.some((c) => c.id === id)) installed.push(item(id, "fail", { producer: "soul declaration", reason: "declared by the soul but not in the inventory", remedy: `oats install <package providing ${id}>`,
|
|
130
|
+
capability: { id, level: null, scope: null }, origin: { kind: "requires", target: `soul:${soul}` } }));
|
|
78
131
|
|
|
79
132
|
// trusted — executable approval of the exact artifact; signature separately
|
|
80
133
|
const trusted = relevant.map((c) => {
|
|
81
|
-
|
|
134
|
+
// The inspect row states whether the manifest has anything trust approves
|
|
135
|
+
// (commands/hooks/launch env). No surface → trust is not-applicable, however
|
|
136
|
+
// the lock records it. Older rows without the flag fall back to the old heuristic.
|
|
137
|
+
const executable = typeof c.health?.executableSurface === "boolean" ? c.health.executableSurface
|
|
138
|
+
: !!(c.operations?.length || c.health?.code === "untrusted-surface");
|
|
82
139
|
const approved = c.health?.trusted === true;
|
|
83
|
-
const sig = signatureOf(sourceOfCapability(c, catalog), { verify: verifySignatures });
|
|
140
|
+
const sig = signatureOf(sourceOfCapability(c, catalog), { verify: verifySignatures, budget });
|
|
84
141
|
return item(c.id, !executable ? "not-applicable" : approved ? "pass" : c.health?.trusted === false ? "fail" : "unknown", { required: required.has(c.id), producer: "artifact approval",
|
|
85
142
|
reason: !executable ? "no executable surface" : approved ? null : "executable surface not approved", evidence: { integrity: c.health?.integrity ?? null }, remedy: approved || !executable ? null : `oats trust ${c.id}`,
|
|
86
|
-
signature: sig });
|
|
143
|
+
signature: sig, ...typed(c) });
|
|
87
144
|
});
|
|
88
145
|
|
|
89
|
-
// configured — activation for the subject
|
|
146
|
+
// configured — EFFECTIVE activation for the subject (a declaration at the
|
|
147
|
+
// soul is not activation: `enabled` is the resolved verdict for this subject,
|
|
148
|
+
// and a declared-but-disabled binding is a fail that says so) + runtime
|
|
149
|
+
// package requirements + layer readiness problems
|
|
90
150
|
const configured = [];
|
|
91
151
|
for (const c of relevant) {
|
|
92
|
-
const active =
|
|
93
|
-
|
|
94
|
-
|
|
152
|
+
const active = c.activation?.enabled === true;
|
|
153
|
+
const declaredOnly = !active && soul && declaredForSoul(c);
|
|
154
|
+
if (required.has(c.id)) configured.push(item(`${c.id} activation`, active ? "pass" : "fail", { producer: "oats-config.yaml",
|
|
155
|
+
reason: active ? null : declaredOnly ? `declared for soul ${soul} but disabled${c.activation?.reason ? ` (${c.activation.reason})` : ""}` : `not active for ${soul ? `soul ${soul}` : "this scope"}`,
|
|
156
|
+
evidence: { target: c.activation?.target ?? null, level: c.activation?.level ?? null, source: c.activation?.source ?? null }, remedy: active ? null : `oats use ${c.id}${soul ? ` --soul ${soul}` : ""}`, ...typed(c) }));
|
|
157
|
+
for (const miss of c.missingRequires || []) configured.push(item(`${c.id} requires ${miss.command}`, "fail", { producer: "capability manifest", reason: miss.why || "required command not on PATH", remedy: miss.install || null, ...typed(c) }));
|
|
95
158
|
}
|
|
96
159
|
for (const p of inspect.problems || []) if (/runtime package|DISABLED|extension/i.test(p.message || "")) configured.push(item(p.capability || p.code, "fail", { producer: "runtime settings", reason: p.message, remedy: null }));
|
|
97
160
|
if (soulEntry && soulEntry.readiness?.status === "undeclared") configured.push(item(`${soul} declarations`, "not-applicable", { required: false, producer: "soul.yaml", reason: "no requirements declared" }));
|
|
@@ -101,16 +164,32 @@ export function readinessOf(inspect, { soul = null, verifySignatures = false, ca
|
|
|
101
164
|
const enrolled = [];
|
|
102
165
|
const member = memberDocument ?? readMemberDocument(deploymentDir);
|
|
103
166
|
if (!member) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "standalone deployment: no workspace declared in oats.yaml" }));
|
|
167
|
+
else if (member.unreadable) enrolled.push(item("workspace membership", "unknown", { producer: "oats.yaml", reason: "workspace member document is unreadable; membership cannot be stated", evidence: { file: member.file ?? null }, remedy: "repair oats.yaml (valid YAML) and re-run" }));
|
|
104
168
|
else if (!member.workspace?.source) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "oats.yaml declares exports but no workspace backlink" }));
|
|
105
169
|
else enrolled.push(item(`member of ${member.workspace.source}`, member.admitted === true ? "pass" : member.admitted === false ? "fail" : "unknown", { producer: "workspace discovery",
|
|
106
|
-
reason: member.admitted === true ? null : member.admitted === false ? "this repository is not admitted in the workspace's members" : "admission not
|
|
170
|
+
reason: member.admitted === true ? null : member.admitted === false ? "this repository is not admitted in the workspace's members" : "reciprocal admission not observed: this CLI reads the backlink but does not yet fetch the workspace's members (declared, not verified)",
|
|
107
171
|
evidence: { workspace: member.workspace.source, revision: member.workspace.revision ?? null }, remedy: member.admitted === true ? null : "ask the workspace maintainer to admit this repository (oats-workspace.yaml members) — enrolment is admission, not login" }));
|
|
108
172
|
|
|
109
173
|
const checks = { installed: { status: roll(installed), items: installed }, trusted: { status: roll(trusted), items: trusted }, configured: { status: roll(configured), items: configured }, enrolled: { status: roll(enrolled), items: enrolled } };
|
|
110
174
|
const requiredStatuses = Object.values(checks).flatMap((c) => c.items.filter((i) => i.required).map((i) => i.status));
|
|
111
|
-
|
|
175
|
+
// Per-capability grouping of the same items (no second observation): each
|
|
176
|
+
// capability's four verdicts, ready only if all its REQUIRED items pass.
|
|
177
|
+
const byCapability = [...new Set(Object.values(checks).flatMap((c) => c.items.map((i) => i.capability?.id).filter(Boolean)))].sort().map((id) => {
|
|
178
|
+
const of = (name) => checks[name].items.filter((i) => i.capability?.id === id);
|
|
179
|
+
const statuses = Object.keys(checks).flatMap((name) => of(name).filter((i) => i.required).map((i) => i.status));
|
|
180
|
+
const any = Object.keys(checks).flatMap((name) => of(name)).find(Boolean);
|
|
181
|
+
return { capability: any?.capability ?? { id, level: null, scope: null }, origin: any?.origin ?? null, required: statuses.length > 0,
|
|
182
|
+
checks: Object.fromEntries(Object.keys(checks).map((name) => [name, of(name).length ? roll(of(name)) : "not-applicable"])),
|
|
183
|
+
ownReady: statuses.length > 0 && statuses.every((s) => s === "pass" || s === "not-applicable") };
|
|
184
|
+
});
|
|
185
|
+
// Items that belong to no capability (workspace membership, soul declarations)
|
|
186
|
+
// are SUBJECT-level: a capability's own verdict never overrides them, and a
|
|
187
|
+
// row must not read "ready" while the subject is blocked by one of them.
|
|
188
|
+
const subjectBlockers = Object.entries(checks).flatMap(([name, c]) => c.items.filter((i) => i.required && !i.capability && i.status !== "pass" && i.status !== "not-applicable").map((i) => ({ check: name, subject: i.subject, status: i.status })));
|
|
189
|
+
for (const g of byCapability) g.ready = g.ownReady && subjectBlockers.length === 0;
|
|
190
|
+
return { readinessApi: READINESS_API, subject: { ...(soul ? { kind: "soul", name: soul } : { kind: "scope", context: inspect.scope?.context ?? null }), ...(selector ? { selector } : {}) }, at: new Date().toISOString(),
|
|
112
191
|
checks, summary: { ready: requiredStatuses.length > 0 && requiredStatuses.every((s) => s === "pass" || s === "not-applicable"), required: requiredStatuses.length,
|
|
113
|
-
pass: requiredStatuses.filter((s) => s === "pass").length, fail: requiredStatuses.filter((s) => s === "fail").length, unknown: requiredStatuses.filter((s) => s === "unknown").length },
|
|
192
|
+
pass: requiredStatuses.filter((s) => s === "pass").length, fail: requiredStatuses.filter((s) => s === "fail").length, unknown: requiredStatuses.filter((s) => s === "unknown").length, byCapability, subjectBlockers },
|
|
114
193
|
notes: [
|
|
115
194
|
...(verifySignatures ? [] : ["signatures are unknown until --verify-signatures (network fetch)"]),
|
|
116
195
|
"ready means every REQUIRED check passes; it is never inferred from an empty set",
|
|
@@ -122,8 +201,13 @@ function readMemberDocument(deploymentDir) {
|
|
|
122
201
|
if (!deploymentDir) return null;
|
|
123
202
|
const file = join(deploymentDir, "oats.yaml");
|
|
124
203
|
if (!existsSync(file)) return null;
|
|
125
|
-
try {
|
|
126
|
-
|
|
204
|
+
try {
|
|
205
|
+
const doc = parseYamlNested(readFileSync(file, "utf8"));
|
|
206
|
+
// The lenient parser never throws: a `workspace:` that is present but not a
|
|
207
|
+
// mapping is a document we cannot read a membership from — unreadable, not absent.
|
|
208
|
+
if (doc.workspace !== undefined && (doc.workspace === null || typeof doc.workspace !== "object")) return { workspace: null, admitted: null, unreadable: true, file };
|
|
209
|
+
return { workspace: doc.workspace && typeof doc.workspace === "object" ? doc.workspace : null, admitted: null, exports: doc.exports ?? null };
|
|
210
|
+
} catch { return { workspace: null, admitted: null, unreadable: true, file }; }
|
|
127
211
|
}
|
|
128
212
|
|
|
129
213
|
/** Enforced policy for an instance (from its recorded metadata) or a soul
|
package/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.9",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|