@awebai/oats 0.24.7 → 0.24.8

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.
@@ -0,0 +1,181 @@
1
+ /** K3 — lifecycle plans for the Desktop's Stop and Remove confirmations.
2
+ *
3
+ * A plan is a read-only statement of what an action WOULD touch, with the
4
+ * facts a human needs to decide (session state, children, dirty work) and a
5
+ * `planRevision` hashed from the facts that make the action safe. Apply
6
+ * takes that revision back: if reality moved, apply refuses with the fresh
7
+ * plan instead of acting on a world the human did not see. Idempotency keys
8
+ * make a retried apply return the first receipt rather than act twice.
9
+ * Unknown is reported as unknown — never as "clean" or "no children". */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { basename, join } from "node:path";
13
+ import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, retirePendingMarkerPath, stopInstanceSession, teamAgentRoots, resolveOatsConfig } from "./core.mjs";
14
+ import { observeInstanceGit } from "./instance-git.mjs";
15
+ import { appendEvent } from "./instance-events.mjs";
16
+ import { oatsError } from "./errors.mjs";
17
+
18
+ export const LIFECYCLE_API = 1;
19
+ const KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
20
+ const stopPendingPath = (home) => join(home, ".oats-stop-pending.json");
21
+ const stopReceiptPath = (home, key) => join(home, `.oats-stop-receipt.${key}.json`);
22
+
23
+ function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } }
24
+ function planRevision(facts) { return createHash("sha256").update(JSON.stringify(facts)).digest("hex").slice(0, 24); }
25
+
26
+ /** Resolve `<name>` under the scope's agents roots to exactly one home. */
27
+ export function resolveInstance(ctx, root, name, { home } = {}) {
28
+ let r; try { r = resolveOatsConfig(ctx); } catch { r = {}; }
29
+ const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])])];
30
+ const candidates = [];
31
+ for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
32
+ if (home !== undefined) {
33
+ const found = candidates.find((c) => c.home === home);
34
+ if (!found) throw oatsError("E_HOME_MISMATCH", `--home ${home} is not a home of instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
35
+ return found;
36
+ }
37
+ if (!candidates.length) throw oatsError("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
38
+ if (candidates.length > 1) throw Object.assign(oatsError("E_AMBIGUOUS_INSTANCE", `instance ${JSON.stringify(name)} has ${candidates.length} homes; pass --home <abs>`), { candidates });
39
+ return candidates[0];
40
+ }
41
+
42
+ /** Every instance under the roots whose recorded parent chain reaches `name`,
43
+ * deepest first (children are acted on before their parent). Recorded
44
+ * parentage is the only relation the kernel knows; it is reported as such. */
45
+ export function descendantsOf(root, name) {
46
+ const rows = [];
47
+ for (const a of listAgents(root)) for (const i of listInstances(root).filter((x) => x.name === a.name)) for (const inst of i.instances || []) {
48
+ const home = join(a._dir, "instances", inst.instance);
49
+ const meta = readJson(join(home, "instance.json"));
50
+ if (meta) rows.push({ instance: inst.instance, agent: a.name, home, parent: meta.parentInstance ?? null });
51
+ }
52
+ // Recorded parentage is a BARE name, and names are unique only per agent
53
+ // directory. An edge whose parent name resolves to several homes under this
54
+ // root is ambiguous: it is reported, never acted on (nothing is inferred).
55
+ const homesByName = new Map();
56
+ for (const r of rows) { if (!homesByName.has(r.instance)) homesByName.set(r.instance, []); homesByName.get(r.instance).push(r); }
57
+ const byParent = new Map();
58
+ const ambiguous = [];
59
+ for (const r of rows) {
60
+ if (r.parent && (homesByName.get(r.parent) || []).length > 1) { ambiguous.push({ ...r, reason: `parent name ${JSON.stringify(r.parent)} resolves to ${homesByName.get(r.parent).length} homes; edge not followed` }); continue; }
61
+ if (!byParent.has(r.parent)) byParent.set(r.parent, []); byParent.get(r.parent).push(r);
62
+ }
63
+ const out = [];
64
+ const walk = (parent, depth) => { for (const c of byParent.get(parent) || []) { if (c.instance === parent) continue; walk(c.instance, depth + 1); out.push({ ...c, depth }); } };
65
+ walk(name, 1);
66
+ out.ambiguous = ambiguous.filter((r) => r.parent === name); // edges pointing at THIS name that could not be followed
67
+ return out; // deepest first
68
+ }
69
+
70
+ /** `state` is the session backend's word: "shell"/"stopped"/"not-launched" are
71
+ * idle; "unknown" means a NON-shell process is running whose identity tmux
72
+ * cannot name (the ordinary case for a launched harness). "Could not be
73
+ * established" is a different thing and is `present: null` with a reason. */
74
+ function sessionFacts(home) {
75
+ try { const s = inspectInstanceSession(home); return { state: s.state, present: s.present, backend: s.backend ?? null, established: true }; }
76
+ catch (e) { return { state: "unestablished", present: null, backend: null, established: false, reason: e.code || "E_SESSION_UNAVAILABLE" }; }
77
+ }
78
+ const running = (s) => s.established && s.present === true && !["shell", "stopped", "not-launched"].includes(s.state);
79
+ function workFacts(home) {
80
+ if (!existsSync(join(home, "work"))) return { observed: false, reason: "no-worktree" };
81
+ try {
82
+ const g = observeInstanceGit(home);
83
+ return { observed: true, revision: g.observation.revision, branch: g.observation.branch, detached: g.observation.detached, drift: g.recorded.drift,
84
+ changed: g.summary.changed + g.summary.renamed + g.summary.copied + g.summary.unmerged, untracked: g.summary.untracked,
85
+ upstream: g.upstream, base: { ref: g.base.ref, ahead: g.base.ahead, behind: g.base.behind }, remote: g.remote ? { host: g.remote.host, path: g.remote.path } : null };
86
+ } catch (e) { return { observed: false, reason: e.code || "E_GIT_FAILED" }; }
87
+ }
88
+ function targetFacts(row) {
89
+ const meta = readJson(join(row.home, "instance.json")) || {};
90
+ return { instance: row.instance, agent: row.agent, home: row.home, depth: row.depth ?? 0, workMode: meta.work ?? null, launched: meta.launched === true,
91
+ session: sessionFacts(row.home), work: workFacts(row.home),
92
+ retiring: existsSync(retirePendingMarkerPath(row.home)), stopPending: existsSync(stopPendingPath(row.home)) };
93
+ }
94
+
95
+ /** Facts for Stop: the instance, its recorded descendants (deepest first),
96
+ * each with session state and dirty-work counts. `midTask` is REPORTED
97
+ * activity: dirty work or a running session; unknown says unknown. */
98
+ export function planStop(ctx, root, name, { home, recursive = true } = {}) {
99
+ const me = resolveInstance(ctx, root, name, { home });
100
+ const kids = recursive ? descendantsOf(me.root, name) : [];
101
+ const targets = [...kids.map(targetFacts), targetFacts({ ...me, instance: name, depth: 0 })];
102
+ // Reported activity: a running session or dirty work is mid-task; when the
103
+ // session could not be established and work is not observed, say unknown.
104
+ for (const t of targets) t.midTask = running(t.session) || (t.work.observed && t.work.changed + t.work.untracked > 0) ? true : (!t.session.established || !t.work.observed) ? "unknown" : false;
105
+ const safety = targets.map((t) => [t.home, t.session.state, t.session.present, t.launched, t.retiring]);
106
+ const ambiguousEdges = (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason }));
107
+ const plan = { lifecycleApi: LIFECYCLE_API, action: "stop", instance: name, home: me.home, recursive, at: new Date().toISOString(),
108
+ targets, skipped: recursive ? [] : descendantsOf(me.root, name).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: "recursive=false" })),
109
+ ambiguous: ambiguousEdges,
110
+ planRevision: planRevision(safety),
111
+ notes: [...(ambiguousEdges.length ? [`${ambiguousEdges.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
112
+ ...(targets.some((t) => t.retiring) ? ["a target is being retired; apply will refuse it"] : []), ...(targets.some((t) => !t.session.established) ? ["a session state could not be established; it is reported unestablished, not idle"] : [])] };
113
+ return plan;
114
+ }
115
+
116
+ /** Apply a Stop plan: revalidate the revision, take the stop-pending marker
117
+ * per target (refusing retiring targets), stop deepest-first, record a
118
+ * receipt keyed by idempotency key. A retried apply with the same key
119
+ * returns the recorded receipt without acting again. */
120
+ export function applyStop(ctx, root, name, { home, recursive = true, planRevision: expected, idempotencyKey, graceMs } = {}) {
121
+ if (typeof expected !== "string" || !expected) throw oatsError("E_BAD_ARGS", "apply needs --plan-revision from a prior `--plan`");
122
+ if (typeof idempotencyKey !== "string" || !KEY.test(idempotencyKey)) throw oatsError("E_BAD_ARGS", "apply needs --idempotency-key (1-128 chars of [A-Za-z0-9._:-])");
123
+ const plan = planStop(ctx, root, name, { home, recursive });
124
+ const prior = readJson(stopReceiptPath(plan.home, idempotencyKey));
125
+ if (prior && prior.idempotencyKey === idempotencyKey) return { ...prior, replayed: true };
126
+ if (plan.planRevision !== expected) throw Object.assign(oatsError("E_PLAN_STALE", `the stop plan changed since it was shown (${expected} → ${plan.planRevision}); review the fresh plan`), { plan });
127
+ const retiring = plan.targets.filter((t) => t.retiring);
128
+ if (retiring.length) throw Object.assign(oatsError("E_INSTANCE_RETIRING", `${retiring.map((t) => t.instance).join(", ")} ${retiring.length === 1 ? "is" : "are"} being retired; nothing was stopped`), { plan });
129
+ const pending = plan.targets.filter((t) => t.stopPending);
130
+ if (pending.length) throw Object.assign(oatsError("E_LIFECYCLE_BUSY", `${pending.map((t) => t.instance).join(", ")} already ${pending.length === 1 ? "has" : "have"} a stop in progress`), { plan });
131
+ const results = [];
132
+ const marker = { action: "stop", idempotencyKey, planRevision: expected, at: new Date().toISOString() };
133
+ for (const t of plan.targets) { try { writeFileSyncAtomic(stopPendingPath(t.home), marker); } catch { /* best effort marker; the lock is advisory across our own apply paths */ } }
134
+ try {
135
+ for (const t of plan.targets) {
136
+ try {
137
+ const r = stopInstanceSession(t.home, { graceMs });
138
+ results.push({ instance: t.instance, home: t.home, ok: true, stopped: r.stopped, alreadyIdle: r.alreadyIdle, state: r.state });
139
+ } catch (e) {
140
+ results.push({ instance: t.instance, home: t.home, ok: false, code: e.code || "E_SESSION_STOP_FAILED", message: e.message, stillRunning: e.receipt?.stillRunning ?? null });
141
+ }
142
+ }
143
+ } finally { for (const t of plan.targets) rmSync(stopPendingPath(t.home), { force: true }); }
144
+ const receipt = { lifecycleApi: LIFECYCLE_API, action: "stop", instance: name, home: plan.home, idempotencyKey, planRevision: expected, at: new Date().toISOString(),
145
+ ok: results.every((r) => r.ok), results, retained: ["home", "work", "transcript", "launch"], replayed: false };
146
+ // One receipt file per key: a retry of ANY earlier key replays its own
147
+ // receipt for as long as the home exists (the replay horizon).
148
+ try { writeFileSyncAtomic(stopReceiptPath(plan.home, idempotencyKey), receipt); } catch { /* receipt is evidence, not authority */ }
149
+ return receipt;
150
+ }
151
+
152
+ function writeFileSyncAtomic(path, value) {
153
+ const tmp = `${path}.${process.pid}.tmp`;
154
+ writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
155
+ }
156
+
157
+ /** Facts for Remove (retire): what retirement would touch, with the design's
158
+ * defaults (retain worktree and branch; never touch a PR). `oats retire`
159
+ * itself applies them: plain retire re-homes the worktree; --discard-worktree
160
+ * / --delete-branch are the dialog's opt-ins. */
161
+ export function planRetire(ctx, root, name, { home } = {}) {
162
+ const me = resolveInstance(ctx, root, name, { home });
163
+ const kids = descendantsOf(me.root, name);
164
+ const target = targetFacts({ ...me, instance: name, depth: 0 });
165
+ const meta = readJson(join(me.home, "instance.json")) || {};
166
+ const facts = { session: target.session, work: target.work, workMode: meta.work ?? null, repo: meta.repo ?? null, recordedBranch: meta.branch ?? null,
167
+ children: kids.map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, session: sessionFacts(c.home) })),
168
+ ambiguous: (kids.ambiguous || []).map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, reason: c.reason })),
169
+ pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
170
+ const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
171
+ const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)];
172
+ appendEvent(me.home, { kind: "retire-planned", data: { planRevision: planRevision(safety), children: kids.length, dirty: facts.work.observed ? facts.work.changed + facts.work.untracked : null } }, { workspaceOnly: true });
173
+ return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
174
+ notes: [
175
+ ...(facts.work.observed && facts.work.drift ? [`the worktree is on ${facts.work.branch}, not the recorded ${facts.recordedBranch}; branch actions use the worktree's branch`] : []),
176
+ ...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
177
+ ...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
178
+ ...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
179
+ "pull request state is unknown to the kernel; the ADE reports it when a forge connection exists",
180
+ ] };
181
+ }
@@ -9,7 +9,7 @@ export { FUNDAMENTAL_SLOTS } from "./portable-policy.mjs";
9
9
  const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
10
  const ALIAS = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
11
11
  const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
12
- const ROOT_FIELDS = ["schemaVersion", "name", "description", "requires", "defaults", "knowledge", "teams", "resources", "work", "runtime", "model", "yolo", "launch-config", "backend"];
12
+ const ROOT_FIELDS = ["schemaVersion", "name", "description", "requires", "defaults", "knowledge", "teams", "resources", "work", "runtime", "model", "yolo", "launch-config", "backend", "children"];
13
13
 
14
14
  export function parsePortableSoul(input, { origin = null, localBase, allowLocalPaths = false, limits } = {}) {
15
15
  const parsed = parseConfigData(input, { origin, limits });
@@ -34,5 +34,9 @@ export function parsePortableSoul(input, { origin = null, localBase, allowLocalP
34
34
  if (Object.hasOwn(value, "launch-config")) stringAt(value["launch-config"], "/launch-config", { pattern: LAUNCH_CONFIG_NAME });
35
35
  if (Object.hasOwn(value, "backend") && !["tmux", "herdr"].includes(value.backend)) invalidShape("/backend", "unsupported session backend");
36
36
  if (Object.hasOwn(value, "yolo") && typeof value.yolo !== "boolean") invalidShape("/yolo", "expected boolean");
37
+ if (Object.hasOwn(value, "children")) {
38
+ const children = objectAt(value.children, ["spawn"], [], "/children");
39
+ if (Object.hasOwn(children, "spawn") && typeof children.spawn !== "boolean") invalidShape("/children/spawn", "expected boolean");
40
+ }
37
41
  return { declaration: value, sources, origins: parsed.origins, integrity: parsed.integrity };
38
42
  }
@@ -0,0 +1,141 @@
1
+ /** K5 — readiness quartet, signature status and enforced policy, as DATA.
2
+ *
3
+ * `installed | trusted | configured | enrolled`, each `pass | fail | unknown |
4
+ * not-applicable` with items a human can act on: subject, whether it is
5
+ * required, the reason, the producer of the fact, evidence, remedy. Every fact
6
+ * is derived from what inspect already reports (capability inventory, health,
7
+ * executable approval, activation, runtime-package requirements, soul
8
+ * declarations) — never a second opinion. Unknown is unknown; "Ready" is the
9
+ * consumer's word and only when every required check passes. */
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { parseYamlNested } from "./core.mjs";
15
+
16
+ export const READINESS_API = 1;
17
+
18
+ function roll(items, { required = (i) => i.required !== false } = {}) {
19
+ const req = items.filter(required);
20
+ if (!items.length) return "not-applicable";
21
+ if (req.some((i) => i.status === "fail")) return "fail";
22
+ if (req.some((i) => i.status === "unknown")) return "unknown";
23
+ if (req.every((i) => i.status === "pass" || i.status === "not-applicable")) return req.length ? "pass" : "not-applicable";
24
+ return "unknown";
25
+ }
26
+ const item = (subject, status, { required = true, reason = null, producer = "kernel", evidence = null, remedy = null, ...rest } = {}) =>
27
+ ({ subject, status, required, reason, producer, evidence, remedy, ...rest });
28
+
29
+ /** Verified Git signature of a source commit, named signer or nothing.
30
+ * Requires network (fetch) — only when the caller asks (`verify: true`);
31
+ * 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 });
38
+ 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"}` }; }
42
+ // %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();
44
+ 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 }); }
52
+ }
53
+
54
+ function sourceOfCapability(cap, catalog) {
55
+ // cap.source is e.g. "catalog:oats.framework" or "git:https://…@ref#path"; the
56
+ // installed record's package commit is what we would verify.
57
+ const src = String(cap.source || "");
58
+ if (src.startsWith("git:")) { const m = /^git:([^@#]+)(?:@([^#]+))?/.exec(src); return { url: m?.[1] ?? null, commit: cap.commit ?? m?.[2] ?? null }; }
59
+ if (src.startsWith("catalog:") && catalog) { const entry = catalog.packages?.[src.slice(8)]; return { url: entry?.url ?? null, commit: cap.commit ?? null }; }
60
+ return { url: null, commit: cap.commit ?? null };
61
+ }
62
+
63
+ /** 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 } = {}) {
65
+ const caps = inspect.capabilities || [];
66
+ 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;
69
+
70
+ // installed — the artifact's bytes are present and locked with matching integrity
71
+ const installed = relevant.map((c) => {
72
+ const ok = c.health?.installed === true && (c.health.integrity == null || c.health.installedIntegrity == null || c.health.integrity === c.health.installedIntegrity);
73
+ return item(c.id, ok ? "pass" : c.health?.installed === false ? "fail" : "unknown", { required: required.has(c.id), producer: "oats list",
74
+ 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}` });
76
+ });
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}>` }));
78
+
79
+ // trusted — executable approval of the exact artifact; signature separately
80
+ const trusted = relevant.map((c) => {
81
+ const executable = !!(c.operations?.length || c.health?.code === "untrusted-surface" || c.health?.trusted !== undefined);
82
+ const approved = c.health?.trusted === true;
83
+ const sig = signatureOf(sourceOfCapability(c, catalog), { verify: verifySignatures });
84
+ return item(c.id, !executable ? "not-applicable" : approved ? "pass" : c.health?.trusted === false ? "fail" : "unknown", { required: required.has(c.id), producer: "artifact approval",
85
+ 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 });
87
+ });
88
+
89
+ // configured — activation for the subject + runtime package requirements + layer readiness problems
90
+ const configured = [];
91
+ 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 }));
95
+ }
96
+ 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
+ if (soulEntry && soulEntry.readiness?.status === "undeclared") configured.push(item(`${soul} declarations`, "not-applicable", { required: false, producer: "soul.yaml", reason: "no requirements declared" }));
98
+
99
+ // enrolled — workspace member admission (decision §3): not-applicable for
100
+ // standalone; pass/fail when this repository declares a workspace.
101
+ const enrolled = [];
102
+ const member = memberDocument ?? readMemberDocument(deploymentDir);
103
+ if (!member) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "standalone deployment: no workspace declared in oats.yaml" }));
104
+ 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
+ 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)",
107
+ 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
+
109
+ 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
+ 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(),
112
+ 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 },
114
+ notes: [
115
+ ...(verifySignatures ? [] : ["signatures are unknown until --verify-signatures (network fetch)"]),
116
+ "ready means every REQUIRED check passes; it is never inferred from an empty set",
117
+ "enrolment is workspace member admission, not native login or team registration",
118
+ ] };
119
+ }
120
+
121
+ function readMemberDocument(deploymentDir) {
122
+ if (!deploymentDir) return null;
123
+ const file = join(deploymentDir, "oats.yaml");
124
+ 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 }; }
127
+ }
128
+
129
+ /** Enforced policy for an instance (from its recorded metadata) or a soul
130
+ * (its declaration + default), with origins. Advisory/unknown stays unknown. */
131
+ export function policyOf({ instanceMeta = null, soul = null } = {}) {
132
+ const child = instanceMeta?.policy?.childSpawns
133
+ ? { allowed: instanceMeta.policy.childSpawns.allowed === true, enforced: true, origin: instanceMeta.policy.childSpawns.origin ?? { kind: "recorded" } }
134
+ : instanceMeta ? { allowed: true, enforced: true, origin: { kind: "default", detail: "no recorded policy: children allowed (pre-0.24.8 instance)" } }
135
+ : soul?.declarations?.children && typeof soul.declarations.children.spawn === "boolean"
136
+ ? { allowed: soul.declarations.children.spawn, enforced: false, origin: { kind: "soul", detail: "children.spawn in soul.yaml; enforced once an instance records it" } }
137
+ : { allowed: true, enforced: false, origin: { kind: "default", detail: "no declaration: children allowed" } };
138
+ const worktree = instanceMeta ? { allowed: instanceMeta.work === "worktree" || instanceMeta.work === "checkout", mode: instanceMeta.work ?? null, enforced: true, origin: { kind: "work-mode", detail: `work: ${instanceMeta.work}` } }
139
+ : soul ? { allowed: ["worktree", "checkout"].includes(soul.work), mode: soul.work ?? null, enforced: false, origin: { kind: "soul", detail: `work: ${soul.work}` } } : { allowed: null, mode: null, enforced: false, origin: { kind: "unknown" } };
140
+ return { readinessApi: READINESS_API, policy: { childSpawns: child, worktrees: worktree }, notes: ["a lifecycle-authority claim enforced by the spawn route, not an OS sandbox"] };
141
+ }
package/lib/schedule.mjs CHANGED
@@ -145,7 +145,25 @@ export function readDefinitions(ws) {
145
145
  }
146
146
  export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
147
147
  export function readState(ws) { const st = readJson(statePath(ws), { jobs: {} }); if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
148
- export function writeState(ws, st) { writeJson(statePath(ws), st); }
148
+ /** K8 — bounded run history. Every time a job's lastRun reaches a settled
149
+ * outcome (not "active"/"starting"), it is appended once to `recentRuns`
150
+ * (newest first, max 50), keyed by scheduledFor+startedAt so re-saves of the
151
+ * same run never duplicate it. Recording happens on save: producers keep
152
+ * writing lastRun exactly as they do; nothing is inferred. */
153
+ export const RECENT_RUNS_MAX = 50;
154
+ function recordRecentRuns(st) {
155
+ for (const js of Object.values(st.jobs || {})) {
156
+ const lr = js.lastRun;
157
+ if (!lr || typeof lr !== "object" || ["active", "starting"].includes(lr.outcome)) continue;
158
+ const key = `${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.outcome ?? ""}`;
159
+ js.recentRuns ||= [];
160
+ if (js.recentRuns[0]?.key === key) { js.recentRuns[0] = { key, ...lr }; continue; } // same run, later fields
161
+ if (js.recentRuns.some((r) => r.key === key)) continue;
162
+ js.recentRuns.unshift({ key, ...lr });
163
+ if (js.recentRuns.length > RECENT_RUNS_MAX) js.recentRuns.length = RECENT_RUNS_MAX;
164
+ }
165
+ }
166
+ export function writeState(ws, st) { recordRecentRuns(st); writeJson(statePath(ws), st); }
149
167
 
150
168
  // ------------------------------------------------------- host registry
151
169
 
@@ -712,7 +730,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
712
730
  if (run.startedRuntime) js.lastLaunchedAt = now.toISOString();
713
731
  if (!existsSync(def.home)) releaseJobLock(ws, id);
714
732
  if (run.pending) js.pendingWake = { scheduledFor: minute.toISOString() }; else delete js.pendingWake;
715
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action };
733
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action, ...(run.instance ? { instance: run.instance } : {}) };
716
734
  record(id, run.action, { ...(run.reason ? { reason: run.reason } : {}), pending: !!run.pending });
717
735
  continue;
718
736
  }
@@ -877,7 +895,8 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
877
895
  let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
878
896
  const lock = jobLockInfo(ws, id);
879
897
  const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
880
- return { ...def, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
898
+ const recentRuns = (js.recentRuns || []).map(({ key, ...r }) => ({ ...r, ...(r.instance && r.home ? { transcript: { instance: r.instance, home: r.home, kind: "session" } } : {}) }));
899
+ return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
881
900
  }
882
901
  export function listSchedules(ws, io, { now = new Date() } = {}) {
883
902
  const defs = readDefinitions(ws), st = readState(ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.7",
3
+ "version": "0.24.8",
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",