@jr2/machines 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rich Snapp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @jr2/machines
2
+
3
+ The Machines the kit ships (ADR-0054). A fourth published package beside `@jr2/{cli,orchestrator,agent-protocol}`, on
4
+ the same release train, exporting named Machines from its index.
5
+
6
+ It ships **Machines**, never Workflows: a Workflow is the name an Instance registers a Machine under, and only the
7
+ Instance's `workflows/` file can do that. So the consumer line is one file of your own —
8
+
9
+ ```ts
10
+ // workflows/task.ts → the workflow "task"
11
+ import { customize } from "@jr2/orchestrator";
12
+ import { task } from "@jr2/machines";
13
+
14
+ export const machine = customize(task, {
15
+ repos: { target: { url: "https://github.com/you/repo.git" } },
16
+ agents: { coder: { model: "anthropic/claude-sonnet-4-6" } },
17
+ });
18
+ ```
19
+
20
+ ## The Open parts
21
+
22
+ A packaged Machine leaves the parts a package cannot honestly fill **Open** — a Repo Slot with no url, a slot map with
23
+ no slots, an Agent with no model — because a package cannot know your repository or pay for your model. `jr2 up` walks
24
+ the registered Machines, refuses an Open part nobody bound before anything is built, and prints the `customize` line
25
+ that binds it.
26
+
27
+ `task` has two: its Repo Slots, Open as a map, and the Agent `coder`. Both are bound above. The slot names are yours:
28
+ `task` reads none of them. Their order is `task`'s convention (jr2 itself gives no slot a meaning): the first you write
29
+ is where the coder works; any others are checked out beside it and the coder is told where they are, so a change that
30
+ targets a library, or wants a handbook to hand, is one more line —
31
+
32
+ ```ts
33
+ repos: {
34
+ target: { url: "https://github.com/you/repo.git" },
35
+ reference: { url: "https://github.com/you/lib.git", ref: "v3" },
36
+ },
37
+ ```
38
+
39
+ ## Peer dependencies
40
+
41
+ `@jr2/orchestrator`, `xstate` and `zod` are **peer** dependencies, never regular ones. Every part a Machine carries is
42
+ keyed in maps `@jr2/orchestrator` holds and in xstate's own machinery; a second copy of either is a Machine whose Menus
43
+ are empty and whose Gates accept nothing, with no error anywhere. Your own Machine package copies this shape.
44
+
45
+ ## Machines
46
+
47
+ - **`task`** — one prompt, one Workspace, one human says done. The coder works in a Sandbox on `jr2/task-<run id>` (or
48
+ the branch you pass), `finish` parks the run at the `review` Gate, `request_changes` sends it back on the same
49
+ conversation, `approve` ends the run and tears the Workspace down. It does not push: while the run is parked you can
50
+ `exec` into the Sandbox, read the branch, and push it yourself with your own credential.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@jr2/machines",
3
+ "version": "0.1.0",
4
+ "description": "The Machines the jr2 kit ships: packaged xstate Machines that leave their parts Open for a composer to bind.",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/snapwich/jr2.git",
13
+ "directory": "packages/machines"
14
+ },
15
+ "homepage": "https://github.com/snapwich/jr2#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/snapwich/jr2/issues"
18
+ },
19
+ "keywords": [
20
+ "jr2",
21
+ "agents",
22
+ "agentic",
23
+ "xstate",
24
+ "kubernetes",
25
+ "workflow"
26
+ ],
27
+ "engines": {
28
+ "node": ">=24"
29
+ },
30
+ "type": "module",
31
+ "exports": {
32
+ ".": "./src/index.ts"
33
+ },
34
+ "types": "./src/index.ts",
35
+ "peerDependencies": {
36
+ "xstate": "^5.18.0",
37
+ "zod": "^4.4.3",
38
+ "@jr2/orchestrator": "0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.0.1",
42
+ "typescript": "^5.6.0",
43
+ "xstate": "^5.18.0",
44
+ "zod": "^4.4.3",
45
+ "@jr2/orchestrator": "0.1.0"
46
+ },
47
+ "files": [
48
+ "src"
49
+ ],
50
+ "scripts": {
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "node --test"
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // @jr2/machines — the Machines the kit ships (ADR-0054). A fourth published package beside
2
+ // `@jr2/{cli,orchestrator,agent-protocol}` (ADR-0043), on the same release train, exporting named
3
+ // Machines from this index: `import { task } from "@jr2/machines"`.
4
+ //
5
+ // It ships MACHINES and never Workflows. A Workflow is the name an Instance registers a Machine
6
+ // under, and only the Instance's `workflows/` file can do that (CONTEXT.md) — so what a consumer
7
+ // receives is a Machine object, and the line that turns it into a Workflow is theirs:
8
+ //
9
+ // import { customize } from "@jr2/orchestrator";
10
+ // import { task } from "@jr2/machines";
11
+ // export const machine = customize(task, { repos: { target: { url } }, agents: { coder: { model } } });
12
+ //
13
+ // PEER DEPENDENCIES, NO REGULAR ONES — the one fact about this package's manifest that a comment
14
+ // has to carry, because `package.json` cannot hold one. Every part a Machine here carries is keyed
15
+ // in maps `@jr2/orchestrator` holds (vocabulary.ts, parts.ts) and in `xstate`'s own `provide` and
16
+ // machine tests. A SECOND copy of either — which a regular dependency installs the moment the
17
+ // consumer's version skews from ours — is a Machine whose Menus are empty and whose Gates accept
18
+ // nothing, with no error anywhere: the walk finds no vocabulary, the Gate resolves no names, and
19
+ // the run parks on a surface that accepts nothing. Peer deps state the fact instead, and are the
20
+ // shape a user's own Machine package copies. Regular deps pinned at the exact kit version were
21
+ // rejected for working by the ACCIDENT of equal versions and installing two copies silently on
22
+ // skew (ADR-0054).
23
+ //
24
+ // Every Machine exported here leaves its unfillable parts OPEN (CONTEXT.md, ADR-0054): a package
25
+ // cannot know the repository or pay for the model, so it says so rather than guessing. `jr2 up`
26
+ // refuses an Open part nobody bound and prints the `customize` line that binds it.
27
+
28
+ export * from "./task.ts";
package/src/task.ts ADDED
@@ -0,0 +1,349 @@
1
+ // `task` — the kit's first shipped Machine (ADR-0054): one prompt, one Workspace, one human says
2
+ // done. It is the smallest shape that is still worth packaging — a coder in a Sandbox, a Gate a
3
+ // human answers, and a loop between them that ends when the human ends it.
4
+ //
5
+ // Both of the parts a package cannot honestly fill are left OPEN (CONTEXT.md, ADR-0051/0054): the
6
+ // Repo Slots are Open as a MAP, because the package cannot know the repository — nor how many
7
+ // checkouts the task wants beside it — and the Agent `coder` has no model, because the package
8
+ // cannot pay for one. A consumer binds both on the line that registers this Machine as a
9
+ // Workflow, naming every slot. The ORDER is this Machine's convention, not the kit's (ADR-0051:
10
+ // the handles keep declaration order and the kit reads nothing into it): the FIRST slot is the
11
+ // one the coder works in, and any others are attached beside it for the coder to read (a library
12
+ // the change targets, a handbook):
13
+ //
14
+ // export const machine = customize(task, {
15
+ // repos: {
16
+ // target: { url: "https://github.com/you/repo.git" },
17
+ // reference: { url: "https://github.com/you/lib.git" }, // optional: more checkouts to read
18
+ // },
19
+ // agents: { coder: { model: "anthropic/claude-sonnet-4-6" } },
20
+ // });
21
+ //
22
+ // `jr2 up` walks the registered Machines and refuses either part unbound, printing exactly that
23
+ // line (parts.ts, `customizeLine`); the Agent actor refuses to admit a Turn under an Open model as
24
+ // the second fence. So the failure mode of forgetting is a converge that stops, never a run that
25
+ // silently spends money on a model nobody chose.
26
+ //
27
+ // The Machine does NOT push (ADR-0005/0053): the Agent holds no credential, and the push url is
28
+ // the caller's own spelling with the caller's own credential. The `review` Gate IS the inspection
29
+ // window — while the run is parked the Sandbox is alive (parking is retention, ADR-0012), so a
30
+ // human execs in, reads the branch, pushes it if the work should outlive the run, and only then
31
+ // answers. Unpushed commits go with the pod, as ADR-0012 always said.
32
+
33
+ import { assign } from "xstate";
34
+ import { z } from "zod";
35
+ import {
36
+ agent,
37
+ defineEvent,
38
+ jr2Setup,
39
+ open,
40
+ workspace,
41
+ type HostInjectedInput,
42
+ type ThinkingLevel,
43
+ type Workspaced,
44
+ } from "@jr2/orchestrator";
45
+
46
+ // ---------------------------------------------------------------------------------------------
47
+ // The door (ADR-0033): what a caller sends to start a run, declared on the `workspace()` that is
48
+ // this Machine's root. One source of truth — the types below derive from it, the Console generates
49
+ // its start form from it, and it types the spec mapper.
50
+ //
51
+ // No `repo` field, deliberately (ADR-0054): a packaged door cannot enumerate the Instance's
52
+ // repositories, and "a Workflow is a name" reads best when the name means "a prompt against THIS
53
+ // repository". The repositories are the Workflow's `customize` line, not the run's input.
54
+
55
+ /** Mirrors `ThinkingLevel` as a value, since the door needs a zod enum and the type is a type.
56
+ * `satisfies` is what keeps the two from drifting: a level added to the type fails here. */
57
+ const THINKING_LEVELS = [
58
+ "off",
59
+ "minimal",
60
+ "low",
61
+ "medium",
62
+ "high",
63
+ "xhigh",
64
+ ] as const satisfies readonly ThinkingLevel[];
65
+
66
+ const door = z.object({
67
+ prompt: z.string().describe("What to do, in prose. The coder's first turn is this, framed with the worktree."),
68
+ branch: z
69
+ .string()
70
+ .optional()
71
+ .describe("The branch to cut and commit on. Default: jr2/task-<run id>, so concurrent runs never collide."),
72
+ // The two Dials (ADR-0018) as a PER-RUN escape hatch, not a default (ADR-0054): the bound
73
+ // definition is what `jr2 up` preflights, and a door value overrides it for this one run's Turns.
74
+ // Identity — instructions, workspace access — is deliberately absent: an invocation that rewrote
75
+ // those would make the Agent's name a lie.
76
+ model: z.string().optional().describe('Override the coder\'s model for this run only, as "<provider>/<modelId>".'),
77
+ thinkingLevel: z
78
+ .enum(THINKING_LEVELS)
79
+ .optional()
80
+ .describe("Override the coder's reasoning effort for this run only."),
81
+ });
82
+
83
+ /** What a caller sends. */
84
+ export type TaskInput = z.infer<typeof door>;
85
+
86
+ /** What a run of `task` settles as — approved by the human, or lost with its Workspace. */
87
+ export type TaskOutput = { outcome: "approved"; branch: string } | { outcome: "lost" };
88
+
89
+ // ---------------------------------------------------------------------------------------------
90
+ // Vocabulary (ADR-0011/0015), scoped to this Machine alone: the agent event becomes the coder
91
+ // state's Menu, the two external ones become the Gate's accepted set.
92
+
93
+ const finish = defineEvent({
94
+ name: "finish",
95
+ description: "End the turn: the work is committed on the branch and ready for a human to read.",
96
+ audience: "agent",
97
+ input: z.object({ summary: z.string().describe("What you did, in a few sentences.") }),
98
+ });
99
+
100
+ const approve = defineEvent({
101
+ name: "approve",
102
+ description: "Accept the work. The run finishes and the Workspace — and anything unpushed in it — is torn down.",
103
+ audience: "external",
104
+ input: z.object({}),
105
+ });
106
+
107
+ const requestChanges = defineEvent({
108
+ name: "request_changes",
109
+ description: "Send the work back to the coder with notes. The coder continues the same conversation.",
110
+ audience: "external",
111
+ input: z.object({ notes: z.string().describe("What to change, in prose.") }),
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------------------------
115
+ // The body. It receives the door PLUS the handles `workspace()` injects and, because the wrapper is
116
+ // this Machine's ROOT, the field the host injects beside the door (`instanceId`, ADR-0033).
117
+ //
118
+ // `Workspaced<…, string>` — the body names NO slot (ADR-0051): it works in the FIRST checkout the
119
+ // handles carry, which is whatever the consumer wrote first (`worktreeOf`), and frames every
120
+ // other one. That is what lets the wrapper leave the slot map Open; a body that named
121
+ // `repos.target` could not.
122
+
123
+ type BodyInput = Workspaced<TaskInput & HostInjectedInput, string>;
124
+
125
+ type BodyContext = BodyInput & {
126
+ /**
127
+ * Which conversation the coder's Turns ride. `request_changes` continues the SAME conversation —
128
+ * one human steering one Agent wants the Agent to remember what it did (ADR-0054) — but a
129
+ * terminal `agent.fault` means jr2 already rerolled that conversation and gave up on it
130
+ * (ADR-0035), so there is nothing left to continue. Bumping the generation folds into the
131
+ * conversation's disambiguator, and the next Turn starts a fresh conversation instead of
132
+ * addressing a dead one.
133
+ */
134
+ generation: number;
135
+ /** Turns COMPLETED on the current conversation. Zero means the next Turn is its first, so the
136
+ * prompt has to carry the whole task and the worktree framing again — which is exactly what a
137
+ * post-fault Turn needs and a continued one does not. */
138
+ turns: number;
139
+ /** The coder's own account of the last finished Turn — the Gate's `summary`. */
140
+ summary?: string;
141
+ /** Why the run is parked without a summary: the reason the fault carried — the Gate's `reason`. */
142
+ reason?: string;
143
+ /** The human's last notes, which are the next Turn's prompt. */
144
+ notes?: string;
145
+ };
146
+
147
+ /** The conversation pin (ADR-0016): a workflow-chosen name, so every `working` Turn of a run
148
+ * derives one deterministic instance id and the coder's context survives the round trip through
149
+ * the Gate. The name is the slot key, because that is what the conversation is about. */
150
+ const CONVERSATION = "coder";
151
+
152
+ export const body = jr2Setup({
153
+ types: {} as { context: BodyContext; input: BodyInput; output: TaskOutput },
154
+ events: [finish, approve, requestChanges],
155
+ // The Agent rides the Machine (ADR-0049): the slot key is its name on the Harness wire, in the
156
+ // minted iid, and in the `customize` line that binds its model.
157
+ actors: {
158
+ coder: agent({
159
+ // OPEN (ADR-0054). Not a default — a stock model would have this package pick a vendor and
160
+ // spend a user's money on a choice they never read. The consumer binds it with `customize`.
161
+ model: open,
162
+ description: "Works one task through to committed work in its own Workspace.",
163
+ // Explicit, though it is also the default (ADR-0028): this Agent writes, and the ADR-0031
164
+ // placement scan reads this field off the declaration to decide where its Turns run.
165
+ workspace: "write",
166
+ instructions: `You are a software engineer working alone on one task, in a container of your own.
167
+
168
+ - Edit only inside the worktree the conversation names as yours. Nothing you write outside it
169
+ survives. Any other checkout it names is there for you to read.
170
+ - Commit your work on the named branch. You cannot push, and you do not need to: a human reads the
171
+ branch in this container before the run ends.
172
+ - Read before you write, and prefer the smallest change that does the task.
173
+ - You MUST end your turn by calling \`finish\` exactly once, with a summary of what you did. An
174
+ uncalled tool parks the whole workflow waiting for you.`,
175
+ }),
176
+ },
177
+ }).createMachine({
178
+ id: "task",
179
+ context: ({ input }) => ({ ...input, generation: 0, turns: 0 }),
180
+ initial: "working",
181
+
182
+ // Our Sandbox is gone — reaped, or replaced after an eviction (ADR-0021). The pod-local clone and
183
+ // every unpushed commit went with it, so settle as lost rather than pretend we can resume.
184
+ on: { "workspace.lost": { target: ".lost" } },
185
+
186
+ states: {
187
+ working: {
188
+ invoke: {
189
+ src: "coder",
190
+ input: ({ context }) => ({
191
+ prompt: coderPrompt(context),
192
+ // One conversation per generation (see `BodyContext.generation`). `scope` is the
193
+ // disambiguator the pin already has for exactly this: it rides the derived iid, so a
194
+ // post-fault Turn addresses a new conversation instead of a dead one.
195
+ conversation: CONVERSATION,
196
+ scope: `g${context.generation}`,
197
+ // This run's Dials, if the caller set them (ADR-0018/0054) — passed straight through,
198
+ // layered over the bound definition when the Submission starts.
199
+ ...(context.model !== undefined ? { model: context.model } : {}),
200
+ ...(context.thinkingLevel !== undefined ? { thinkingLevel: context.thinkingLevel } : {}),
201
+ }),
202
+ },
203
+ on: {
204
+ finish: {
205
+ target: "review",
206
+ actions: assign({
207
+ summary: ({ event }) => event.summary,
208
+ reason: undefined,
209
+ notes: undefined,
210
+ turns: ({ context }) => context.turns + 1,
211
+ }),
212
+ },
213
+ // ADR-0016's ONE terminal telemetry, ROUTED: jr2 has already retried, nudged and rerolled
214
+ // (ADR-0027/0035), so this is the end of that conversation, not of the run. Park at the
215
+ // same Gate — the Workspace is still alive and the work so far is still on the branch —
216
+ // and start the next Turn on a fresh conversation.
217
+ "agent.fault": {
218
+ target: "review",
219
+ actions: assign({
220
+ reason: ({ event }) => event.reason,
221
+ summary: undefined,
222
+ generation: ({ context }) => context.generation + 1,
223
+ turns: 0,
224
+ }),
225
+ },
226
+ },
227
+ },
228
+
229
+ // The one park, and the whole point of the Machine: a human reads the branch and decides. The
230
+ // Gate id derives from the actor path (leaf = this state's key), so it stays fan-out-safe if
231
+ // this Machine is ever composed under a Pool; its accepted set derives from this state's
232
+ // external transitions; `meta` is what `jr2 status`, the Console's drawer, and a webhook
233
+ // translator read (ADR-0011/0015).
234
+ review: {
235
+ invoke: {
236
+ src: "gate",
237
+ input: ({ context }) => ({
238
+ meta: {
239
+ ...(context.summary !== undefined ? { summary: context.summary } : {}),
240
+ ...(context.reason !== undefined ? { reason: context.reason } : {}),
241
+ // The branch the attach actually made and the worktree the coder committed in — what
242
+ // an `exec` needs, and what a push would name.
243
+ branch: context.workspace.branch,
244
+ worktree: worktreeOf(context).path,
245
+ },
246
+ }),
247
+ },
248
+ on: {
249
+ approve: { target: "done" },
250
+ // No round cap (ADR-0054): the human is the cap. They see every Turn's result before the
251
+ // next one starts, so a count jr2 chose would only ever interrupt them.
252
+ request_changes: {
253
+ target: "working",
254
+ actions: assign({ notes: ({ event }) => event.notes, summary: undefined, reason: undefined }),
255
+ },
256
+ },
257
+ },
258
+
259
+ // Reaching a final state is what tears the Workspace down (ADR-0012): approve means done with
260
+ // the pod, and anything still unpushed in it goes too.
261
+ done: {
262
+ type: "final",
263
+ output: ({ context }): TaskOutput => ({ outcome: "approved", branch: context.workspace.branch }),
264
+ },
265
+ lost: { type: "final", output: { outcome: "lost" } satisfies TaskOutput },
266
+ },
267
+
268
+ // The body's output is whichever final state settled it — the wrapper forwards it verbatim, so
269
+ // `jr2 status` on a finished run says which of the two things happened.
270
+ output: ({ event }) => (event as { output?: TaskOutput }).output as TaskOutput,
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------------------------
274
+ // The wrapper is this Machine's ROOT, so its `input` is the run's door (ADR-0033) and both mappers
275
+ // are typed by it — nothing here restates a shape.
276
+
277
+ export const task = workspace(body, {
278
+ input: door,
279
+ // The Repo Slots, OPEN as a map (ADR-0051/0054): the consumer names every slot and every Repo.
280
+ // The package names none, because the body reads none — the first slot the consumer writes is
281
+ // the one the coder edits (this Machine's convention, `worktreeOf`), and the rest are checkouts
282
+ // the coder is told about and may read. A named
283
+ // `target: open` would have said "one repository", which is not what the body knows.
284
+ repos: open,
285
+ spec: ({ input }) => ({ branch: branchOf(input) }),
286
+ });
287
+
288
+ /**
289
+ * The branch this run works on: the door's, or `jr2/task-<run id>` (ADR-0054).
290
+ *
291
+ * The id is the run's seed Instance ID, which `RunHost.start` injects beside the door on the ROOT
292
+ * machine's input ({@link HostInjectedInput}) and `jr2 status` reports. It is not door material —
293
+ * no caller sends it and it is never served as JSON Schema (ADR-0033) — so the schema does not
294
+ * carry it and reading it takes a cast that says why.
295
+ *
296
+ * A `task` composed UNDER another Machine is fed by its parent rather than by the host, so the
297
+ * field is absent there and there is no per-run id to name a branch after. Refuse, loudly and
298
+ * before any pod exists: the alternative is a constant branch name, which two concurrent workers
299
+ * would both cut and neither would own.
300
+ */
301
+ function branchOf(input: TaskInput): string {
302
+ if (input.branch) return input.branch;
303
+ const { instanceId } = input as TaskInput & Partial<HostInjectedInput>;
304
+ if (!instanceId) {
305
+ throw new Error(
306
+ "task: no `branch` on the door and no run id to derive one from — `jr2/task-<run id>` needs the " +
307
+ "id the host injects beside the door of the ROOT machine (ADR-0033), and this run of `task` " +
308
+ "was started by a parent Machine instead. Pass `branch` in the run input.",
309
+ );
310
+ }
311
+ return `jr2/task-${instanceId}`;
312
+ }
313
+
314
+ /**
315
+ * The turn's framing. The FIRST Turn of a conversation carries the whole task and the geography,
316
+ * because the conversation holds nothing yet; every Turn after it carries the human's notes alone,
317
+ * because the coder remembers the rest (ADR-0054's continued conversation). A post-fault Turn is a
318
+ * first Turn again — that is what `turns` counts.
319
+ *
320
+ * The geography is every slot the handles carry (ADR-0051): the first is where the work goes, and
321
+ * each other checkout is named by the consumer's own word for it, at the path the attach put it —
322
+ * the only way the coder can learn what is beside it, since the package never knew.
323
+ */
324
+ function coderPrompt(context: BodyContext): string {
325
+ const notes = context.notes ? `\n\nThe human reviewed your work and asks for changes:\n${context.notes}` : "";
326
+ if (context.turns > 0) return notes.trimStart() || "Continue.";
327
+ const { branch, repos } = context.workspace;
328
+ const worktree = worktreeOf(context);
329
+ const beside = Object.entries(repos)
330
+ .filter(([slot]) => slot !== worktree.slot)
331
+ .map(([slot, path]) => `\n- ${slot}: ${path}`)
332
+ .join("");
333
+ return (
334
+ `${context.prompt}${notes}\n\n` +
335
+ `Work in ${worktree.path}, on branch ${branch}. Commit what you do there, then call finish.` +
336
+ (beside ? `\n\nAlso checked out beside it, for you to read:${beside}` : "")
337
+ );
338
+ }
339
+
340
+ /**
341
+ * The checkout the coder edits: the FIRST slot the consumer wrote. This is `task`'s convention,
342
+ * not the kit's — the handles keep the slots in declaration order and give none a meaning
343
+ * (ADR-0051) — so it is decided here, in one place, and stated on the line that registers the
344
+ * Machine. The wrapper refuses a map with no slot, so the first always exists.
345
+ */
346
+ function worktreeOf(context: Pick<BodyContext, "workspace">): { slot: string; path: string } {
347
+ const [slot, path] = Object.entries(context.workspace.repos)[0]!;
348
+ return { slot, path };
349
+ }