@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 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 = Object.keys(cap.manifest?.commands || {}).length
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 = Object.keys(m.commands || {}).length || Object.keys(m.hooks || {}).length || (m.environment?.length || 0);
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
- const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir });
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: 1, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5021
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2"], instanceGitApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 1, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
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
- function problem(code) {return {code};}
248
+ // Closed vocabulary of check-phase reasons: one fixed literal per cause, so the
249
+ // operator learns WHICH qualification failed without any value, path, alias or
250
+ // caught message reaching the wire. Every literal is also in oats.json
251
+ // binding.reasons (byte-exact) — the manifest test pins that.
252
+ const checkReasons=Object.freeze({
253
+ 'action:not-admitted':'check action is not an admitted knowledge operation',
254
+ 'bindings:invalid':'bound runtime bindings file is missing or invalid',
255
+ 'bases:too-many':'more than 64 git knowledge bases declared',
256
+ 'base:stage-failed':'declared knowledge base could not be staged from its git source',
257
+ 'base:not-validated':'declared knowledge base is not a validated knowledge tree',
258
+ 'base:owner-unmet':'knowledge base owner or remote custody requirement not met',
259
+ 'base:source-mismatch':'staged git source does not match the declared knowledge base',
260
+ 'runtime:command-missing':'harvest runtime command is not installed on this host',
261
+ 'runtime:not-qualified':'harvest runtime could not be qualified against the accepted bases',
262
+ });
263
+ function problem(code,reason) {
264
+ if(reason===undefined) return {code};
265
+ if(!Object.hasOwn(checkReasons,reason)) wireError('invalid-binding');
266
+ return {code,message:checkReasons[reason]};
267
+ }
268
+ export const CHECK_REASONS=Object.values(checkReasons);
249
269
  function providerActionName(action) {
250
270
  if(action.kind==='operation' && action.slot===SLOT) return action.name;
251
271
  if(action.kind!=='command') return null;
@@ -261,19 +281,26 @@ function checkPhase(req) {
261
281
  const harvestInvocation=req.input.invocation;
262
282
  const admittedHarvest=name==='harvest' && action.kind==='operation' && action.slot==='knowledge' && action.name==='harvest'
263
283
  && harvestInvocation?.subject.kind==='persistent' && harvestInvocation.instance!==null && !!harvestInvocation.intent;
264
- if(name && (unsupportedCapturedCommands.has(name) || name==='run-source' || (name==='harvest' && !admittedHarvest))) return {status:'needs-configuration',problems:[problem('provider-not-qualified')]};
284
+ if(name && (unsupportedCapturedCommands.has(name) || name==='run-source' || (name==='harvest' && !admittedHarvest))) return {status:'needs-configuration',problems:[problem('provider-not-qualified','action:not-admitted')]};
265
285
  if(action.kind==='hook' && action.name==='soul-scaffold') return {status:'ready',problems:[]};
266
- let bindings;try{bindings=validateBindings(runtime.bindings,runtime.descriptorFile);}catch{return {status:'needs-configuration',problems:[problem('needs-configuration')]};}
286
+ let bindings;try{bindings=validateBindings(runtime.bindings,runtime.descriptorFile);}catch{return {status:'needs-configuration',problems:[problem('needs-configuration','bindings:invalid')]};}
267
287
  const accepted={},gitBases=Object.entries(bindings.bases).filter(([,base])=>base.kind==='git');
268
- if(gitBases.length>64) return {status:'unavailable',problems:[problem('provider-not-qualified')]};
269
- let scratch=null;
288
+ if(gitBases.length>64) return {status:'unavailable',problems:[problem('provider-not-qualified','bases:too-many')]};
289
+ let scratch=null,stage='base';
270
290
  try {
271
291
  if(gitBases.length) scratch=fs.mkdtempSync(join(fs.realpathSync(tmpdir()),'oats-okf-binding-check-'));
272
- for(const [alias,base] of Object.entries(bindings.bases)) accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
292
+ for(const [alias,base] of Object.entries(bindings.bases)) {
293
+ stage=base.kind==='directory'?'validate':'stage';
294
+ accepted[alias]=(base.kind==='directory'?validateBase(base.path,base):stageBase(base,join(scratch,alias))).meta;
295
+ }
296
+ stage='runtime';
273
297
  checkKnowledgeRuntime({rendered:runtime,accepted});
274
298
  } catch(error) {
275
- if(error.code==='E_COMMAND') return {status:'unavailable',problems:[problem('provider-unavailable')]};
276
- if(['E_OWNER','E_BASE','E_VALIDATION','E_DIRECTORY_GIT','E_CONFIRM'].includes(error.code)) return {status:'needs-configuration',problems:[problem('provider-not-qualified')]};
299
+ if(error.code==='E_COMMAND') return {status:'unavailable',problems:[problem('provider-unavailable','runtime:command-missing')]};
300
+ if(error.code==='E_OWNER') return {status:'needs-configuration',problems:[problem('provider-not-qualified','base:owner-unmet')]};
301
+ if(error.code==='E_CONFIRM') return {status:'needs-configuration',problems:[problem('provider-not-qualified','base:source-mismatch')]};
302
+ if(['E_BASE','E_VALIDATION','E_DIRECTORY_GIT'].includes(error.code)) return {status:'needs-configuration',problems:[problem('provider-not-qualified',stage==='stage'?'base:stage-failed':'base:not-validated')]};
303
+ if(stage==='runtime') return {status:'unavailable',problems:[problem('provider-unavailable','runtime:not-qualified')]};
277
304
  return {status:'unavailable',problems:[problem('provider-unavailable')]};
278
305
  } finally {
279
306
  if(scratch) fs.rmSync(scratch,{recursive:true,force:true});
@@ -67,6 +67,7 @@ export function loadBindings(file = settings()['bindings-file'], opts = {}) {
67
67
  export function declaration(soul) {
68
68
  if(fs.existsSync(join(soul,'.okf-cutover.json'))) fail('E_MIGRATION','incomplete explicit migration cutover: rerun its recorded migrate --cutover command');
69
69
  if(fs.existsSync(join(soul,'knowledge'))) fail('E_MIGRATION','legacy soul/knowledge exists: use oats okf migrate to preserve and stage it, then explicit cutover; no automatic loss');
70
+ if(!fs.existsSync(join(soul,'okf.json'))) fail('E_CONFIG',`soul has no okf.json: this soul reads/owns no knowledge yet. Provision it explicitly (oats okf init, or oats okf migrate for a legacy soul), or deactivate oats.okf for this soul; nothing was created`);
70
71
  return validateDeclaration(readJSON(join(soul,'okf.json')));
71
72
  }
72
73
  export function validateDeclaration(d) {
@@ -246,6 +246,21 @@ export function input(source,id) {
246
246
  const value=readJSON(join(dirname(source.file),'inputs',`${id}.json`));
247
247
  if(hash(value)!==id) fail('E_INPUT','durable evidence hash mismatch');return value;
248
248
  }
249
+ /** Switch a retired source's job off once nothing is pending. Idempotent; a
250
+ * scheduler failure is recorded, never thrown — the evidence is already safe. */
251
+ export function settleRetiredSchedule(source) {
252
+ const status=loadStatus(source);
253
+ if(status.schedule?.settled===true) return {status:'already-disabled',id:status.schedule.id};
254
+ if(!status.retired || status.auto || status.schedule?.id===undefined) return {status:'kept'};
255
+ try {
256
+ oats(['schedule','disable',status.schedule.id,'--dir',source.context,'--json'],source.context);
257
+ updateStatus(source,current=>{current.schedule={...current.schedule,settled:true,settledAt:new Date().toISOString()};});
258
+ return {status:'disabled',id:status.schedule.id};
259
+ } catch(e) {
260
+ updateStatus(source,current=>{current.schedule={...current.schedule,settled:false,settleError:e.message};});
261
+ return {status:'disable-failed',id:status.schedule.id};
262
+ }
263
+ }
249
264
  export function capture(source,{final=false,deadlineMs=85000}={}) {
250
265
  return withLock(join(dirname(source.file),'capture.lock'),()=>{
251
266
  const status=loadStatus(source);
@@ -318,7 +333,15 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
318
333
  }
319
334
  status.lastCapture={status:report.status,complete:report.complete===true,ignored:report.ignored||0,at:new Date().toISOString()};
320
335
  if(report.complete!==true) fail('E_CAPTURE',`capture ${report.status || 'uncertified'}: retain source and retry`);
321
- if(final) {status.retired=true;status.auto=status.auto && !noLaunch;status.retiredAt=new Date().toISOString();}
336
+ if(final) {
337
+ status.retired=true;status.retiredAt=new Date().toISOString();
338
+ // A retired source whose every captured input is already processed has
339
+ // no further work: its schedule is switched off now (never deleted —
340
+ // the job definition stays as evidence). Anything still pending keeps
341
+ // the job enabled until the worker drains it (see worker.mjs).
342
+ const drained=status.captured.inputs.every(id=>status.processed.includes(id));
343
+ status.auto=status.auto && !noLaunch && !drained;
344
+ }
322
345
  saveCapture(source,status);return {...status.lastCapture,inputs:status.captured.inputs.length};
323
346
  } catch(e) {
324
347
  status.lastCapture={status:'incomplete',complete:false,error:e.message,at:new Date().toISOString()}; saveCapture(source,status);throw e;
@@ -351,7 +374,9 @@ export function scheduleSource(source) {
351
374
  if(!sameJson(responsible,spec.responsibleHuman)) fail('E_SCHEDULE','captured source schedule responsible human differs');
352
375
  if(actual.execution && (actual.execution.deployment!==source.executionBinding.deployment || actual.execution.resolution?.id!==source.executionBinding.resolution.id)) fail('E_SCHEDULE','captured source schedule execution binding differs');
353
376
  }
354
- updateStatus(source,status=>{status.schedule={id:spec.id,status:'ready',result};});return result;
377
+ // A job already settled off for a retired, drained source stays settled:
378
+ // registration re-verifies the definition but does not forget the switch-off.
379
+ updateStatus(source,status=>{const settled=status.schedule?.settled===true?{settled:true,settledAt:status.schedule.settledAt}:{};status.schedule={id:spec.id,status:'ready',result,...settled};});return result;
355
380
  } catch(e) {
356
381
  updateStatus(source,status=>{status.schedule={...(status.schedule || {}),id:spec.id,status:'failed',error:e.message};});throw e;
357
382
  }
@@ -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) return status.finalCaptureUncertified?{status:'source-unavailable',processedCapturedInput:true,finalCaptureComplete:false}:{status:'empty',processed:true};
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.2",
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": { "version": 1 }
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": { "version": 1 }
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
- {"name": "native-request", "flag": "--native-request", "required": false, "description": "Captured operations require an explicit absolute backend-only native request JSON; provider owns the worker task."},
103
- {"name": "worker-mode", "flag": "--worker-mode", "required": false, "description": "Captured worker mode: prepare or launch (default launch); legacy behavior is unchanged when absent."}
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 10:30Z (clock verified with `date -u`; the previous "22:20Z" stamp was a wall-clock error) · **Slice 2b MERGED** (PR65) · **K3 producer pins closed** (PR66 advertisement + guarded Remove; PR67 kernel-owned child stop, confirmed-branch binding, per-key stop replay, ambiguous parentage) · `oats session recompose` seam (in-place instruction refresh) · engineer wiring **2c** on `aed94b74` → 5 → 6b → 7b → 8 · lead: reviews; tag 0.24.8 when 2c lands
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
 
@@ -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. These published revisions
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.
@@ -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.2#oats-package
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 = execFileSync(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
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 = execFileSync(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
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 = shTry(`pi --list-models ${shq(id)} 2>/dev/null`) || "";
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
- const launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } });
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
- if (parentHome) appendEvent(parentHome, { kind: "child-spawn-refused", data: { child: instance, agent: agent.name, policy } });
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
- const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
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: 1, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
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 { execFileSync } from "node:child_process";
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 function signatureOf({ url, commit }, { verify = false, timeoutMs = 30000 } = {}) {
33
- if (!url || !commit || commit === "local") return { status: "not-applicable", signer: null, reason: commit === "local" ? "path-installed capability has no source commit" : "no source recorded" };
34
- if (!verify) return { status: "unknown", signer: null, reason: "signature verification needs a network fetch; pass --verify-signatures" };
35
- const dir = mkdtempSync(join(tmpdir(), "oats-sig-"));
36
- const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", LC_ALL: "C" };
37
- const git = (args) => execFileSync("git", ["-C", dir, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, env, shell: false });
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
- try { git(["fetch", "-q", "--depth", "1", url, commit]); }
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] || `git reported ${code || "nothing"}` };
50
- } catch (e) { return { status: "unknown", signer: null, reason: String(e.stderr ?? e.message ?? "").trim().slice(0, 200) || "verification failed" }; }
51
- finally { rmSync(dir, { recursive: true, force: true }); }
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 required = new Set(soulEntry?.declarations?.requires?.capabilities ? Object.keys(soulEntry.declarations.requires.capabilities) : caps.filter((c) => c.activation?.enabled).map((c) => c.id));
68
- const relevant = soul ? caps.filter((c) => required.has(c.id) || c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`))) : caps;
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
- const executable = !!(c.operations?.length || c.health?.code === "untrusted-surface" || c.health?.trusted !== undefined);
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 + runtime package requirements + layer readiness problems
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 = soul ? (c.activation?.enabled === true || c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`))) : c.activation?.enabled === true;
93
- if (required.has(c.id)) configured.push(item(`${c.id} activation`, active ? "pass" : "fail", { producer: "oats-config.yaml", reason: active ? null : `not active for ${soul ? `soul ${soul}` : "this scope"}`, evidence: { target: c.activation?.target ?? null, level: c.activation?.level ?? null }, remedy: active ? null : `oats use ${c.id}${soul ? ` --soul ${soul}` : ""}` }));
94
- 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 }));
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 verified (needs the workspace observation)",
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
- return { readinessApi: READINESS_API, subject: soul ? { kind: "soul", name: soul } : { kind: "scope", context: inspect.scope?.context ?? null }, at: new Date().toISOString(),
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 { const doc = parseYamlNested(readFileSync(file, "utf8")); return { workspace: doc.workspace && typeof doc.workspace === "object" ? doc.workspace : null, admitted: null, exports: doc.exports ?? null }; }
126
- catch { return { workspace: null, admitted: null, unreadable: true }; }
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
@@ -3,7 +3,7 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v2.1.2",
6
+ "ref": "v2.1.3",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.8",
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",