@jr2/machines 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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/task.ts +187 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/machines",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "The Machines the jr2 kit ships: packaged xstate Machines that leave their parts Open for a composer to bind.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -35,14 +35,14 @@
35
35
  "peerDependencies": {
36
36
  "xstate": "^5.18.0",
37
37
  "zod": "^4.4.3",
38
- "@jr2/orchestrator": "0.4.1"
38
+ "@jr2/orchestrator": "0.6.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^26.0.1",
42
42
  "typescript": "^5.6.0",
43
43
  "xstate": "^5.18.0",
44
44
  "zod": "^4.4.3",
45
- "@jr2/orchestrator": "0.4.1"
45
+ "@jr2/orchestrator": "0.6.0"
46
46
  },
47
47
  "files": [
48
48
  "src"
package/src/task.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  // target: { url: "https://github.com/you/repo.git" },
18
18
  // reference: { url: "https://github.com/you/lib.git" }, // optional: more checkouts to read
19
19
  // },
20
- // agents: { coder: { model: "anthropic/claude-sonnet-4-6" } },
20
+ // agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
21
21
  // });
22
22
  //
23
23
  // `jr2 up` walks the registered Machines and refuses either part unbound, printing exactly that
@@ -36,18 +36,22 @@ import { z } from "zod";
36
36
  import {
37
37
  agent,
38
38
  defineEvent,
39
+ fail,
39
40
  jr2Setup,
40
41
  open,
41
42
  workspace,
43
+ worktree,
44
+ branchName,
42
45
  type HostInjectedInput,
43
46
  type ThinkingLevel,
44
47
  type Workspaced,
48
+ type WorktreePath,
45
49
  } from "@jr2/orchestrator";
46
50
 
47
51
  // ---------------------------------------------------------------------------------------------
48
52
  // The door (ADR-0033): what a caller sends to start a run, declared on the `workspace()` that is
49
53
  // this Machine's root. One source of truth — the types below derive from it, the Console generates
50
- // its start form from it, and it types the spec mapper.
54
+ // its start form from it, and it types the body's input.
51
55
  //
52
56
  // No `repo` field, deliberately (ADR-0054): a packaged door cannot enumerate the Instance's
53
57
  // repositories, and "a Workflow is a name" reads best when the name means "a prompt against THIS
@@ -65,27 +69,73 @@ const THINKING_LEVELS = [
65
69
  ] as const satisfies readonly ThinkingLevel[];
66
70
 
67
71
  const door = z.object({
72
+ /** What to do, in prose. Required. The coder's first Turn is this prompt, with the Worktree it
73
+ * works in. */
68
74
  prompt: z.string().describe("What to do, in prose. The coder's first turn is this, framed with the worktree."),
69
- branch: z
70
- .string()
75
+ /** The branch to cut and commit on. Default: `jr2/task-<instance id>`, where `<instance id>` is
76
+ * the seed Instance ID of the run (`instanceId` in `jr2 status`). It is not the run id. Two runs
77
+ * never use the same branch. Required when `task` runs inside another Machine, such as a
78
+ * `pool()`: there is no Instance ID to name the branch after. */
79
+ branch: branchName
71
80
  .optional()
72
- .describe("The branch to cut and commit on. Default: jr2/task-<run id>, so concurrent runs never collide."),
81
+ .describe(
82
+ "The branch to cut and commit on. Default: jr2/task-<instance id>, where <instance id> is the seed " +
83
+ "Instance ID of the run (instanceId in jr2 status). It is not the run id.",
84
+ ),
73
85
  // The two Dials (ADR-0018) as a PER-RUN escape hatch, not a default (ADR-0054): the bound
74
86
  // definition is what `jr2 up` preflights, and a door value overrides it for this one run's Turns.
75
87
  // Identity — instructions, workspace access — is deliberately absent: an invocation that rewrote
76
88
  // those would make the Agent's name a lie.
89
+ /** The coder's model for this run only, as `"<provider>/<modelId>"`. Default: the model of the
90
+ * coder Agent. */
77
91
  model: z.string().optional().describe('Override the coder\'s model for this run only, as "<provider>/<modelId>".'),
92
+ /** The coder's thinking level for this run only: `off`, `minimal`, `low`, `medium`, `high` or
93
+ * `xhigh`. Default: the thinking level of the coder Agent. */
78
94
  thinkingLevel: z
79
95
  .enum(THINKING_LEVELS)
80
96
  .optional()
81
97
  .describe("Override the coder's reasoning effort for this run only."),
82
98
  });
83
99
 
84
- /** What a caller sends. */
100
+ /**
101
+ * The run input of `task`: what a caller sends to start a run, for example with
102
+ * `jr2 run <workflow> --input '{"prompt":"…"}'`.
103
+ *
104
+ * `prompt` is required. `branch`, `model` and `thinkingLevel` are optional. Each field has its own
105
+ * doc.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * import type { TaskInput } from "@jr2/machines";
110
+ *
111
+ * export const input: TaskInput = {
112
+ * prompt: "Make the failing test in test/greet.test.ts pass.",
113
+ * branch: "fix/greet",
114
+ * thinkingLevel: "high",
115
+ * };
116
+ * ```
117
+ *
118
+ * @see @jr2/orchestrator/docs/reference/machines.md
119
+ */
85
120
  export type TaskInput = z.infer<typeof door>;
86
121
 
87
- /** What a run of `task` settles as — approved by the human, or lost with its Workspace. */
88
- export type TaskOutput = { outcome: "approved"; branch: string } | { outcome: "lost" };
122
+ /**
123
+ * The output of a run of `task` that ends `done`: `{ branch }`, the branch of the work that the
124
+ * human approved. A run whose Workspace is lost has no output of this type: it fails, and the run
125
+ * ends `failed` with the reason `the Workspace was lost` (ADR-0073).
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * import type { TaskOutput } from "@jr2/machines";
130
+ *
131
+ * export function describe(output: TaskOutput): string {
132
+ * return `approved on ${output.branch}`;
133
+ * }
134
+ * ```
135
+ *
136
+ * @see @jr2/orchestrator/docs/reference/machines.md
137
+ */
138
+ export type TaskOutput = { branch: string };
89
139
 
90
140
  // ---------------------------------------------------------------------------------------------
91
141
  // Vocabulary (ADR-0011/0015), scoped to this Machine alone: the agent event becomes the coder
@@ -124,6 +174,9 @@ const requestChanges = defineEvent({
124
174
  type BodyInput = Workspaced<TaskInput & HostInjectedInput, string>;
125
175
 
126
176
  type BodyContext = BodyInput & {
177
+ /** The branch of this run: the door's, or `jr2/task-<instance id>`. Undefined only under a parent
178
+ * Machine with no `branch` in its input. */
179
+ branch: string | undefined;
127
180
  /** Turns COMPLETED on the conversation the next Turn will land on. Zero means that Turn is its
128
181
  * first, so the prompt has to carry the whole task and the worktree framing again — which is
129
182
  * exactly what a post-fault Turn needs and a continued one does not. It counts for the PROMPT
@@ -141,6 +194,30 @@ type BodyContext = BodyInput & {
141
194
  notes?: string;
142
195
  };
143
196
 
197
+ /**
198
+ * The Machine inside `task`: the coder's loop and the `review` Gate, with no `workspace()` around
199
+ * it. Use `task` to run it. Use `body` only to put the same loop in a `workspace()` of your own.
200
+ * For example, your `workspace()` can have Repo Slots or a run input that differ from those of
201
+ * `task`. Its input is the run input plus the `workspace` handles, and it works in the first Repo
202
+ * Slot of those handles.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * import { customize, workspace } from "@jr2/orchestrator";
207
+ * import { body, type TaskInput } from "@jr2/machines";
208
+ *
209
+ * const fixed = workspace(body, {
210
+ * repos: { app: { url: "https://github.com/you/app.git" } },
211
+ * });
212
+ *
213
+ * export const machine = customize(fixed, {
214
+ * agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
215
+ * });
216
+ * ```
217
+ *
218
+ * @see @jr2/orchestrator/docs/reference/machines.md
219
+ * @see @jr2/orchestrator/docs/concepts/workspaces.md
220
+ */
144
221
  export const body = jr2Setup({
145
222
  types: {} as { context: BodyContext; input: BodyInput; output: TaskOutput },
146
223
  events: [finish, approve, requestChanges],
@@ -165,25 +242,43 @@ export const body = jr2Setup({
165
242
  - You MUST end your turn by calling \`finish\` exactly once, with a summary of what you did. An
166
243
  uncalled tool parks the whole workflow waiting for you.`,
167
244
  }),
245
+ cut: worktree(),
246
+ // The Failure (ADR-0073): the `lost` state invokes it, and the run ends `failed`.
247
+ fail: fail(),
168
248
  },
169
249
  }).createMachine({
170
250
  id: "task",
171
- context: ({ input }) => ({ ...input, turns: 0 }),
172
- initial: "working",
251
+ context: ({ input }) => ({ ...input, branch: branchOf(input), turns: 0 }),
252
+ initial: "starting",
173
253
 
174
254
  // Our Sandbox is gone — reaped, or replaced after an eviction (ADR-0021). The pod-local clone and
175
- // every unpushed commit went with it, so settle as lost rather than pretend we can resume.
255
+ // every unpushed commit went with it, so fail the run rather than pretend we can resume.
176
256
  on: { "workspace.lost": { target: ".lost" } },
177
257
 
178
258
  states: {
259
+ // A bad name is the door's (`branchName`): it costs no pod, under a parent also, because
260
+ // `place` holds an invocation's input to the wrapper's door. A name that NO ONE gave cannot be a
261
+ // door rule: the host adds `instanceId` after the door. So this one line stays, and it costs a
262
+ // pod: a Failure, and not a defect: under a pool(), one item fails, and the run goes on.
263
+ starting: {
264
+ always: [{ guard: ({ context }) => context.branch === undefined, target: "noBranch" }, { target: "cutting" }],
265
+ },
266
+ // The first slot is the coder's. A slot after it needs no cut: `<slot>/default` is on its base.
267
+ cutting: {
268
+ invoke: {
269
+ src: "cut",
270
+ input: ({ context }) => ({ path: worktreeOf(context).path, branch: context.branch! }),
271
+ onDone: [{ guard: ({ event }) => event.output.outcome === "ready", target: "working" }, { target: "notCut" }],
272
+ },
273
+ },
179
274
  working: {
180
275
  invoke: {
181
276
  src: "coder",
182
277
  input: ({ context }) => ({
183
278
  // This Turn's FRAME (ADR-0057): what it is about, and where it works. The `cwd` is
184
- // stated rather than left to the single-slot default, because "the first slot is the one
185
- // the coder edits" is this Machine's convention (`worktreeOf`) and a convention is
186
- // stated — a consumer who binds a second slot must not change where the coder works.
279
+ // required under Repo Slots (ADR-0075): "the first slot is the one the coder edits" is this
280
+ // Machine's convention (`worktreeOf`), and a consumer who binds a second slot must not
281
+ // change where the coder works.
187
282
  prompt: coderPrompt(context),
188
283
  cwd: worktreeOf(context).path,
189
284
  // One human steering one Agent wants the Agent to remember what it did (ADR-0054), so
@@ -235,9 +330,9 @@ export const body = jr2Setup({
235
330
  meta: {
236
331
  ...(context.summary !== undefined ? { summary: context.summary } : {}),
237
332
  ...(context.reason !== undefined ? { reason: context.reason } : {}),
238
- // The branch the attach actually made and the worktree the coder committed in — what
333
+ // The branch the cut made and the Worktree the coder committed in — what
239
334
  // an `exec` needs, and what a push would name.
240
- branch: context.workspace.branch,
335
+ branch: context.branch!,
241
336
  worktree: worktreeOf(context).path,
242
337
  },
243
338
  }),
@@ -257,13 +352,30 @@ export const body = jr2Setup({
257
352
  // the pod, and anything still unpushed in it goes too.
258
353
  done: {
259
354
  type: "final",
260
- output: ({ context }): TaskOutput => ({ outcome: "approved", branch: context.workspace.branch }),
355
+ output: ({ context }): TaskOutput => ({ branch: context.branch! }),
356
+ },
357
+ // Not a final state: a final state means `done`, and this work did not succeed. The Failure
358
+ // goes up through the wrapper, and the run ends `failed` (ADR-0073). A parent catches it with
359
+ // an `onError` that `isFailure` guards.
360
+ noBranch: {
361
+ invoke: {
362
+ src: "fail",
363
+ input: { reason: "task: no `branch` on the door and no Instance ID to derive one from" },
364
+ },
365
+ },
366
+ notCut: { invoke: { src: "fail", input: { reason: "task: the Worktree was not cut" } } },
367
+ lost: {
368
+ invoke: {
369
+ src: "fail",
370
+ input: {
371
+ reason: "the Workspace was lost",
372
+ detail: { lost: "the Sandbox is gone, with every commit that was not pushed" },
373
+ },
374
+ },
261
375
  },
262
- lost: { type: "final", output: { outcome: "lost" } satisfies TaskOutput },
263
376
  },
264
377
 
265
- // The body's output is whichever final state settled it — the wrapper forwards it verbatim, so
266
- // `jr2 status` on a finished run says which of the two things happened.
378
+ // The body's output is the output of its one final state, `done`; the wrapper forwards it.
267
379
  output: ({ event }) => (event as { output?: TaskOutput }).output as TaskOutput,
268
380
  });
269
381
 
@@ -271,6 +383,51 @@ export const body = jr2Setup({
271
383
  // The wrapper is this Machine's ROOT, so its `input` is the run's door (ADR-0033) and both mappers
272
384
  // are typed by it — nothing here restates a shape.
273
385
 
386
+ /**
387
+ * A Machine in which a coder Agent works on one prompt in a Workspace until a human approves the
388
+ * work. The coder works in a Sandbox and commits on a branch. Then the run waits at the `review`
389
+ * Gate. The human answers `request_changes` with `notes`, and the coder continues the same
390
+ * conversation, or `approve`, and the run ends. A run has no limit on the number of rounds.
391
+ *
392
+ * `task` does not push. While the run waits at the Gate, the Sandbox stays. You can exec into it
393
+ * and read the branch. To keep the work, push it yourself. `approve` removes the Sandbox, and
394
+ * every commit that was not pushed goes with it. If the Sandbox is lost, `task` fails: the run
395
+ * ends `failed` with the reason `the Workspace was lost`. A parent Machine catches that Failure
396
+ * with an `onError` that `isFailure` guards.
397
+ *
398
+ * `task` leaves two parts Open, so you bind them with `customize()` where you register it:
399
+ *
400
+ * - `repos`: the whole Repo Slot map. Name every slot, and at least one. The first slot is the
401
+ * Repo that the coder works in. The coder can read the others.
402
+ * - `agents.coder.model`: the coder's model, as `"<provider>/<modelId>"`. You can also change the
403
+ * coder's other fields, such as `instructions` or `thinkingLevel`.
404
+ *
405
+ * `jr2 up` refuses a Workflow that leaves either part Open, and prints the `customize()` line
406
+ * that binds it. You can also give `task` an `image`, a `user` or a Size (`resources`). Without an
407
+ * image, it uses the Instance's `images/default`, or jr2's Harness image when there is none.
408
+ * Without a Size, it uses the Instance's `sandbox.resources`, or the kit's Size when it has none.
409
+ * Inside another Machine, `task` needs `branch` in its input, or it refuses:
410
+ * ``task: no `branch` on the door and no Instance ID to derive one from``.
411
+ *
412
+ * @example
413
+ * ```ts
414
+ * import { customize } from "@jr2/orchestrator";
415
+ * import { task } from "@jr2/machines";
416
+ *
417
+ * export const machine = customize(task, {
418
+ * repos: {
419
+ * app: { url: "https://github.com/you/app.git" },
420
+ * lib: { url: "https://github.com/you/lib.git" },
421
+ * },
422
+ * agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
423
+ * });
424
+ * ```
425
+ *
426
+ * @see @jr2/orchestrator/docs/reference/machines.md
427
+ * @see @jr2/orchestrator/docs/get-started/an-agent-in-a-sandbox.md
428
+ * @see @jr2/orchestrator/docs/guides/bind-open-parts.md
429
+ * @see @jr2/orchestrator/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md
430
+ */
274
431
  export const task = workspace(body, {
275
432
  input: door,
276
433
  // The Repo Slots, OPEN as a map (ADR-0051/0054): the consumer names every slot and every Repo.
@@ -279,11 +436,10 @@ export const task = workspace(body, {
279
436
  // the coder is told about and may read. A named
280
437
  // `target: open` would have said "one repository", which is not what the body knows.
281
438
  repos: open,
282
- spec: ({ input }) => ({ branch: branchOf(input) }),
283
439
  });
284
440
 
285
441
  /**
286
- * The branch this run works on: the door's, or `jr2/task-<run id>` (ADR-0054).
442
+ * The branch this run works on: the door's, or `jr2/task-<instance id>` (ADR-0054).
287
443
  *
288
444
  * The id is the run's seed Instance ID, which `RunHost.start` injects beside the door on the ROOT
289
445
  * machine's input ({@link HostInjectedInput}) and `jr2 status` reports. It is not door material —
@@ -291,21 +447,12 @@ export const task = workspace(body, {
291
447
  * carry it and reading it takes a cast that says why.
292
448
  *
293
449
  * A `task` composed UNDER another Machine is fed by its parent rather than by the host, so the
294
- * field is absent there and there is no per-run id to name a branch after. Refuse, loudly and
295
- * before any pod exists: the alternative is a constant branch name, which two concurrent workers
296
- * would both cut and neither would own.
450
+ * field is absent there and there is no per-run id to name a branch after: undefined.
297
451
  */
298
- function branchOf(input: TaskInput): string {
452
+ function branchOf(input: TaskInput): string | undefined {
299
453
  if (input.branch) return input.branch;
300
454
  const { instanceId } = input as TaskInput & Partial<HostInjectedInput>;
301
- if (!instanceId) {
302
- throw new Error(
303
- "task: no `branch` on the door and no run id to derive one from — `jr2/task-<run id>` needs the " +
304
- "id the host injects beside the door of the ROOT machine (ADR-0033), and this run of `task` " +
305
- "was started by a parent Machine instead. Pass `branch` in the run input.",
306
- );
307
- }
308
- return `jr2/task-${instanceId}`;
455
+ return instanceId ? `jr2/task-${instanceId}` : undefined;
309
456
  }
310
457
 
311
458
  /**
@@ -325,11 +472,12 @@ function coderPrompt(context: BodyContext): string {
325
472
  if (context.turns > 0) return notes.trimStart() || "Continue.";
326
473
  const fault = memoryKill(context.reason);
327
474
  const ended = fault ? `\n\nThe last attempt ended: ${fault}` : "";
328
- const { branch, repos } = context.workspace;
475
+ const { repos } = context.workspace;
476
+ const { branch } = context;
329
477
  const worktree = worktreeOf(context);
330
478
  const beside = Object.entries(repos)
331
479
  .filter(([slot]) => slot !== worktree.slot)
332
- .map(([slot, path]) => `\n- ${slot}: ${path}`)
480
+ .map(([slot, path]) => `\n- ${slot}: ${path}/default`)
333
481
  .join("");
334
482
  return (
335
483
  `${context.prompt}${ended}${notes}\n\n` +
@@ -357,7 +505,7 @@ function memoryKill(reason: string | undefined): string | undefined {
357
505
  * (ADR-0051) — so it is decided here, in one place, and stated on the line that registers the
358
506
  * Machine. The wrapper refuses a map with no slot, so the first always exists.
359
507
  */
360
- function worktreeOf(context: Pick<BodyContext, "workspace">): { slot: string; path: string } {
361
- const [slot, path] = Object.entries(context.workspace.repos)[0]!;
362
- return { slot, path };
508
+ function worktreeOf(context: Pick<BodyContext, "workspace">): { slot: string; path: WorktreePath<string> } {
509
+ const [slot, folder] = Object.entries(context.workspace.repos)[0]!;
510
+ return { slot, path: `${folder}/task` };
363
511
  }