@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 +21 -0
- package/README.md +50 -0
- package/package.json +54 -0
- package/src/index.ts +28 -0
- package/src/task.ts +349 -0
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
|
+
}
|