@jr2/cli 0.4.1 → 0.6.0

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/README.md CHANGED
@@ -12,8 +12,9 @@ jr2 run <workflow>
12
12
  ```
13
13
 
14
14
  `init` scaffolds an Instance pinned at this CLI's version; `up` converges the target namespace to match it (operator,
15
- Orchestrator, Sandboxes, secrets), and every other verb (`run`, `runs`, `status`, `logs`, `send`, `down`, `gc`,
16
- `kit push`) talks to what `up` deployed. Node 24, a kube context, and docker for the image `up` builds.
15
+ Orchestrator, Sandboxes, secrets); `check` runs the typecheck and the Machine walk of `up` with no cluster; and every
16
+ other verb (`run`, `runs`, `status`, `logs`, `send`, `down`, `gc`, `kit push`) talks to what `up` deployed. Node 24, a
17
+ kube context, and docker for the image `up` builds.
17
18
 
18
19
  Inside an Instance, the global `jr2` hands off to the Instance's own `@jr2/cli` (the gulp model), so the version that
19
20
  runs is the one the Instance pins and the global's stops mattering. An Instance has one kit version: `@jr2/cli` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/cli",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -39,14 +39,14 @@
39
39
  "ts-blank-space": "^0.9.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@jr2/orchestrator": "0.4.1"
42
+ "@jr2/orchestrator": "0.6.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@anthropic-ai/sdk": "0.91.1",
46
46
  "@types/node": "^26.0.1",
47
47
  "xstate": "^5.18.0",
48
48
  "zod": "^4.4.3",
49
- "@jr2/orchestrator": "0.4.1"
49
+ "@jr2/orchestrator": "0.6.0"
50
50
  },
51
51
  "files": [
52
52
  "bin",
@@ -0,0 +1,149 @@
1
+ // The Instance's `AGENTS.md` block (ADR-0072): an Assistant reads `AGENTS.md` on every start, so
2
+ // the index of the docs this Kit version bundles goes there, between two marker lines. `jr2 init`
3
+ // writes the file; `jr2 up` keeps the text between the markers equal to the block of the Kit the
4
+ // Instance resolves, because every upgrade ends in `up`. The block's text is the bundle's
5
+ // (`docs/agents-block.md` in `@jr2/orchestrator`, assembled by `docs:build`), never this module's,
6
+ // so it names its own Kit version and cannot describe another one.
7
+ //
8
+ // The file is the user's. The markers are the whole contract: a folder without them has opted out,
9
+ // so `up` never creates the file or the markers and no config key exists for it; a broken pair is
10
+ // said and left alone, since any repair would guess which half the user meant; and every byte
11
+ // outside the markers survives a rewrite, line endings included. The block takes the line ending
12
+ // of the BEGIN marker's line, so a CRLF checkout compares equal and is not rewritten on every `up`.
13
+ //
14
+ // Builtins only: `init` reads the bundle from the CLI's own peer, which a global `jr2` has before
15
+ // any Instance exists.
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { readFile, writeFile } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { activity, type Io } from "./output.ts";
21
+
22
+ export const AGENTS_MD = "AGENTS.md";
23
+ export const BEGIN_MARKER = "<!-- BEGIN:jr2-docs -->";
24
+ export const END_MARKER = "<!-- END:jr2-docs -->";
25
+
26
+ /** Where the bundle keeps the block, relative to the `@jr2/orchestrator` root. */
27
+ export const AGENTS_BLOCK_PATH = join("docs", "agents-block.md");
28
+
29
+ /** The `AGENTS.md` that `jr2 init` writes: the line that says who owns the markers, the markers
30
+ * with `block` between them (empty when init has no bundle to read), and room for the user's own
31
+ * instructions after the END marker. `templates/default/AGENTS.md` is this with no block. */
32
+ export function scaffoldAgentsMd(block = ""): string {
33
+ return (
34
+ "<!-- jr2 up writes the text between the jr2-docs markers. Write your own instructions after the END marker. -->\n" +
35
+ `${BEGIN_MARKER}\n${block}${END_MARKER}\n`
36
+ );
37
+ }
38
+
39
+ /** Claude Code reads `AGENTS.md` only when no `CLAUDE.md` exists, so the scaffold's `CLAUDE.md`
40
+ * imports it and holds nothing else. */
41
+ export const CLAUDE_MD = "@AGENTS.md\n";
42
+
43
+ /** The block a Kit at `kitRoot` bundles; `undefined` when it has none (a checkout before
44
+ * `docs:build`, or a Kit that predates the bundle). */
45
+ export function readAgentsBlock(kitRoot: string): string | undefined {
46
+ try {
47
+ return readFileSync(join(kitRoot, AGENTS_BLOCK_PATH), "utf8");
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Where the block sits in an `AGENTS.md` text: `from` is where the line after BEGIN starts, `to`
54
+ * where the END line starts, and `eol` the BEGIN line's own line ending. */
55
+ export type BlockPlace =
56
+ | { kind: "opted-out" }
57
+ | { kind: "broken"; reason: string }
58
+ | { kind: "found"; from: number; to: number; eol: string };
59
+
60
+ type MarkerLine = { marker: typeof BEGIN_MARKER | typeof END_MARKER; start: number; end: number; eol: string };
61
+
62
+ /** Each line that IS a marker (a CR before its LF included), with where it starts, where the next
63
+ * line starts, and its own line ending. A marker inside other text on a line is not a marker. */
64
+ function markerLines(text: string): MarkerLine[] {
65
+ const found: MarkerLine[] = [];
66
+ let start = 0;
67
+ while (start < text.length) {
68
+ const lf = text.indexOf("\n", start);
69
+ const stop = lf < 0 ? text.length : lf + 1;
70
+ const raw = text.slice(start, lf < 0 ? text.length : lf);
71
+ const eol = lf < 0 ? "" : raw.endsWith("\r") ? "\r\n" : "\n";
72
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
73
+ if (line === BEGIN_MARKER || line === END_MARKER) found.push({ marker: line, start, end: stop, eol });
74
+ start = stop;
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /** Find the markers in `text`, or say why there is no block to rewrite. */
80
+ export function placeAgentsBlock(text: string): BlockPlace {
81
+ const lines = markerLines(text);
82
+ if (lines.length === 0) return { kind: "opted-out" };
83
+ const begins = lines.filter((l) => l.marker === BEGIN_MARKER);
84
+ const ends = lines.filter((l) => l.marker === END_MARKER);
85
+ if (begins.length > 1) return { kind: "broken", reason: `${BEGIN_MARKER} occurs ${begins.length} times` };
86
+ if (ends.length > 1) return { kind: "broken", reason: `${END_MARKER} occurs ${ends.length} times` };
87
+ const [begin] = begins;
88
+ const [end] = ends;
89
+ if (!begin) return { kind: "broken", reason: `${END_MARKER} has no ${BEGIN_MARKER} before it` };
90
+ if (!end) return { kind: "broken", reason: `${BEGIN_MARKER} has no ${END_MARKER} after it` };
91
+ if (end.start < begin.start) return { kind: "broken", reason: `${END_MARKER} comes before ${BEGIN_MARKER}` };
92
+ return { kind: "found", from: begin.end, to: end.start, eol: begin.eol };
93
+ }
94
+
95
+ /** What {@link rewriteAgentsBlock} found. Only `updated` carries a text to write. */
96
+ export type BlockRewrite =
97
+ | Exclude<BlockPlace, { kind: "found" }>
98
+ | { kind: "current" }
99
+ | { kind: "updated"; text: string };
100
+
101
+ /**
102
+ * Put `block` between the markers of the `AGENTS.md` text `text`. Pure: the caller reads and
103
+ * writes. `block` is the bundle's, with LF line endings; it is written with the BEGIN line's.
104
+ */
105
+ export function rewriteAgentsBlock(text: string, block: string): BlockRewrite {
106
+ const place = placeAgentsBlock(text);
107
+ if (place.kind !== "found") return place;
108
+ const wanted = place.eol === "\r\n" ? block.replace(/\r?\n/g, "\r\n") : block;
109
+ if (text.slice(place.from, place.to) === wanted) return { kind: "current" };
110
+ return { kind: "updated", text: text.slice(0, place.from) + wanted + text.slice(place.to) };
111
+ }
112
+
113
+ /**
114
+ * `jr2 up`'s step: bring the Instance's `AGENTS.md` to the block of the Kit at `kitRoot`, which is
115
+ * `version`. Says what it did on stderr in at most one line, and nothing when there was nothing to
116
+ * do. Never throws for the file's content, and never refuses the converge: the block describes the
117
+ * installed Kit, not the cluster.
118
+ */
119
+ export async function syncAgentsMd(io: Io, root: string, kitRoot: string, version: string): Promise<void> {
120
+ // Read and written as latin1, one char per byte, so a byte outside the markers that is not valid
121
+ // UTF-8 survives too. The markers are ASCII; the block is re-encoded to match.
122
+ let text: string;
123
+ try {
124
+ text = (await readFile(join(root, AGENTS_MD))).toString("latin1");
125
+ } catch {
126
+ return; // no AGENTS.md: opted out
127
+ }
128
+ // The markers are checked before the bundle is read, so a broken pair is said even by a Kit
129
+ // with no bundle.
130
+ const place = placeAgentsBlock(text);
131
+ if (place.kind === "opted-out") return;
132
+ if (place.kind === "broken") {
133
+ activity(io, `warning: AGENTS.md: ${place.reason}, so the jr2 block was not updated`);
134
+ return;
135
+ }
136
+ const block = readAgentsBlock(kitRoot);
137
+ if (block === undefined) {
138
+ activity(
139
+ io,
140
+ `AGENTS.md: jr2 block not updated: the Kit at ${kitRoot} has no ${AGENTS_BLOCK_PATH} ` +
141
+ "(a kit checkout makes it with docs:build)",
142
+ );
143
+ return;
144
+ }
145
+ const rewrite = rewriteAgentsBlock(text, Buffer.from(block, "utf8").toString("latin1"));
146
+ if (rewrite.kind !== "updated") return;
147
+ await writeFile(join(root, AGENTS_MD), Buffer.from(rewrite.text, "latin1"));
148
+ activity(io, `AGENTS.md: jr2 block updated for ${version}`);
149
+ }
package/src/assembly.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { lstat, mkdtemp, readdir, readFile, readlink, realpath, rm, stat } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
- import { assemblyHash, discoverModules, KIT_VERSION } from "@jr2/orchestrator";
16
+ import { assemblyHash, discoverModules, KIT_VERSION } from "@jr2/orchestrator/internal";
17
17
  import { lockfileInstall, type BuildPort } from "./build.ts";
18
18
 
19
19
  const ORCHESTRATOR = "@jr2/orchestrator";
package/src/build.ts CHANGED
@@ -37,7 +37,7 @@ import { cp, readdir, readFile, rm, stat } from "node:fs/promises";
37
37
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
  import { promisify } from "node:util";
40
- import { contentHash, KIT_VERSION } from "@jr2/orchestrator";
40
+ import { contentHash, KIT_VERSION } from "@jr2/orchestrator/internal";
41
41
  import { LABEL_INSTANCE } from "./deploy.ts";
42
42
 
43
43
  const exec = promisify(execFile);
@@ -142,6 +142,12 @@ export type BuildPort = {
142
142
  * so it must stay hashed — the old shared set excluded it and lied. */
143
143
  const KIT_PACKAGE_EXCLUDE = new Set(["node_modules", "dist"]);
144
144
 
145
+ /** For the walk of the Orchestrator image's sources: the kit root's `.dockerignore` also drops
146
+ * `docs` at any depth under each of them (`packages/orchestrator/**\/docs`, …), the docs bundle that
147
+ * `docs:build` writes into `@jr2/orchestrator` (ADR-0072). The image never reads it, so a local
148
+ * `docs:build` must move neither the image nor its tag. */
149
+ const KIT_ORCHESTRATOR_EXCLUDE = new Set([...KIT_PACKAGE_EXCLUDE, "docs"]);
150
+
145
151
  /** For the walk of `operator/`: its context is `operator/` itself, governed by
146
152
  * `operator/.dockerignore`, an ALLOWLIST (`**` then `!**\/*.go`, go.mod, go.sum). `bin/` (~400 MB
147
153
  * of downloaded tooling) and `testbin/` hold no `.go`, and `cover.out` is not one — all three are
@@ -149,9 +155,9 @@ const KIT_PACKAGE_EXCLUDE = new Set(["node_modules", "dist"]);
149
155
  * rest of the non-go tree (Makefile, config/, hack/) stays hashed: over-hash, the cheap side. */
150
156
  const KIT_OPERATOR_EXCLUDE = new Set(["bin", "testbin", "cover.out"]);
151
157
 
152
- // `contentHash` lives in `@jr2/orchestrator` (ADR-0069): the door recomputes an Assembly's hash
158
+ // `contentHash` lives in `@jr2/orchestrator/internal` (ADR-0069): the door recomputes an Assembly's hash
153
159
  // with the same function. Re-exported so every CLI caller keeps its import.
154
- export { contentHash } from "@jr2/orchestrator";
160
+ export { contentHash } from "@jr2/orchestrator/internal";
155
161
 
156
162
  // --- ownership: who built this image (ADR-0039) ------------------------------------------------
157
163
 
@@ -376,7 +382,7 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
376
382
  // this list equals the Orchestrator's `workspace:` dependencies, both asserted in
377
383
  // test/orchestrator-image.test.ts.
378
384
  sources: ["packages/orchestrator", "packages/agent-protocol", "deploy/orchestrator"],
379
- exclude: KIT_PACKAGE_EXCLUDE,
385
+ exclude: KIT_ORCHESTRATOR_EXCLUDE,
380
386
  },
381
387
  };
382
388
 
@@ -619,9 +625,10 @@ export type NodeSweepPlan = {
619
625
  *
620
626
  * Aggressive by ADR-0039: nothing RUNS from the host daemon — its images are scratch awaiting
621
627
  * delivery — and BuildKit's cache is a separate store `docker rmi` does not touch, so regenerating
622
- * a swept tag costs seconds. The accepted cost, written down so nobody adds a name filter to
623
- * "fix" it: a second checkout converging to a different cluster can have its host kit generation
624
- * swept, because this keep set only sees the current context's roots.
628
+ * a swept tag costs seconds. Written down so nobody adds a name filter to "fix" it: this keep set
629
+ * only sees the current context's roots, so a converge to another cluster — a second checkout's,
630
+ * or this one's — can lose a host tag it built and has not used yet. `jr2 up` rebuilds a tag it
631
+ * finds missing at its use and retries ({@link isMissingHostImage}).
625
632
  */
626
633
  export function hostSweepPlan(images: ObservedImage[], keep: Iterable<string>): HostSweepPlan {
627
634
  const reachable = keepSet(keep);
@@ -722,6 +729,16 @@ function isAlreadyGone(err: unknown): boolean {
722
729
  return /no such image/i.test(err instanceof Error ? err.message : String(err));
723
730
  }
724
731
 
732
+ /** A host-daemon read or delivery that found no image under its tag, in each verb's own words:
733
+ * `docker image inspect`, `docker create`, `kind load docker-image`, `docker push`. The one failure
734
+ * `jr2 up` answers with a rebuild: another converge's sweep can take a tag between this converge's
735
+ * build (or host-held skip) and its use (ADR-0039). */
736
+ export function isMissingHostImage(err: unknown): boolean {
737
+ return /no such image|unable to find image .* locally|not present locally|does not exist locally/i.test(
738
+ err instanceof Error ? err.message : String(err),
739
+ );
740
+ }
741
+
725
742
  /**
726
743
  * Sweep the host daemon: every labeled ref no root names. `keep` is the caller's assembled keep
727
744
  * set — the union of the cluster's live roots plus whatever this converge just resolved, which the
package/src/cli.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // Top-level dispatch (ADR-0009). `argv[0]` is the verb; the rest is handed to that command, which does
2
- // its own `parseArgs`. `main` returns an exit code (the bin sets `process.exitCode`) and never throws:
3
- // a command error becomes an `error: …` line on stderr + code 1. Codes: 0 ok, 1 runtime error, 2 usage.
2
+ // its own `parseArgs` against its entry in the command table (`command-table.ts`). Every help prints
3
+ // from that table too: `jr2 --help` the whole of it, `jr2 <verb> --help` (or `-h`) one entry — and
4
+ // then the verb does NOT run, so `jr2 up --help` never converges a cluster. `main` returns an exit
5
+ // code (the bin sets `process.exitCode`) and never throws: a command error becomes an `error: …` line
6
+ // on stderr + code 1. Codes: 0 ok, 1 runtime error, 2 usage.
4
7
 
5
8
  import { activity, defaultIo, type Io } from "./output.ts";
6
9
  import { JR2HttpError } from "./client.ts";
@@ -12,46 +15,12 @@ import { status } from "./commands/status.ts";
12
15
  import { logs } from "./commands/logs.ts";
13
16
  import { send } from "./commands/send.ts";
14
17
  import { up } from "./commands/up.ts";
18
+ import { check } from "./commands/check.ts";
15
19
  import { down } from "./commands/down.ts";
16
20
  import { gc } from "./commands/gc.ts";
17
21
  import { kit } from "./commands/kit.ts";
18
22
  import { version } from "./commands/version.ts";
19
-
20
- const USAGE = `jr2 — orchestrate agentic workflows (ADR-0009)
21
-
22
- usage: jr2 <command> [args]
23
-
24
- version [--local] [--json] what runs here (this jr2, the global that handed off, the
25
- Instance's Kit) and what the cluster runs; --local skips the cluster
26
- init [dir] [--name <n>] scaffold a new instance folder
27
- up [--yes] [--force] converge the current kube context to this instance (ADR-0019)
28
- down [--all] remove the instance from the cluster (--all: operator too)
29
- gc [--dry-run] [--repo-ttl 7d] remove jr2's images that no live instance names (ADR-0039), and
30
- Repo resources no Machine binds and no run attached lately (ADR-0051)
31
- kit push <registry> mirror the published kit images into a registry (ADR-0044)
32
- run <workflow> [--input <json>] start a run; stream activity, print terminal result
33
- [--detach] ...or just print the runId and return
34
- runs list live runs; "placing" names the Workspaces that wait for
35
- capacity (ADR-0064), and "status <runId>" says why; the
36
- Workflows and their named Charts on stderr (ADR-0070)
37
- [--workflows] ...or print that listing as the result (see "runs --help")
38
- status [runId] print a run's current status (read-through),
39
- [--json] or the instance's Repos, per node, when given none (ADR-0048/0051)
40
- logs <runId> [-f] replay a run's status; -f to follow until it settles
41
- send <runId> --event CANCEL abandon a live run and destroy its Workspaces
42
- [--keep] ...or keep them for inspection until the idle timeout
43
- send <runId> --gate <gate> --event <name> [--input <json>]
44
- deliver a workflow event to an open gate
45
-
46
- run ids: any <runId> above may be abbreviated to a unique prefix (4+ chars, git-style);
47
- an ambiguous prefix lists the candidates and fails rather than guessing
48
-
49
- global (run verbs): -n/--namespace <ns>, --context <ctx> address the deployment (ADR-0019);
50
- --url <u> / JR2_URL attaches to a specific orchestrator (skips kube entirely)
51
-
52
- output: result verbs (run, runs, status <runId>, send, logs) print one JSON result on stdout and
53
- activity on stderr, so \`jr2 run … | jq\` yields the result; report verbs (version, bare
54
- status) print a table on stdout, or the one object with --json`;
23
+ import { asksForHelp, commandsFor, GLOBAL, renderCommand, renderHelp } from "./command-table.ts";
55
24
 
56
25
  /**
57
26
  * The catch-all half of skew reporting: a route-shaped failure gets ONE extra line naming what the
@@ -78,6 +47,15 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
78
47
  // Before any verb, so the instance's `.env` is in `process.env` by the time a command imports
79
48
  // `jr2.config.ts` (whose deployment-varying values are read from there — ADR-0019).
80
49
  loadDotenv(io);
50
+ const entries = cmd === undefined ? [] : commandsFor(cmd);
51
+ if (entries.length && asksForHelp(entries, rest)) {
52
+ activity(io, entries.map(renderCommand).join("\n\n"));
53
+ return 0;
54
+ }
55
+ if (cmd !== undefined && (GLOBAL.help as readonly string[]).includes(cmd)) {
56
+ activity(io, renderHelp());
57
+ return 0;
58
+ }
81
59
  switch (cmd) {
82
60
  case "init":
83
61
  return await init(rest, io);
@@ -91,6 +69,8 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
91
69
  return await logs(rest, io);
92
70
  case "send":
93
71
  return await send(rest, io);
72
+ case "check":
73
+ return await check(rest, io);
94
74
  case "up":
95
75
  return await up(rest, io);
96
76
  case "down":
@@ -102,17 +82,12 @@ export async function main(argv: string[], io: Io = defaultIo): Promise<number>
102
82
  case "version":
103
83
  case "--version":
104
84
  return await version(rest, io);
105
- case "help":
106
- case "--help":
107
- case "-h":
108
- activity(io, USAGE);
109
- return 0;
110
85
  case undefined:
111
- activity(io, USAGE);
86
+ activity(io, renderHelp());
112
87
  return 2;
113
88
  default:
114
89
  activity(io, `unknown command: ${cmd}`);
115
- activity(io, USAGE);
90
+ activity(io, renderHelp());
116
91
  return 2;
117
92
  }
118
93
  } catch (err) {
package/src/client.ts CHANGED
@@ -6,7 +6,15 @@
6
6
  // `fetchImpl` is injectable so tests drive it with a hono `app.request` (no socket) the same way the
7
7
  // orchestrator's own http tests do; in production it defaults to the global `fetch`.
8
8
 
9
- import type { ChildErrorMarker, MachineDoc, PlacingMarker, RepoStatus, RunChild, RunWaiting } from "@jr2/orchestrator";
9
+ import type {
10
+ ChildErrorMarker,
11
+ DispatchedBy,
12
+ MachineDoc,
13
+ PlacingMarker,
14
+ RepoStatus,
15
+ RunChild,
16
+ RunWaiting,
17
+ } from "@jr2/orchestrator/internal";
10
18
  import { parseSSE } from "./sse.ts";
11
19
 
12
20
  /** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
@@ -35,6 +43,19 @@ export type RunStatus = {
35
43
  bound?: true;
36
44
  /** The hashes of the Charts the run binds now, distinct and sorted (ADR-0070). */
37
45
  chartHashes?: string[];
46
+ /** The run that dispatched this one through a `dispatch` Effect, the caller's slot, its ledger
47
+ * row id (the idempotency key) and this run's depth in the chain (ADR-0071); `instance` names a
48
+ * foreign caller, as opaque text. Absent for a run nobody dispatched. */
49
+ dispatchedBy?: DispatchedBy;
50
+ /** The Machine's output, for a run that is no longer live (ADR-0071): what a `dispatch` with
51
+ * `wait` settles with. Never for a live run. */
52
+ output?: unknown;
53
+ /** The runs this run's `dispatch` Effects started, oldest first: the slot, the run, and when its
54
+ * ledger row was written (ADR-0071). Absent when it started none. */
55
+ dispatched?: Array<{ slot: string; run: string; at: string }>;
56
+ /** The Worktrees this run's cuts made, oldest first: the Repo Slot, the path, and the branch,
57
+ * `null` when detached (ADR-0075). Absent when it cut none. */
58
+ worktrees?: Array<{ slot: string; path: string; branch: string | null }>;
38
59
  };
39
60
 
40
61
  /** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or