@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
@@ -10,6 +10,7 @@ import {
10
10
  } from "../workbase/schemas"
11
11
  import {
12
12
  formatMarkdownDocument,
13
+ formatWorkDocumentBody,
13
14
  parseFrontmatter,
14
15
  } from "../workbase/frontmatter"
15
16
  import { documentRevision } from "../workbase/document-revision"
@@ -95,7 +96,7 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
95
96
  .join(" ")
96
97
  const content = formatMarkdownDocument(
97
98
  data,
98
- `# ${title}\n\nDescribe the epic outcome.`,
99
+ formatWorkDocumentBody(title, "epic"),
99
100
  )
100
101
  yield* fs.writeFile(path, content)
101
102
  return {
@@ -76,7 +76,8 @@ describe("IntegrationService", () => {
76
76
 
77
77
  expect(body).toContain("agency context . --json")
78
78
  expect(body).toContain("authority.writable.checkoutPath")
79
- expect(body).toContain("Do not begin execution without a claim")
79
+ expect(body).toContain("`agency work` is the local launch flow")
80
+ expect(body).toContain("External orchestrators claim before launching")
80
81
  expect(body).toContain("Run `agency validate`")
81
82
  expect(body).toContain("only with explicit user intent")
82
83
  expect(body).toContain("An execution unit is `working`")
@@ -14,6 +14,7 @@ import {
14
14
  } from "../workbase/schemas"
15
15
  import {
16
16
  formatMarkdownDocument,
17
+ formatWorkDocumentBody,
17
18
  parseFrontmatter,
18
19
  } from "../workbase/frontmatter"
19
20
  import { canTransitionStatus } from "../readiness"
@@ -196,7 +197,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
196
197
  .join(" ")
197
198
  const content = formatMarkdownDocument(
198
199
  data,
199
- `# ${title}\n\nDescribe the phase outcome.`,
200
+ formatWorkDocumentBody(title, "phase"),
200
201
  )
201
202
 
202
203
  if (!isMultiPhase) {
@@ -246,7 +247,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
246
247
  const firstPhasePath = join(firstDirectory, "PHASE.md")
247
248
  const firstContent = formatMarkdownDocument(
248
249
  firstData,
249
- `# ${firstTitle}\n\nDescribe the phase outcome.`,
250
+ formatWorkDocumentBody(firstTitle, "phase"),
250
251
  )
251
252
  const steps: TransactionStep[] = []
252
253
  if (yield* fs.isDirectory(oldCodePath)) {
@@ -473,10 +474,10 @@ export class PhaseService extends Effect.Service<PhaseService>()(
473
474
  const fs = yield* FileSystemService
474
475
  const service = yield* PhaseService
475
476
  const validStatus = yield* decodeStatus(status)
476
- if (validStatus === "working" || validStatus === "delegated") {
477
+ if (validStatus === "delegated") {
477
478
  return yield* new PhaseError({
478
479
  message:
479
- "Active work and delegation require explicit ownership; use 'agency claim'",
480
+ "Delegation requires explicit ownership; use 'agency claim'",
480
481
  })
481
482
  }
482
483
  const record = yield* service.show(taskId, id, startPath)
@@ -220,7 +220,10 @@ process.exit(${exitCode})
220
220
  const original = await Bun.file(taskPath).text()
221
221
  await Bun.write(
222
222
  taskPath,
223
- original.replace("# Example\n\nDescribe the task outcome.", body),
223
+ original.replace(
224
+ "# Example\n\n## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
225
+ body,
226
+ ),
224
227
  )
225
228
  const url = "https://github.com/example/agency/pull/42"
226
229
  await writeFakeGh({ stdout: `${url}\n` })
@@ -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!,
@@ -33,7 +33,7 @@ describe("task and phase services", () => {
33
33
  ),
34
34
  ),
35
35
  )
36
- await runTestEffect(
36
+ const createdTask = await runTestEffect(
37
37
  TaskService.pipe(
38
38
  Effect.flatMap((service) =>
39
39
  service.create(
@@ -51,6 +51,9 @@ describe("task and phase services", () => {
51
51
  ),
52
52
  ),
53
53
  )
54
+ expect(createdTask.content).toContain(
55
+ "# Task One\n\n## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
56
+ )
54
57
 
55
58
  const epic = await runTestEffect(
56
59
  EpicService.pipe(
@@ -159,6 +162,14 @@ describe("task and phase services", () => {
159
162
  ),
160
163
  )
161
164
  expect(phases.map((phase) => phase.id)).toEqual(["first", "second"])
165
+ const firstPhase = await runTestEffect(
166
+ PhaseService.pipe(
167
+ Effect.flatMap((service) => service.show("multi", "first", root)),
168
+ ),
169
+ )
170
+ expect(firstPhase.content).toContain(
171
+ "# First\n\n## Outcome\n\nDescribe the phase outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
172
+ )
162
173
  })
163
174
 
164
175
  test("converts a single-phase task when the existing phase ID is provided", async () => {
@@ -243,7 +254,9 @@ describe("task and phase services", () => {
243
254
  { id: "extra", dependsOn: ["implementation"] },
244
255
  ],
245
256
  })
246
- expect(task.content).toContain("Describe the task outcome.")
257
+ expect(task.content).toContain(
258
+ "## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions",
259
+ )
247
260
 
248
261
  const firstPhase = await runTestEffect(
249
262
  PhaseService.pipe(
@@ -288,17 +301,23 @@ describe("task and phase services", () => {
288
301
  ),
289
302
  )
290
303
  expect(createdTask.content).toContain("status: open")
291
- for (const status of ["working", "delegated"]) {
292
- await expect(
293
- runTestEffect(
294
- TaskService.pipe(
295
- Effect.flatMap((service) =>
296
- service.setStatus("single-status", status, root),
297
- ),
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),
298
317
  ),
299
318
  ),
300
- ).rejects.toThrow("require explicit ownership")
301
- }
319
+ ),
320
+ ).rejects.toThrow("Delegation requires explicit ownership")
302
321
  await runTestEffect(
303
322
  TaskService.pipe(
304
323
  Effect.flatMap((service) =>
@@ -346,6 +365,14 @@ describe("task and phase services", () => {
346
365
  ),
347
366
  ),
348
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")
349
376
  const phase = await runTestEffect(
350
377
  PhaseService.pipe(
351
378
  Effect.flatMap((service) =>
@@ -13,6 +13,7 @@ import {
13
13
  } from "../workbase/schemas"
14
14
  import {
15
15
  formatMarkdownDocument,
16
+ formatWorkDocumentBody,
16
17
  parseFrontmatter,
17
18
  } from "../workbase/frontmatter"
18
19
  import { canTransitionStatus } from "../readiness"
@@ -163,7 +164,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
163
164
  .join(" ")
164
165
  const content = formatMarkdownDocument(
165
166
  data,
166
- `# ${title}\n\nDescribe the task outcome.`,
167
+ formatWorkDocumentBody(title, "task"),
167
168
  )
168
169
  const writes: {
169
170
  path: string
@@ -251,10 +252,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
251
252
  const fs = yield* FileSystemService
252
253
  const service = yield* TaskService
253
254
  const validStatus = yield* decodeStatus(status)
254
- if (validStatus === "working" || validStatus === "delegated") {
255
+ if (validStatus === "delegated") {
255
256
  return yield* new TaskError({
256
257
  message:
257
- "Active work and delegation require explicit ownership; use 'agency claim'",
258
+ "Delegation requires explicit ownership; use 'agency claim'",
258
259
  })
259
260
  }
260
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
+ }