@markjaquith/agency 2.30.1 → 2.32.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 (38) hide show
  1. package/README.md +56 -38
  2. package/cli.ts +2 -1
  3. package/package.json +5 -2
  4. package/skills/agency/SKILL.md +7 -6
  5. package/skills/agency/references/commands.md +17 -14
  6. package/skills/agency/references/contracts.md +4 -5
  7. package/skills/agency/references/recipes.md +13 -10
  8. package/src/cli-parser.test.ts +12 -2
  9. package/src/cli-parser.ts +5 -2
  10. package/src/cli.test.ts +34 -10
  11. package/src/commands/doctor.test.ts +11 -1
  12. package/src/commands/phase.ts +1 -1
  13. package/src/commands/task.test.ts +34 -1
  14. package/src/commands/task.ts +16 -20
  15. package/src/commands/work.test.ts +65 -65
  16. package/src/commands/work.ts +43 -35
  17. package/src/opentui.d.ts +1 -0
  18. package/src/services/DoctorService.ts +22 -0
  19. package/src/services/EpicService.test.ts +3 -0
  20. package/src/services/EpicService.ts +2 -1
  21. package/src/services/IntegrationService.test.ts +2 -1
  22. package/src/services/PhaseService.ts +5 -4
  23. package/src/services/PullRequestService.test.ts +4 -1
  24. package/src/services/ReadinessService.test.ts +54 -0
  25. package/src/services/ReadinessService.ts +39 -2
  26. package/src/services/TaskPhaseService.test.ts +38 -11
  27. package/src/services/TaskService.ts +4 -3
  28. package/src/utils/chooser.test.ts +49 -12
  29. package/src/utils/chooser.ts +20 -37
  30. package/src/utils/interactive-loader.ts +6 -0
  31. package/src/utils/interactive.test.tsx +258 -0
  32. package/src/utils/interactive.tsx +290 -0
  33. package/src/workbase/AGENTS.md +5 -5
  34. package/src/workbase/frontmatter.ts +17 -0
  35. package/src/workbase/runner-command.test.ts +30 -4
  36. package/src/workbase/runner-command.ts +21 -6
  37. package/src/workbase/schemas.test.ts +4 -2
  38. package/src/workbase/schemas.ts +4 -0
@@ -0,0 +1,258 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { testRender } from "@opentui/solid"
3
+ import {
4
+ fuzzyChoices,
5
+ InteractiveSelectPrompt,
6
+ InteractiveTextPrompt,
7
+ interactiveRendererConfig,
8
+ } from "./interactive"
9
+
10
+ describe("OpenTUI interaction", () => {
11
+ test("uses the split-footer renderer contract", () => {
12
+ expect(interactiveRendererConfig).toMatchObject({
13
+ screenMode: "split-footer",
14
+ footerHeight: 4,
15
+ externalOutputMode: "capture-stdout",
16
+ clearOnShutdown: false,
17
+ })
18
+ })
19
+
20
+ test("ranks case-insensitive fuzzy matches", () => {
21
+ const choices = [
22
+ { key: "nested", label: "Manage Agency" },
23
+ { key: "prefix", label: "Agency" },
24
+ { key: "other", label: "Website" },
25
+ ]
26
+
27
+ expect(fuzzyChoices(choices, "AG").map((choice) => choice.key)).toEqual([
28
+ "prefix",
29
+ "nested",
30
+ ])
31
+ expect(fuzzyChoices(choices, "mgy").map((choice) => choice.key)).toEqual([
32
+ "nested",
33
+ ])
34
+ expect(fuzzyChoices(choices, "zzz")).toEqual([])
35
+ })
36
+
37
+ test("selects choices with ctrl-p, ctrl-n, and arrow navigation", async () => {
38
+ const selectAfter = async (key: "p" | "n" | "up" | "down") => {
39
+ let selected: string | null | undefined
40
+ const setup = await testRender(
41
+ () => (
42
+ <InteractiveSelectPrompt
43
+ prompt="Repository"
44
+ choices={[
45
+ { key: "agency", label: "agency" },
46
+ { key: "web", label: "web" },
47
+ { key: "docs", label: "docs" },
48
+ ]}
49
+ onDone={(value) => {
50
+ selected = value
51
+ }}
52
+ />
53
+ ),
54
+ { width: 60, height: 4 },
55
+ )
56
+ try {
57
+ await setup.renderer.setupTerminal()
58
+ await setup.renderOnce()
59
+ await Bun.sleep(0)
60
+ if (key === "up" || key === "down") {
61
+ setup.mockInput.pressArrow(key)
62
+ } else {
63
+ setup.mockInput.pressKey(key, { ctrl: true })
64
+ }
65
+ setup.mockInput.pressEnter()
66
+ await setup.waitFor(() => selected !== undefined)
67
+ return selected
68
+ } finally {
69
+ setup.renderer.destroy()
70
+ }
71
+ }
72
+
73
+ expect(await selectAfter("p")).toBe("docs")
74
+ expect(await selectAfter("n")).toBe("web")
75
+ expect(await selectAfter("up")).toBe("docs")
76
+ expect(await selectAfter("down")).toBe("web")
77
+ })
78
+
79
+ test("uses printable j, k, and q characters as the fuzzy query", async () => {
80
+ for (const query of ["j", "k", "q"]) {
81
+ let selected: string | null | undefined
82
+ const setup = await testRender(
83
+ () => (
84
+ <InteractiveSelectPrompt
85
+ prompt="Repository"
86
+ choices={[
87
+ { key: query, label: `${query} target` },
88
+ { key: "other", label: "Other" },
89
+ ]}
90
+ onDone={(value) => {
91
+ selected = value
92
+ }}
93
+ />
94
+ ),
95
+ { width: 60, height: 4 },
96
+ )
97
+ try {
98
+ await setup.renderer.setupTerminal()
99
+ await setup.renderOnce()
100
+ await Bun.sleep(0)
101
+ await setup.mockInput.typeText(query)
102
+ setup.mockInput.pressEnter()
103
+ await setup.waitFor(() => selected !== undefined)
104
+ expect(selected).toBe(query)
105
+ } finally {
106
+ setup.renderer.destroy()
107
+ }
108
+ }
109
+ })
110
+
111
+ test("does not select an empty result and supports ctrl-u editing", async () => {
112
+ let selected: string | null | undefined
113
+ const setup = await testRender(
114
+ () => (
115
+ <InteractiveSelectPrompt
116
+ prompt="Repository"
117
+ choices={[
118
+ { key: "agency", label: "agency" },
119
+ { key: "web", label: "web" },
120
+ ]}
121
+ onDone={(value) => {
122
+ selected = value
123
+ }}
124
+ />
125
+ ),
126
+ { width: 60, height: 4 },
127
+ )
128
+ try {
129
+ await setup.renderer.setupTerminal()
130
+ await setup.renderOnce()
131
+ await Bun.sleep(0)
132
+ await setup.mockInput.typeText("zzz")
133
+ setup.mockInput.pressEnter()
134
+ await Bun.sleep(0)
135
+ expect(selected).toBeUndefined()
136
+ setup.mockInput.pressKey("u", { ctrl: true })
137
+ await setup.mockInput.typeText("web")
138
+ setup.mockInput.pressEnter()
139
+ await setup.waitFor(() => selected !== undefined)
140
+ expect(selected).toBe("web")
141
+ } finally {
142
+ setup.renderer.destroy()
143
+ }
144
+ })
145
+
146
+ test("resets and navigates selection after filtering", async () => {
147
+ let selected: string | null | undefined
148
+ const setup = await testRender(
149
+ () => (
150
+ <InteractiveSelectPrompt
151
+ prompt="Repository"
152
+ choices={[
153
+ { key: "agency", label: "agency" },
154
+ { key: "web-one", label: "web one" },
155
+ { key: "web-two", label: "web two" },
156
+ { key: "docs", label: "docs" },
157
+ ]}
158
+ onDone={(value) => {
159
+ selected = value
160
+ }}
161
+ />
162
+ ),
163
+ { width: 60, height: 4 },
164
+ )
165
+ try {
166
+ await setup.renderer.setupTerminal()
167
+ await setup.renderOnce()
168
+ await Bun.sleep(0)
169
+ setup.mockInput.pressArrow("down")
170
+ setup.mockInput.pressArrow("down")
171
+ setup.mockInput.pressArrow("down")
172
+ await setup.mockInput.typeText("web")
173
+ setup.mockInput.pressKey("n", { ctrl: true })
174
+ setup.mockInput.pressEnter()
175
+ await setup.waitFor(() => selected !== undefined)
176
+ expect(selected).toBe("web-two")
177
+ } finally {
178
+ setup.renderer.destroy()
179
+ }
180
+ })
181
+
182
+ test("submits text and cancels with ctrl-c or escape", async () => {
183
+ let submitted: string | null | undefined
184
+ const input = await testRender(
185
+ () => (
186
+ <InteractiveTextPrompt
187
+ prompt="Task ID"
188
+ onDone={(value) => {
189
+ submitted = value
190
+ }}
191
+ />
192
+ ),
193
+ { width: 60, height: 4 },
194
+ )
195
+ try {
196
+ await input.renderer.setupTerminal()
197
+ await input.renderOnce()
198
+ await Bun.sleep(0)
199
+ expect(input.renderer.keyInput.listenerCount("keypress")).toBeGreaterThan(
200
+ 0,
201
+ )
202
+ await input.mockInput.typeText("improve-ui")
203
+ input.mockInput.pressEnter()
204
+ await input.waitFor(() => submitted !== undefined)
205
+ expect(submitted).toBe("improve-ui")
206
+ } finally {
207
+ input.renderer.destroy()
208
+ }
209
+
210
+ let cancelled: string | null | undefined
211
+ const select = await testRender(
212
+ () => (
213
+ <InteractiveSelectPrompt
214
+ prompt="Cancel"
215
+ choices={[{ key: "one", label: "One" }]}
216
+ onDone={(value) => {
217
+ cancelled = value
218
+ }}
219
+ />
220
+ ),
221
+ { width: 60, height: 4 },
222
+ )
223
+ try {
224
+ await select.renderer.setupTerminal()
225
+ await select.renderOnce()
226
+ await Bun.sleep(0)
227
+ select.mockInput.pressCtrlC()
228
+ await select.waitFor(() => cancelled !== undefined)
229
+ expect(cancelled).toBeNull()
230
+ } finally {
231
+ select.renderer.destroy()
232
+ }
233
+
234
+ let escaped: string | null | undefined
235
+ const escape = await testRender(
236
+ () => (
237
+ <InteractiveSelectPrompt
238
+ prompt="Cancel"
239
+ choices={[{ key: "one", label: "One" }]}
240
+ onDone={(value) => {
241
+ escaped = value
242
+ }}
243
+ />
244
+ ),
245
+ { width: 60, height: 4, kittyKeyboard: true },
246
+ )
247
+ try {
248
+ await escape.renderer.setupTerminal()
249
+ await escape.renderOnce()
250
+ await Bun.sleep(0)
251
+ escape.mockInput.pressEscape()
252
+ await escape.waitFor(() => escaped !== undefined)
253
+ expect(escaped).toBeNull()
254
+ } finally {
255
+ escape.renderer.destroy()
256
+ }
257
+ })
258
+ })
@@ -0,0 +1,290 @@
1
+ import {
2
+ createCliRenderer,
3
+ type CliRenderer,
4
+ type CliRendererConfig,
5
+ type InputRenderable,
6
+ } from "@opentui/core"
7
+ import { render, useKeyboard, type JSX } from "@opentui/solid"
8
+ import { createMemo, createSignal, For } from "solid-js"
9
+
10
+ export interface InteractiveChoice {
11
+ readonly key: string
12
+ readonly label: string
13
+ }
14
+
15
+ export const interactiveRendererConfig = {
16
+ screenMode: "split-footer",
17
+ footerHeight: 4,
18
+ externalOutputMode: "capture-stdout",
19
+ consoleMode: "disabled",
20
+ clearOnShutdown: false,
21
+ exitOnCtrlC: false,
22
+ useMouse: false,
23
+ autoFocus: false,
24
+ openConsoleOnError: false,
25
+ } satisfies CliRendererConfig
26
+
27
+ interface PromptProps<T> {
28
+ readonly prompt: string
29
+ readonly onDone: (value: T | null) => void
30
+ }
31
+
32
+ const isCancel = (key: { name: string; ctrl: boolean }) =>
33
+ key.name === "escape" || (key.ctrl && key.name === "c")
34
+
35
+ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
36
+ let input: InputRenderable | undefined
37
+ let value = ""
38
+ useKeyboard((key) => {
39
+ if (isCancel(key)) {
40
+ key.preventDefault()
41
+ key.stopPropagation()
42
+ props.onDone(null)
43
+ return
44
+ }
45
+ if (key.name !== "return") return
46
+ key.preventDefault()
47
+ key.stopPropagation()
48
+ props.onDone(value)
49
+ })
50
+
51
+ return (
52
+ <box flexDirection="column" width="100%" height="100%">
53
+ <text fg="#7aa2f7">{props.prompt}</text>
54
+ <input
55
+ focused
56
+ onInput={(next) => {
57
+ value = next
58
+ }}
59
+ ref={(next) => {
60
+ input = next
61
+ queueMicrotask(() => {
62
+ if (input && !input.isDestroyed) input.focus()
63
+ })
64
+ }}
65
+ />
66
+ <text fg="#6c7086">enter submit | esc cancel</text>
67
+ </box>
68
+ )
69
+ }
70
+
71
+ interface SelectPromptProps extends PromptProps<string> {
72
+ readonly choices: readonly InteractiveChoice[]
73
+ }
74
+
75
+ const isWordBoundary = (value: string, index: number) =>
76
+ index === 0 || /[\s/_.:-]/.test(value[index - 1]!)
77
+
78
+ const fuzzyScore = (value: string, query: string) => {
79
+ const candidate = value.toLowerCase()
80
+ const needle = query.toLowerCase()
81
+ let previous = new Float64Array(candidate.length)
82
+ let current = new Float64Array(candidate.length)
83
+ previous.fill(Number.NEGATIVE_INFINITY)
84
+ let bestScore = Number.NEGATIVE_INFINITY
85
+
86
+ for (let queryIndex = 0; queryIndex < needle.length; queryIndex++) {
87
+ current.fill(Number.NEGATIVE_INFINITY)
88
+ let bestEarlier = Number.NEGATIVE_INFINITY
89
+ bestScore = Number.NEGATIVE_INFINITY
90
+ for (let index = 0; index < candidate.length; index++) {
91
+ if (queryIndex > 0 && index > 0) {
92
+ bestEarlier = Math.max(bestEarlier, previous[index - 1]! + index - 1)
93
+ }
94
+ if (candidate[index] !== needle[queryIndex]) continue
95
+
96
+ const boundaryBonus = isWordBoundary(candidate, index) ? 8 : 0
97
+ if (queryIndex === 0) {
98
+ current[index] = 10 + boundaryBonus - index
99
+ bestScore = Math.max(bestScore, current[index]!)
100
+ continue
101
+ }
102
+
103
+ const contiguous =
104
+ index > 0 ? previous[index - 1]! + 12 : Number.NEGATIVE_INFINITY
105
+ const gapped = bestEarlier - index + 1
106
+ current[index] = Math.max(contiguous, gapped) + 10 + boundaryBonus
107
+ bestScore = Math.max(bestScore, current[index]!)
108
+ }
109
+ if (!Number.isFinite(bestScore)) return null
110
+ const swap = previous
111
+ previous = current
112
+ current = swap
113
+ }
114
+
115
+ return bestScore - candidate.length / 1000
116
+ }
117
+
118
+ export const fuzzyChoices = (
119
+ choices: readonly InteractiveChoice[],
120
+ query: string,
121
+ ) => {
122
+ if (!query) return choices
123
+ return choices
124
+ .map((choice, index) => ({
125
+ choice,
126
+ index,
127
+ score: fuzzyScore(choice.label, query),
128
+ }))
129
+ .filter(
130
+ (
131
+ match,
132
+ ): match is typeof match & {
133
+ score: number
134
+ } => match.score !== null,
135
+ )
136
+ .sort((left, right) => right.score - left.score || left.index - right.index)
137
+ .map((match) => match.choice)
138
+ }
139
+
140
+ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
141
+ let input: InputRenderable | undefined
142
+ const [query, setQuery] = createSignal("")
143
+ const [selected, setSelected] = createSignal(0)
144
+ const choices = createMemo(() => fuzzyChoices(props.choices, query()))
145
+ const move = (offset: -1 | 1) => {
146
+ const count = choices().length
147
+ if (count === 0) return
148
+ setSelected((current) => (current + offset + count) % count)
149
+ }
150
+ useKeyboard((key) => {
151
+ if (isCancel(key)) {
152
+ key.preventDefault()
153
+ key.stopPropagation()
154
+ props.onDone(null)
155
+ return
156
+ }
157
+ if (key.name === "up" || (key.ctrl && key.name === "p")) {
158
+ key.preventDefault()
159
+ key.stopPropagation()
160
+ move(-1)
161
+ return
162
+ }
163
+ if (key.name === "down" || (key.ctrl && key.name === "n")) {
164
+ key.preventDefault()
165
+ key.stopPropagation()
166
+ move(1)
167
+ return
168
+ }
169
+ if (key.name !== "return") return
170
+ key.preventDefault()
171
+ key.stopPropagation()
172
+ const choice = choices()[selected()]
173
+ if (choice) props.onDone(choice.key)
174
+ })
175
+
176
+ const visible = () => {
177
+ const start = Math.min(
178
+ Math.max(selected() - 1, 0),
179
+ Math.max(choices().length - 2, 0),
180
+ )
181
+ return choices()
182
+ .slice(start, start + 2)
183
+ .map((choice, offset) => ({
184
+ choice,
185
+ index: start + offset,
186
+ }))
187
+ }
188
+
189
+ return (
190
+ <box flexDirection="column" width="100%" height="100%">
191
+ <box flexDirection="row" width="100%">
192
+ <text fg="#7aa2f7" flexShrink={1} wrapMode="none">
193
+ {props.prompt}
194
+ </text>
195
+ <text fg="#7aa2f7">{" > "}</text>
196
+ <input
197
+ focused
198
+ flexGrow={1}
199
+ minWidth={8}
200
+ placeholder="filter"
201
+ onInput={(next) => {
202
+ setQuery(next)
203
+ setSelected(0)
204
+ }}
205
+ ref={(next) => {
206
+ input = next
207
+ queueMicrotask(() => {
208
+ if (input && !input.isDestroyed) input.focus()
209
+ })
210
+ }}
211
+ />
212
+ </box>
213
+ <box flexDirection="column" height={2}>
214
+ <For each={visible()} fallback={<text fg="#6c7086">No matches</text>}>
215
+ {({ choice, index }) => (
216
+ <text
217
+ fg={index === selected() ? "#c0caf5" : "#6c7086"}
218
+ wrapMode="none"
219
+ >
220
+ {index === selected() ? "> " : " "}
221
+ {choice.label}
222
+ </text>
223
+ )}
224
+ </For>
225
+ </box>
226
+ <text fg="#6c7086" wrapMode="none">
227
+ enter select | esc cancel | ctrl-n/p or arrows
228
+ </text>
229
+ </box>
230
+ )
231
+ }
232
+
233
+ const shutdown = async (renderer: CliRenderer) => {
234
+ await renderer.idle().catch(() => undefined)
235
+ if (renderer.externalOutputMode === "capture-stdout") {
236
+ renderer.externalOutputMode = "passthrough"
237
+ }
238
+ if (renderer.screenMode === "split-footer")
239
+ renderer.screenMode = "main-screen"
240
+ if (!renderer.isDestroyed) renderer.destroy()
241
+ }
242
+
243
+ async function runInteractive<T>(
244
+ view: (finish: (value: T | null) => void) => JSX.Element,
245
+ ) {
246
+ let finish!: (value: T | null) => void
247
+ let settled = false
248
+ const result = new Promise<T | null>((resolve) => {
249
+ finish = (value) => {
250
+ if (settled) return
251
+ settled = true
252
+ resolve(value)
253
+ }
254
+ })
255
+ let renderer: CliRenderer | undefined
256
+ try {
257
+ renderer = await createCliRenderer({
258
+ ...interactiveRendererConfig,
259
+ onDestroy: () => finish(null),
260
+ })
261
+ await render(() => view(finish), renderer)
262
+ renderer.requestRender()
263
+ return await result
264
+ } finally {
265
+ if (renderer) {
266
+ await shutdown(renderer)
267
+ process.stdout.write("\n")
268
+ }
269
+ }
270
+ }
271
+
272
+ export const promptText = async (prompt: string) => {
273
+ const result = await runInteractive<string>((finish) => (
274
+ <InteractiveTextPrompt prompt={prompt} onDone={finish} />
275
+ ))
276
+ if (result === null) throw new Error("Interactive input cancelled")
277
+ return result
278
+ }
279
+
280
+ export const promptSelect = (
281
+ prompt: string,
282
+ choices: readonly InteractiveChoice[],
283
+ ) =>
284
+ runInteractive<string>((finish) => (
285
+ <InteractiveSelectPrompt
286
+ prompt={prompt}
287
+ choices={choices}
288
+ onDone={finish}
289
+ />
290
+ ))
@@ -35,8 +35,8 @@ reason to edit `agency.json` or `repos/` by hand.
35
35
 
36
36
  - Stop on validation errors, dependency blockers, an unexpected writable
37
37
  repository, or a conflicting active claim.
38
- - Do not begin execution without a claim. `agency work` claims before launch;
39
- external orchestrators use `agency claim` with the revision from context.
38
+ - `agency work` is the local launch flow and marks execution units `working`
39
+ without claiming them. External orchestrators claim before launching runners.
40
40
  - Do not manually create, move, or remove worktrees under `code/`.
41
41
  - Use `agency archive`, rather than moving work item folders manually.
42
42
  - Do not edit bare repositories or repository symlinks under `repos/`.
@@ -56,9 +56,9 @@ is open; if merge was requested, merge remains delivery work.
56
56
  At each closeout trigger (creating or updating a PR, marking it ready, completing
57
57
  a refinement loop, or pausing or handing off completed implementation work):
58
58
 
59
- - Use `agency task status` or `agency phase status` to set the execution unit's
60
- current status. Finish an active claim with the current revision via
61
- `agency finish`.
59
+ - Finish an active claim with the current revision via `agency finish`.
60
+ Otherwise use `agency task status` or `agency phase status` to set the
61
+ execution unit's current status.
62
62
  - Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
63
63
  PR state, current head, diff summary, and verification results after later
64
64
  pushes when those details are maintained there.
@@ -81,3 +81,20 @@ export const parseFrontmatter = (content: string, path: string) =>
81
81
 
82
82
  export const formatMarkdownDocument = (data: object, body: string) =>
83
83
  `---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n${body.trim()}\n`
84
+
85
+ export const formatWorkDocumentBody = (
86
+ title: string,
87
+ kind: "epic" | "task" | "phase",
88
+ ) => `# ${title}
89
+
90
+ ## Outcome
91
+
92
+ Describe the ${kind} outcome.
93
+
94
+ ## Plan
95
+
96
+ Describe the current approach.
97
+
98
+ ## Important Decisions
99
+
100
+ Record consequential decisions and their rationale.`
@@ -18,15 +18,27 @@ const variables = {
18
18
  }
19
19
 
20
20
  describe("runner commands", () => {
21
- test("uses deterministic fresh and resume commands for built-in presets", () => {
21
+ test("uses promptless interactive commands for built-in presets", () => {
22
22
  expect(
23
23
  resolveRunnerCommand("opencode", undefined, variables, false).argv,
24
- ).toEqual(["opencode", "--prompt", "Read the task."])
24
+ ).toEqual(["opencode"])
25
25
  expect(
26
26
  resolveRunnerCommand("opencode", undefined, variables, true).argv,
27
- ).toEqual(["opencode", "--continue", "--prompt", "Read the task."])
27
+ ).toEqual(["opencode", "--continue"])
28
28
  expect(
29
29
  resolveRunnerCommand("claude", undefined, variables, true).argv,
30
+ ).toEqual(["claude", "--continue"])
31
+ })
32
+
33
+ test("uses autonomous commands when a prompt is requested", () => {
34
+ expect(
35
+ resolveRunnerCommand("opencode", undefined, variables, false, true).argv,
36
+ ).toEqual(["opencode", "--prompt", "Read the task."])
37
+ expect(
38
+ resolveRunnerCommand("opencode", undefined, variables, true, true).argv,
39
+ ).toEqual(["opencode", "--continue", "--prompt", "Read the task."])
40
+ expect(
41
+ resolveRunnerCommand("claude", undefined, variables, true, true).argv,
30
42
  ).toEqual(["claude", "--continue", "Read the task."])
31
43
  })
32
44
 
@@ -35,12 +47,14 @@ describe("runner commands", () => {
35
47
  "custom",
36
48
  {
37
49
  custom: {
38
- command: ["agent", "--target={target}", "{prompt}"],
50
+ command: ["agent"],
51
+ autoCommand: ["agent", "--target={target}", "{prompt}"],
39
52
  environment: { CUSTOM_SESSION: "{sessionId}" },
40
53
  },
41
54
  },
42
55
  variables,
43
56
  false,
57
+ true,
44
58
  )
45
59
 
46
60
  expect(resolved).toEqual({
@@ -53,6 +67,18 @@ describe("runner commands", () => {
53
67
  })
54
68
  })
55
69
 
70
+ test("rejects --auto for configured runners without an auto command", () => {
71
+ expect(() =>
72
+ resolveRunnerCommand(
73
+ "custom",
74
+ { custom: { command: ["agent"] } },
75
+ variables,
76
+ false,
77
+ true,
78
+ ),
79
+ ).toThrow("Runner 'custom' does not support --auto")
80
+ })
81
+
56
82
  test("rejects unknown placeholders", () => {
57
83
  expect(() =>
58
84
  validateRunners({ custom: { command: ["agent", "{unknown}"] } }),