@markjaquith/agency 2.31.0 → 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.
@@ -203,6 +203,60 @@ describe("ReadinessService", () => {
203
203
  await service((readiness) =>
204
204
  readiness.guardWorkTarget("phase:ship/implement", root),
205
205
  )
206
+ expect(
207
+ [
208
+ ...(await service((readiness) => readiness.getWorkTargetIds(root))),
209
+ ].sort(),
210
+ ).toEqual([...readyIds].sort())
211
+
212
+ await write(
213
+ root,
214
+ "tasks/ship/phases/implement/PHASE.md",
215
+ execution("working", "feat/implement"),
216
+ )
217
+ const resumableIds = await service((readiness) =>
218
+ readiness.getWorkTargetIds(root),
219
+ )
220
+ expect(resumableIds).toContain("epic:delivery")
221
+ expect(resumableIds).toContain("task:ship")
222
+ expect(resumableIds).toContain("execution-unit:phase/ship/implement")
223
+ await service((readiness) =>
224
+ readiness.guardWorkTarget("execution-unit:phase/ship/implement", root),
225
+ )
226
+
227
+ await write(
228
+ root,
229
+ "tasks/ship/phases/implement/PHASE.md",
230
+ `---
231
+ repo: agency
232
+ branch: feat/implement
233
+ base: main
234
+ pr: null
235
+ status: working
236
+ claim:
237
+ claimant: orchestrator
238
+ runner: opencode
239
+ sessionId: session-1
240
+ startedAt: 2026-07-20T00:00:00.000Z
241
+ targetRevision: ${"a".repeat(64)}
242
+ state: active
243
+ ---
244
+
245
+ # Execution
246
+ `,
247
+ )
248
+ expect(
249
+ await service((readiness) => readiness.getWorkTargetIds(root)),
250
+ ).not.toContain("execution-unit:phase/ship/implement")
251
+ await expect(
252
+ service((readiness) =>
253
+ readiness.guardWorkTarget(
254
+ "execution-unit:phase/ship/implement",
255
+ root,
256
+ true,
257
+ ),
258
+ ),
259
+ ).rejects.toThrow("active claim")
206
260
 
207
261
  const blocked = await service((readiness) =>
208
262
  Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
@@ -46,6 +46,23 @@ const executionNodeId = (taskId: string, phaseId?: string) =>
46
46
  ? `execution-unit:phase/${taskId}/${phaseId}`
47
47
  : `execution-unit:task/${taskId}`
48
48
 
49
+ const hasActiveClaim = (node: GraphNode) =>
50
+ node.kind !== "epic" &&
51
+ node.kind !== "repository" &&
52
+ "claim" in node.data &&
53
+ node.data.claim?.state === "active"
54
+
55
+ const isResumableWork = (node: GraphNode) =>
56
+ node.kind !== "repository" &&
57
+ node.status === "working" &&
58
+ !hasActiveClaim(node) &&
59
+ node.readiness.blockers.every((blocker) => blocker.kind !== "validation")
60
+
61
+ const isWorkTarget = (node: GraphNode) =>
62
+ node.kind !== "repository" &&
63
+ !hasActiveClaim(node) &&
64
+ (node.readiness.ready || isResumableWork(node))
65
+
49
66
  const itemFor = (
50
67
  node: ExecutionNode,
51
68
  graph: AgencyGraph,
@@ -126,6 +143,15 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
126
143
  )
127
144
  }),
128
145
 
146
+ getWorkTargetIds: (cwd: string = process.cwd()) =>
147
+ Effect.gen(function* () {
148
+ const graphs = yield* GraphService
149
+ const graph = yield* graphs.get({ cwd })
150
+ return new Set(
151
+ graph.nodes.filter(isWorkTarget).map((node) => node.id),
152
+ )
153
+ }),
154
+
129
155
  getNext: (cwd: string = process.cwd(), select = false) =>
130
156
  Effect.gen(function* () {
131
157
  const graphs = yield* GraphService
@@ -150,11 +176,11 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
150
176
  override = false,
151
177
  ) =>
152
178
  Effect.gen(function* () {
153
- if (override) return
154
179
  const graphs = yield* GraphService
155
180
  const graph = yield* graphs.get({ cwd })
156
181
  const node = graph.nodes.find((candidate) => candidate.id === target)
157
182
  if (!node || !node.readiness) {
183
+ if (override) return
158
184
  return yield* new ExecutionGuardError({
159
185
  message: `Work target '${target}' was not found in the work graph.`,
160
186
  action: "work",
@@ -164,7 +190,18 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
164
190
  blockers: [],
165
191
  })
166
192
  }
167
- if (!node.readiness.ready) {
193
+ if (hasActiveClaim(node)) {
194
+ return yield* new ExecutionGuardError({
195
+ message: `Cannot work on '${node.key}': it has an active claim. Use agency release or agency finish first.`,
196
+ action: "work",
197
+ target,
198
+ status: node.status!,
199
+ blockedBy: node.readiness!.blockedBy,
200
+ blockers: node.readiness!.blockers,
201
+ })
202
+ }
203
+ if (override) return
204
+ if (!node.readiness.ready && !isResumableWork(node)) {
168
205
  const item = {
169
206
  key: node.key,
170
207
  status: node.status!,
@@ -301,17 +301,23 @@ describe("task and phase services", () => {
301
301
  ),
302
302
  )
303
303
  expect(createdTask.content).toContain("status: open")
304
- for (const status of ["working", "delegated"]) {
305
- await expect(
306
- runTestEffect(
307
- TaskService.pipe(
308
- Effect.flatMap((service) =>
309
- service.setStatus("single-status", status, root),
310
- ),
304
+ const workingTask = await runTestEffect(
305
+ TaskService.pipe(
306
+ Effect.flatMap((service) =>
307
+ service.setStatus("single-status", "working", root),
308
+ ),
309
+ ),
310
+ )
311
+ expect(workingTask.data.status).toBe("working")
312
+ await expect(
313
+ runTestEffect(
314
+ TaskService.pipe(
315
+ Effect.flatMap((service) =>
316
+ service.setStatus("single-status", "delegated", root),
311
317
  ),
312
318
  ),
313
- ).rejects.toThrow("require explicit ownership")
314
- }
319
+ ),
320
+ ).rejects.toThrow("Delegation requires explicit ownership")
315
321
  await runTestEffect(
316
322
  TaskService.pipe(
317
323
  Effect.flatMap((service) =>
@@ -359,6 +365,14 @@ describe("task and phase services", () => {
359
365
  ),
360
366
  ),
361
367
  )
368
+ const workingPhase = await runTestEffect(
369
+ PhaseService.pipe(
370
+ Effect.flatMap((service) =>
371
+ service.setStatus("multi-status", "implementation", "working", root),
372
+ ),
373
+ ),
374
+ )
375
+ expect(workingPhase.data.status).toBe("working")
362
376
  const phase = await runTestEffect(
363
377
  PhaseService.pipe(
364
378
  Effect.flatMap((service) =>
@@ -252,10 +252,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
252
252
  const fs = yield* FileSystemService
253
253
  const service = yield* TaskService
254
254
  const validStatus = yield* decodeStatus(status)
255
- if (validStatus === "working" || validStatus === "delegated") {
255
+ if (validStatus === "delegated") {
256
256
  return yield* new TaskError({
257
257
  message:
258
- "Active work and delegation require explicit ownership; use 'agency claim'",
258
+ "Delegation requires explicit ownership; use 'agency claim'",
259
259
  })
260
260
  }
261
261
  const record = yield* service.show(id, startPath)
@@ -9,43 +9,80 @@ const choices = [
9
9
 
10
10
  const createIO = (
11
11
  overrides: Partial<ChooserIO> = {},
12
- ): ChooserIO & { readonly writes: string[]; readonly inputs: string[] } => {
13
- const writes: string[] = []
12
+ ): ChooserIO & {
13
+ readonly selections: Array<{
14
+ readonly prompt: string
15
+ readonly choices: readonly {
16
+ readonly key: string
17
+ readonly label: string
18
+ }[]
19
+ }>
20
+ readonly inputs: string[]
21
+ } => {
22
+ const selections: Array<{
23
+ readonly prompt: string
24
+ readonly choices: readonly {
25
+ readonly key: string
26
+ readonly label: string
27
+ }[]
28
+ }> = []
14
29
  const inputs: string[] = []
15
30
  return {
16
31
  inputIsTTY: true,
17
32
  outputIsTTY: true,
18
33
  color: false,
19
- write: (message) => writes.push(message),
20
- question: async () => "1",
34
+ select: async (prompt, choices) => {
35
+ selections.push({ prompt, choices })
36
+ return choices[0]?.key ?? null
37
+ },
21
38
  run: async (_command, input) => {
22
39
  inputs.push(input)
23
40
  return { exitCode: 0, stdout: "first-key\n" }
24
41
  },
25
42
  ...overrides,
26
- writes,
43
+ selections,
27
44
  inputs,
28
45
  }
29
46
  }
30
47
 
31
48
  describe("chooser", () => {
32
- test("offers a plain-text numbered chooser on a TTY", async () => {
33
- const io = createIO({ question: async () => "2" })
49
+ test("offers plain choices to the native interactive renderer", async () => {
50
+ let offered:
51
+ | {
52
+ readonly prompt: string
53
+ readonly choices: readonly {
54
+ readonly key: string
55
+ readonly label: string
56
+ }[]
57
+ }
58
+ | undefined
59
+ const io = createIO({
60
+ select: async (prompt, offeredChoices) => {
61
+ offered = { prompt, choices: offeredChoices }
62
+ return offeredChoices[1]!.key
63
+ },
64
+ })
34
65
 
35
66
  const result = await Effect.runPromise(
36
67
  choose("Pick one", choices, undefined, io),
37
68
  )
38
69
 
39
70
  expect(result).toBe(2)
40
- expect(io.writes.join("")).toBe("Pick one\n 1. First\n 2. Second\n")
71
+ expect(offered).toEqual({
72
+ prompt: "Pick one",
73
+ choices: [
74
+ { key: "first-key", label: "First" },
75
+ { key: "second key", label: "Second" },
76
+ ],
77
+ })
41
78
  })
42
79
 
43
- test("preserves colors when enabled", async () => {
80
+ test("preserves colors for configured external choosers", async () => {
44
81
  const io = createIO({ color: true })
45
82
 
46
- await Effect.runPromise(choose("Pick one", choices, undefined, io))
83
+ await Effect.runPromise(choose("Pick one", choices, ["chooser"], io))
47
84
 
48
- expect(io.writes.join("")).toContain("\x1b[32mFirst\x1b[0m")
85
+ expect(io.inputs[0]).toContain("\x1b[32mFirst\x1b[0m")
49
86
  })
50
87
 
51
88
  test("passes generic records to an external argv command", async () => {
@@ -70,7 +107,7 @@ describe("chooser", () => {
70
107
  })
71
108
 
72
109
  test("treats native and external cancellation as no selection", async () => {
73
- const native = createIO({ question: async () => "q" })
110
+ const native = createIO({ select: async () => null })
74
111
  const external = createIO({
75
112
  run: async () => ({ exitCode: 130, stdout: "" }),
76
113
  })
@@ -1,5 +1,4 @@
1
1
  import { Effect } from "effect"
2
- import { createInterface } from "node:readline/promises"
3
2
 
4
3
  export interface Choice<T> {
5
4
  readonly key: string
@@ -35,8 +34,10 @@ export interface ChooserIO {
35
34
  readonly inputIsTTY: boolean
36
35
  readonly outputIsTTY: boolean
37
36
  readonly color: boolean
38
- readonly write: (message: string) => void
39
- readonly question: (prompt: string) => Promise<string>
37
+ readonly select: (
38
+ prompt: string,
39
+ choices: readonly { readonly key: string; readonly label: string }[],
40
+ ) => Promise<string | null>
40
41
  readonly run: (
41
42
  command: readonly string[],
42
43
  input: string,
@@ -51,22 +52,16 @@ const stripAnsi = (value: string) =>
51
52
 
52
53
  const defaultIO = (): ChooserIO => ({
53
54
  inputIsTTY: Boolean(process.stdin.isTTY),
54
- outputIsTTY: Boolean(process.stderr.isTTY),
55
+ outputIsTTY: Boolean(process.stdout.isTTY),
55
56
  color:
56
- Boolean(process.stderr.isTTY) &&
57
+ Boolean(process.stdout.isTTY) &&
57
58
  process.env.NO_COLOR === undefined &&
58
59
  process.env.TERM !== "dumb",
59
- write: (message) => process.stderr.write(message),
60
- question: async (prompt) => {
61
- const input = createInterface({
62
- input: process.stdin,
63
- output: process.stderr,
64
- })
65
- try {
66
- return await input.question(prompt)
67
- } finally {
68
- input.close()
69
- }
60
+ select: async (prompt, choices) => {
61
+ const { promptSelect } = await (
62
+ await import("./interactive-loader")
63
+ ).loadInteractive()
64
+ return promptSelect(prompt, choices)
70
65
  },
71
66
  run: async (command, input) => {
72
67
  const child = Bun.spawn([...command], {
@@ -173,33 +168,21 @@ const nativeChoice = async <T>(
173
168
  "Interactive selection requires a terminal; provide an explicit value or use --no-input",
174
169
  )
175
170
  }
176
- io.write(`${prompt}\n`)
177
- for (const [index, choice] of choices.entries()) {
178
- io.write(` ${index + 1}. ${displayLabel(choice, io.color)}\n`)
179
- }
180
- let answer: string
171
+ let key: string | null
181
172
  try {
182
- answer = (
183
- await io.question(`Select [1-${choices.length}] (q to cancel): `)
184
- ).trim()
173
+ key = await io.select(
174
+ prompt,
175
+ choices.map((choice) => ({
176
+ key: choice.key,
177
+ label: displayLabel(choice, false),
178
+ })),
179
+ )
185
180
  } catch (cause) {
186
- if (cause instanceof Error && cause.name === "AbortError") return null
187
181
  throw new ChooserError("input-unavailable", "Failed to read selection", {
188
182
  cause,
189
183
  })
190
184
  }
191
- if (!answer || answer.toLowerCase() === "q") return null
192
- if (!/^\d+$/.test(answer)) {
193
- throw new ChooserError("invalid-selection", `Invalid selection: ${answer}`)
194
- }
195
- const selected = choices[Number.parseInt(answer, 10) - 1]
196
- if (!selected) {
197
- throw new ChooserError(
198
- "invalid-selection",
199
- `Selection must be between 1 and ${choices.length}`,
200
- )
201
- }
202
- return selected.value
185
+ return key === null ? null : selectedChoice(key, choices)
203
186
  }
204
187
 
205
188
  export const choose = <T>(
@@ -0,0 +1,6 @@
1
+ export const loadInteractive = async () => {
2
+ // Resolve this at runtime so the Node-target bundle still selects Bun's preload.
3
+ const preload = ["@opentui", "solid", "preload"].join("/")
4
+ await import(preload)
5
+ return import("./interactive")
6
+ }
@@ -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
+ })