@jr2/machines 0.5.1 → 0.7.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 +48 -30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/machines",
3
- "version": "0.5.1",
3
+ "version": "0.7.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.5.1"
38
+ "@jr2/orchestrator": "0.7.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.5.1"
45
+ "@jr2/orchestrator": "0.7.0"
46
46
  },
47
47
  "files": [
48
48
  "src"
package/src/task.ts CHANGED
@@ -40,15 +40,18 @@ import {
40
40
  jr2Setup,
41
41
  open,
42
42
  workspace,
43
+ worktree,
44
+ branchName,
43
45
  type HostInjectedInput,
44
46
  type ThinkingLevel,
45
47
  type Workspaced,
48
+ type WorktreePath,
46
49
  } from "@jr2/orchestrator";
47
50
 
48
51
  // ---------------------------------------------------------------------------------------------
49
52
  // The door (ADR-0033): what a caller sends to start a run, declared on the `workspace()` that is
50
53
  // this Machine's root. One source of truth — the types below derive from it, the Console generates
51
- // its start form from it, and it types the spec mapper.
54
+ // its start form from it, and it types the body's input.
52
55
  //
53
56
  // No `repo` field, deliberately (ADR-0054): a packaged door cannot enumerate the Instance's
54
57
  // repositories, and "a Workflow is a name" reads best when the name means "a prompt against THIS
@@ -73,8 +76,7 @@ const door = z.object({
73
76
  * the seed Instance ID of the run (`instanceId` in `jr2 status`). It is not the run id. Two runs
74
77
  * never use the same branch. Required when `task` runs inside another Machine, such as a
75
78
  * `pool()`: there is no Instance ID to name the branch after. */
76
- branch: z
77
- .string()
79
+ branch: branchName
78
80
  .optional()
79
81
  .describe(
80
82
  "The branch to cut and commit on. Default: jr2/task-<instance id>, where <instance id> is the seed " +
@@ -172,6 +174,9 @@ const requestChanges = defineEvent({
172
174
  type BodyInput = Workspaced<TaskInput & HostInjectedInput, string>;
173
175
 
174
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;
175
180
  /** Turns COMPLETED on the conversation the next Turn will land on. Zero means that Turn is its
176
181
  * first, so the prompt has to carry the whole task and the worktree framing again — which is
177
182
  * exactly what a post-fault Turn needs and a continued one does not. It counts for the PROMPT
@@ -203,7 +208,6 @@ type BodyContext = BodyInput & {
203
208
  *
204
209
  * const fixed = workspace(body, {
205
210
  * repos: { app: { url: "https://github.com/you/app.git" } },
206
- * spec: ({ input }: { input: TaskInput }) => ({ branch: input.branch ?? "jr2/fix" }),
207
211
  * });
208
212
  *
209
213
  * export const machine = customize(fixed, {
@@ -238,27 +242,43 @@ export const body = jr2Setup({
238
242
  - You MUST end your turn by calling \`finish\` exactly once, with a summary of what you did. An
239
243
  uncalled tool parks the whole workflow waiting for you.`,
240
244
  }),
245
+ cut: worktree(),
241
246
  // The Failure (ADR-0073): the `lost` state invokes it, and the run ends `failed`.
242
247
  fail: fail(),
243
248
  },
244
249
  }).createMachine({
245
250
  id: "task",
246
- context: ({ input }) => ({ ...input, turns: 0 }),
247
- initial: "working",
251
+ context: ({ input }) => ({ ...input, branch: branchOf(input), turns: 0 }),
252
+ initial: "starting",
248
253
 
249
254
  // Our Sandbox is gone — reaped, or replaced after an eviction (ADR-0021). The pod-local clone and
250
255
  // every unpushed commit went with it, so fail the run rather than pretend we can resume.
251
256
  on: { "workspace.lost": { target: ".lost" } },
252
257
 
253
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
+ },
254
274
  working: {
255
275
  invoke: {
256
276
  src: "coder",
257
277
  input: ({ context }) => ({
258
278
  // This Turn's FRAME (ADR-0057): what it is about, and where it works. The `cwd` is
259
- // stated rather than left to the single-slot default, because "the first slot is the one
260
- // the coder edits" is this Machine's convention (`worktreeOf`) and a convention is
261
- // 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.
262
282
  prompt: coderPrompt(context),
263
283
  cwd: worktreeOf(context).path,
264
284
  // One human steering one Agent wants the Agent to remember what it did (ADR-0054), so
@@ -310,9 +330,9 @@ export const body = jr2Setup({
310
330
  meta: {
311
331
  ...(context.summary !== undefined ? { summary: context.summary } : {}),
312
332
  ...(context.reason !== undefined ? { reason: context.reason } : {}),
313
- // 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
314
334
  // an `exec` needs, and what a push would name.
315
- branch: context.workspace.branch,
335
+ branch: context.branch!,
316
336
  worktree: worktreeOf(context).path,
317
337
  },
318
338
  }),
@@ -332,11 +352,18 @@ export const body = jr2Setup({
332
352
  // the pod, and anything still unpushed in it goes too.
333
353
  done: {
334
354
  type: "final",
335
- output: ({ context }): TaskOutput => ({ branch: context.workspace.branch }),
355
+ output: ({ context }): TaskOutput => ({ branch: context.branch! }),
336
356
  },
337
357
  // Not a final state: a final state means `done`, and this work did not succeed. The Failure
338
358
  // goes up through the wrapper, and the run ends `failed` (ADR-0073). A parent catches it with
339
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" } } },
340
367
  lost: {
341
368
  invoke: {
342
369
  src: "fail",
@@ -409,7 +436,6 @@ export const task = workspace(body, {
409
436
  // the coder is told about and may read. A named
410
437
  // `target: open` would have said "one repository", which is not what the body knows.
411
438
  repos: open,
412
- spec: ({ input }) => ({ branch: branchOf(input) }),
413
439
  });
414
440
 
415
441
  /**
@@ -421,21 +447,12 @@ export const task = workspace(body, {
421
447
  * carry it and reading it takes a cast that says why.
422
448
  *
423
449
  * A `task` composed UNDER another Machine is fed by its parent rather than by the host, so the
424
- * field is absent there and there is no per-run id to name a branch after. Refuse, loudly and
425
- * before any pod exists: the alternative is a constant branch name, which two concurrent workers
426
- * 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.
427
451
  */
428
- function branchOf(input: TaskInput): string {
452
+ function branchOf(input: TaskInput): string | undefined {
429
453
  if (input.branch) return input.branch;
430
454
  const { instanceId } = input as TaskInput & Partial<HostInjectedInput>;
431
- if (!instanceId) {
432
- throw new Error(
433
- "task: no `branch` on the door and no Instance ID to derive one from — `jr2/task-<instance id>` needs " +
434
- "the id the host injects beside the door of the ROOT machine (ADR-0033), and this run of `task` " +
435
- "was started by a parent Machine instead. Pass `branch` in the run input.",
436
- );
437
- }
438
- return `jr2/task-${instanceId}`;
455
+ return instanceId ? `jr2/task-${instanceId}` : undefined;
439
456
  }
440
457
 
441
458
  /**
@@ -455,11 +472,12 @@ function coderPrompt(context: BodyContext): string {
455
472
  if (context.turns > 0) return notes.trimStart() || "Continue.";
456
473
  const fault = memoryKill(context.reason);
457
474
  const ended = fault ? `\n\nThe last attempt ended: ${fault}` : "";
458
- const { branch, repos } = context.workspace;
475
+ const { repos } = context.workspace;
476
+ const { branch } = context;
459
477
  const worktree = worktreeOf(context);
460
478
  const beside = Object.entries(repos)
461
479
  .filter(([slot]) => slot !== worktree.slot)
462
- .map(([slot, path]) => `\n- ${slot}: ${path}`)
480
+ .map(([slot, path]) => `\n- ${slot}: ${path}/default`)
463
481
  .join("");
464
482
  return (
465
483
  `${context.prompt}${ended}${notes}\n\n` +
@@ -487,7 +505,7 @@ function memoryKill(reason: string | undefined): string | undefined {
487
505
  * (ADR-0051) — so it is decided here, in one place, and stated on the line that registers the
488
506
  * Machine. The wrapper refuses a map with no slot, so the first always exists.
489
507
  */
490
- function worktreeOf(context: Pick<BodyContext, "workspace">): { slot: string; path: string } {
491
- const [slot, path] = Object.entries(context.workspace.repos)[0]!;
492
- 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` };
493
511
  }