@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.
- package/README.md +56 -38
- package/cli.ts +2 -1
- package/package.json +5 -2
- package/skills/agency/SKILL.md +7 -6
- package/skills/agency/references/commands.md +17 -14
- package/skills/agency/references/contracts.md +4 -5
- package/skills/agency/references/recipes.md +13 -10
- package/src/cli-parser.test.ts +12 -2
- package/src/cli-parser.ts +5 -2
- package/src/cli.test.ts +34 -10
- package/src/commands/doctor.test.ts +11 -1
- package/src/commands/phase.ts +1 -1
- package/src/commands/task.test.ts +34 -1
- package/src/commands/task.ts +16 -20
- package/src/commands/work.test.ts +65 -65
- package/src/commands/work.ts +43 -35
- package/src/opentui.d.ts +1 -0
- package/src/services/DoctorService.ts +22 -0
- package/src/services/EpicService.test.ts +3 -0
- package/src/services/EpicService.ts +2 -1
- package/src/services/IntegrationService.test.ts +2 -1
- package/src/services/PhaseService.ts +5 -4
- package/src/services/PullRequestService.test.ts +4 -1
- package/src/services/ReadinessService.test.ts +54 -0
- package/src/services/ReadinessService.ts +39 -2
- package/src/services/TaskPhaseService.test.ts +38 -11
- package/src/services/TaskService.ts +4 -3
- package/src/utils/chooser.test.ts +49 -12
- package/src/utils/chooser.ts +20 -37
- package/src/utils/interactive-loader.ts +6 -0
- package/src/utils/interactive.test.tsx +258 -0
- package/src/utils/interactive.tsx +290 -0
- package/src/workbase/AGENTS.md +5 -5
- package/src/workbase/frontmatter.ts +17 -0
- package/src/workbase/runner-command.test.ts +30 -4
- package/src/workbase/runner-command.ts +21 -6
- package/src/workbase/schemas.test.ts +4 -2
- 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
|
+
))
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
39
|
-
|
|
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
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
21
|
+
test("uses promptless interactive commands for built-in presets", () => {
|
|
22
22
|
expect(
|
|
23
23
|
resolveRunnerCommand("opencode", undefined, variables, false).argv,
|
|
24
|
-
).toEqual(["opencode"
|
|
24
|
+
).toEqual(["opencode"])
|
|
25
25
|
expect(
|
|
26
26
|
resolveRunnerCommand("opencode", undefined, variables, true).argv,
|
|
27
|
-
).toEqual(["opencode", "--continue"
|
|
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"
|
|
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}"] } }),
|