@awebai/oats 0.24.6 → 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,225 @@
1
+ /** K1 — read-only Git observation of one instance's work tree.
2
+ *
3
+ * Truth comes from the tree itself (never from recorded spawn metadata):
4
+ * branch/HEAD via the worktree, status via porcelain v2 NUL records, ahead/
5
+ * behind reported twice and separately (upstream; merge-base with the
6
+ * repository's default branch) with "no upstream" ≠ 0/0. Diffs are bounded and
7
+ * addressed by an opaque file id minted with an observation revision; a diff
8
+ * against a tree that has since moved is refused, never served. */
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { oatsError } from "./errors.mjs";
14
+
15
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
16
+ const DIFF_MAX_BYTES = 256 * 1024;
17
+ export const INSTANCE_GIT_API = 1;
18
+
19
+ /** Every invocation is read-only and helper-free: the tree being observed may
20
+ * carry a hostile repo config (an agent works there), so external diff /
21
+ * textconv drivers, fsmonitor and hooks are disabled explicitly, the caller's
22
+ * Git environment is not inherited, and optional locks are off so status/diff
23
+ * never refresh (write) the index. */
24
+ const READ_ONLY_GIT = ["--no-optional-locks",
25
+ "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "diff.external=", "-c", "core.pager=cat",
26
+ "-c", "core.untrackedCache=false", "-c", "index.threads=1", "-c", "safe.bareRepository=explicit"];
27
+ function gitEnv() {
28
+ const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", LANG: "C", LC_ALL: "C", GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1" };
29
+ // The user's global config may name helpers too; observations do not need it.
30
+ env.GIT_CONFIG_GLOBAL = "/dev/null";
31
+ return env;
32
+ }
33
+ function git(cwd, argv, { allowFail = false, input, diffExit = false } = {}) {
34
+ const [sub, ...rest] = argv;
35
+ const extra = sub === "diff" ? ["--no-ext-diff", "--no-textconv", "--no-color"] : [];
36
+ try {
37
+ return execFileSync("git", [...READ_ONLY_GIT, "-C", cwd, sub, ...extra, ...rest], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, input, shell: false, timeout: 30_000, env: gitEnv() });
38
+ } catch (e) {
39
+ // `git diff --no-index` exits 1 when the inputs differ: that is the answer, not a failure.
40
+ if (diffExit && e.status === 1 && typeof e.stdout === "string") return e.stdout;
41
+ if (allowFail) return null;
42
+ throw oatsError("E_GIT_FAILED", `git ${sub} failed in ${cwd}: ${String(e.stderr ?? e.message ?? "").trim() || "unknown error"}`);
43
+ }
44
+ }
45
+ const trim = (s) => (s === null ? null : s.trim());
46
+
47
+ /** The instance's work tree, from its home. The home's instance.json names
48
+ * the work mode; the tree is `<home>/work` (a directory or a symlink to the
49
+ * shared checkout). Missing/retired → attributed refusal, never a crash. */
50
+ export function instanceWorkTree(home) {
51
+ const metaFile = join(home, "instance.json");
52
+ if (!existsSync(metaFile)) throw oatsError("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json)`);
53
+ let meta;
54
+ try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch (e) { throw oatsError("E_SESSION_UNKNOWN", `${metaFile}: ${e.message}`); }
55
+ const work = join(home, "work");
56
+ if (!existsSync(work)) throw oatsError("E_NO_WORKTREE", `${meta.instance ?? home} has no work tree at ${work} (retired, recovered, or never materialized)`);
57
+ const inside = trim(git(work, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }));
58
+ if (inside !== "true") throw oatsError("E_NO_WORKTREE", `${work} is not inside a git work tree`);
59
+ return { meta, work, mode: meta.work ?? null };
60
+ }
61
+
62
+ /** Porcelain v2 `-z` records → entries. Renames carry both paths. */
63
+ export function parsePorcelainV2(raw) {
64
+ const fields = raw.split("\0");
65
+ const entries = [];
66
+ let branch = { oid: null, head: null, upstream: null, ahead: null, behind: null };
67
+ for (let i = 0; i < fields.length; i++) {
68
+ const rec = fields[i];
69
+ if (!rec) continue;
70
+ if (rec.startsWith("# ")) {
71
+ const [, key, ...rest] = rec.split(" ");
72
+ const value = rest.join(" ");
73
+ if (key === "branch.oid") branch.oid = value === "(initial)" ? null : value;
74
+ else if (key === "branch.head") branch.head = value === "(detached)" ? null : value;
75
+ else if (key === "branch.upstream") branch.upstream = value;
76
+ else if (key === "branch.ab") { const m = /^\+(\d+) -(\d+)$/.exec(value); if (m) { branch.ahead = Number(m[1]); branch.behind = Number(m[2]); } }
77
+ continue;
78
+ }
79
+ const type = rec[0];
80
+ if (type === "1") {
81
+ const parts = rec.split(" ");
82
+ entries.push({ kind: "changed", xy: parts[1], submodule: parts[2] !== "N...", path: parts.slice(8).join(" "), origPath: null });
83
+ } else if (type === "2") {
84
+ const parts = rec.split(" ");
85
+ const path = parts.slice(9).join(" ");
86
+ const origPath = fields[++i] ?? null; // rename/copy: the original path is the next NUL field
87
+ entries.push({ kind: /^R/.test(parts[8]) ? "renamed" : "copied", xy: parts[1], submodule: parts[2] !== "N...", score: parts[8], path, origPath });
88
+ } else if (type === "u") {
89
+ const parts = rec.split(" ");
90
+ entries.push({ kind: "unmerged", xy: parts[1], submodule: parts[2] !== "N...", path: parts.slice(10).join(" "), origPath: null });
91
+ } else if (type === "?") entries.push({ kind: "untracked", xy: "??", submodule: false, path: rec.slice(2), origPath: null });
92
+ else if (type === "!") entries.push({ kind: "ignored", xy: "!!", submodule: false, path: rec.slice(2), origPath: null });
93
+ }
94
+ return { branch, entries };
95
+ }
96
+
97
+ function defaultBranch(work) {
98
+ const sym = trim(git(work, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { allowFail: true }));
99
+ if (sym) return { ref: sym, source: "origin/HEAD" };
100
+ for (const candidate of ["origin/main", "origin/master", "main", "master"]) {
101
+ if (git(work, ["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], { allowFail: true }) !== null) return { ref: candidate, source: "well-known" };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function countRange(work, range) {
107
+ const out = trim(git(work, ["rev-list", "--left-right", "--count", range], { allowFail: true }));
108
+ if (out === null) return null;
109
+ const [left, right] = out.split(/\s+/).map(Number);
110
+ return { left, right };
111
+ }
112
+
113
+ function indexRevisionOf(work) {
114
+ const listing = git(work, ["ls-files", "--stage", "-z"], { allowFail: true });
115
+ return listing === null ? "no-index" : createHash("sha256").update(listing).digest("hex").slice(0, 40);
116
+ }
117
+ function blobOf(work, path) {
118
+ // Content fingerprint of the working-tree file without writing an object.
119
+ return trim(git(work, ["hash-object", "--no-filters", "--", path], { allowFail: true }));
120
+ }
121
+ /** The branch's upstream remote (else `origin`, else null), with host/path parsed
122
+ * from ssh/https/git forms so a consumer can pick a forge backend WITHOUT running
123
+ * Git itself. Parsing only: no network, no forge knowledge. */
124
+ export function parseRemoteUrl(url) {
125
+ if (typeof url !== "string" || !url) return { host: null, path: null };
126
+ let m = /^(?:ssh:\/\/)?(?:[^@\/]+@)?([^:\/]+)(?::\d+)?[:\/](.+?)(?:\.git)?\/?$/.exec(url);
127
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
128
+ try { const u = new URL(url); m = [null, u.hostname, u.pathname.replace(/^\/+/, "").replace(/\.git$/, "").replace(/\/+$/, "")]; } catch { m = null; }
129
+ }
130
+ if (!m || !m[1] || !m[2]) return { host: null, path: null };
131
+ return { host: m[1].toLowerCase(), path: m[2] };
132
+ }
133
+ function remoteOf(work, branch) {
134
+ const configured = branch ? trim(git(work, ["config", "--get", `branch.${branch}.remote`], { allowFail: true })) : null;
135
+ const name = configured && configured !== "." ? configured : (trim(git(work, ["remote"], { allowFail: true })) ?? "").split("\n").includes("origin") ? "origin" : null;
136
+ if (!name) return null;
137
+ const url = trim(git(work, ["remote", "get-url", name], { allowFail: true }));
138
+ if (!url) return null;
139
+ return { name, url, ...parseRemoteUrl(url), source: configured && configured !== "." ? "branch-upstream" : "origin" };
140
+ }
141
+ function fileId(revision, indexOid, entry) {
142
+ return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
143
+ }
144
+
145
+ /** One consistent observation of the tree. Every field is what git said. */
146
+ export function observeInstanceGit(home) {
147
+ const { meta, work, mode } = instanceWorkTree(home);
148
+ const headOid = trim(git(work, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowFail: true }));
149
+ const raw = git(work, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--ignore-submodules=none"]);
150
+ const { branch, entries } = parsePorcelainV2(raw);
151
+ // The index state participates in the revision so that a stage/unstage
152
+ // between observation and diff is a moved tree, not a stale-but-served diff.
153
+ // Hashed from the index listing: no `write-tree`, so observing creates no object.
154
+ const indexOid = indexRevisionOf(work);
155
+ const revision = headOid ?? "unborn";
156
+ const at = new Date().toISOString();
157
+ const upstream = branch.upstream
158
+ ? { ref: branch.upstream, ahead: branch.ahead, behind: branch.behind }
159
+ : { ref: null, ahead: null, behind: null };
160
+ const base = defaultBranch(work);
161
+ let baseComparison = { ref: null, source: null, mergeBase: null, ahead: null, behind: null };
162
+ if (base && headOid) {
163
+ const mergeBase = trim(git(work, ["merge-base", "HEAD", base.ref], { allowFail: true }));
164
+ const counts = mergeBase ? countRange(work, `${base.ref}...HEAD`) : null;
165
+ baseComparison = { ref: base.ref, source: base.source, mergeBase, ahead: counts ? counts.right : null, behind: counts ? counts.left : null };
166
+ }
167
+ const files = entries.filter((e) => e.kind !== "ignored").map((e) => ({ id: fileId(revision, indexOid, e), ...e }));
168
+ const summary = { changed: 0, renamed: 0, copied: 0, unmerged: 0, untracked: 0 };
169
+ for (const f of files) summary[f.kind]++;
170
+ return {
171
+ instanceGitApi: INSTANCE_GIT_API,
172
+ instance: meta.instance ?? null, agent: meta.agent ?? null, home, workMode: mode,
173
+ observation: { revision, indexRevision: indexOid, at, worktree: work, branch: branch.head, detached: branch.head === null && headOid !== null, unborn: headOid === null },
174
+ recorded: { branch: meta.branch ?? null, repo: meta.repo ?? null, drift: meta.branch !== undefined && meta.branch !== null && branch.head !== meta.branch },
175
+ upstream, base: baseComparison,
176
+ remote: remoteOf(work, branch.head),
177
+ summary, files,
178
+ notes: [
179
+ ...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
180
+ ...(baseComparison.ref === null ? ["no default branch found (origin/HEAD, origin/main, origin/master, main, master): base comparison unknown"] : []),
181
+ ],
182
+ };
183
+ }
184
+
185
+ /** A bounded unified diff for one observed file. The caller passes the id
186
+ * and the observation revision it was minted under; a moved tree refuses. */
187
+ export function diffInstanceFile(home, { fileId: id, revision, indexRevision } = {}) {
188
+ if (typeof id !== "string" || !/^[a-f0-9]{24}$/.test(id)) throw oatsError("E_BAD_ARGS", "--file needs the opaque file id from `oats instance git --json`");
189
+ if (typeof revision !== "string" || !revision) throw oatsError("E_BAD_ARGS", "--revision needs the observation revision the file id was minted under");
190
+ const current = observeInstanceGit(home);
191
+ if (current.observation.revision !== revision || (indexRevision !== undefined && current.observation.indexRevision !== indexRevision)) {
192
+ throw Object.assign(oatsError("E_STALE_OBSERVATION", "the work tree moved since this file id was observed; re-observe with `oats instance git --json`"), { observation: current.observation });
193
+ }
194
+ const file = current.files.find((f) => f.id === id);
195
+ if (!file) throw Object.assign(oatsError("E_STALE_OBSERVATION", "this file id is not part of the current observation; re-observe"), { observation: current.observation });
196
+ const work = current.observation.worktree;
197
+ const paths = file.origPath ? [file.origPath, file.path] : [file.path];
198
+ const before = { blob: blobOf(work, file.path) };
199
+ let patch, binary = false;
200
+ if (file.kind === "untracked") {
201
+ const numstat = trim(git(work, ["diff", "--no-index", "--numstat", "--", "/dev/null", file.path], { diffExit: true, allowFail: true }));
202
+ binary = /^-\t-\t/.test(numstat ?? "");
203
+ patch = binary ? "" : (git(work, ["diff", "--no-index", "--", "/dev/null", file.path], { diffExit: true, allowFail: true }) ?? "");
204
+ } else {
205
+ const numstat = trim(git(work, ["diff", current.observation.revision, "--numstat", "-M", "--", ...paths], { allowFail: true }));
206
+ binary = /^-\t-\t/.test(numstat ?? "");
207
+ patch = binary ? "" : git(work, ["diff", current.observation.revision, "-M", "--", ...paths]);
208
+ }
209
+ // Consistency across the read, not only before it: HEAD, index and the file's
210
+ // own content must be what the observation said when the patch was produced.
211
+ const after = { revision: trim(git(work, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowFail: true })) ?? "unborn", indexRevision: indexRevisionOf(work), blob: blobOf(work, file.path) };
212
+ if (after.revision !== current.observation.revision || after.indexRevision !== current.observation.indexRevision || after.blob !== before.blob) {
213
+ const moved = observeInstanceGit(home);
214
+ throw Object.assign(oatsError("E_STALE_OBSERVATION", "the work tree moved while the diff was being read; re-observe with `oats instance git --json`"), { observation: moved.observation });
215
+ }
216
+ const bytes = Buffer.byteLength(patch, "utf8");
217
+ const truncated = bytes > DIFF_MAX_BYTES;
218
+ const body = truncated ? Buffer.from(patch, "utf8").subarray(0, DIFF_MAX_BYTES).toString("utf8") : patch;
219
+ return {
220
+ instanceGitApi: INSTANCE_GIT_API, observation: current.observation,
221
+ file: { id: file.id, kind: file.kind, xy: file.xy, path: file.path, origPath: file.origPath },
222
+ against: file.kind === "untracked" ? "empty" : current.observation.revision, binary, bytes, truncated, limit: DIFF_MAX_BYTES, patch: body,
223
+ readOnly: { helpers: "disabled", optionalLocks: "off", objectsWritten: 0 },
224
+ };
225
+ }
@@ -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);