@markjaquith/agency 2.18.0 → 2.20.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.
@@ -2,6 +2,8 @@ import { Effect } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
3
  import { PhaseService } from "../services/PhaseService"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import { formatTable } from "../utils/table"
6
+ import { getWorkViews } from "../work-view"
5
7
  import { parseRepositoryReferences } from "../workbase/repository-reference"
6
8
 
7
9
  interface PhaseOptions extends BaseCommandOptions {
@@ -15,6 +17,11 @@ interface PhaseOptions extends BaseCommandOptions {
15
17
  readonly dependsOn?: readonly string[]
16
18
  readonly firstPhase?: string
17
19
  readonly json?: boolean
20
+ readonly statuses?: readonly string[]
21
+ readonly repositories?: readonly string[]
22
+ readonly ready?: boolean
23
+ readonly blocked?: boolean
24
+ readonly pr?: boolean
18
25
  }
19
26
 
20
27
  export const phase = (options: PhaseOptions) =>
@@ -64,15 +71,53 @@ export const phase = (options: PhaseOptions) =>
64
71
  case "list": {
65
72
  if (!taskId) return yield* Effect.fail(new Error("Task ID is required"))
66
73
  const records = yield* phases.list(taskId, cwd)
74
+ const { phaseRows } = yield* getWorkViews({
75
+ cwd,
76
+ statuses: options.statuses,
77
+ repositories: options.repositories,
78
+ ready: options.ready,
79
+ blocked: options.blocked,
80
+ pr: options.pr,
81
+ })
82
+ const rows = phaseRows.filter((row) => row.parent === taskId)
83
+ const ordered = rows.flatMap((row) => {
84
+ const record = records.find((item) => item.id === row.id)
85
+ return record ? [record] : []
86
+ })
67
87
  if (options.json) {
68
88
  log(
69
89
  JSON.stringify(
70
- records.map(({ content: _, ...record }) => record),
90
+ ordered.map(({ content: _, ...record }) => record),
71
91
  null,
72
92
  2,
73
93
  ),
74
94
  )
75
- } else for (const record of records) log(record.id)
95
+ } else {
96
+ log(
97
+ formatTable(
98
+ [
99
+ "PHASE",
100
+ "PARENT",
101
+ "STATUS",
102
+ "READINESS",
103
+ "REPOSITORIES",
104
+ "BRANCH",
105
+ "PR",
106
+ "WORKTREE",
107
+ ],
108
+ rows.map((row) => [
109
+ row.id,
110
+ row.parent,
111
+ row.status,
112
+ row.readiness,
113
+ row.repositories,
114
+ row.branch,
115
+ row.pr,
116
+ row.worktree,
117
+ ]),
118
+ ),
119
+ )
120
+ }
76
121
  return
77
122
  }
78
123
  case "show": {
@@ -139,4 +184,9 @@ Create options:
139
184
 
140
185
  Options:
141
186
  --json Output results as JSON
187
+ --status <status> Filter list by status; repeatable
188
+ --repository <alias> Filter list by repository; repeatable
189
+ --ready Include only ready phases
190
+ --blocked Include only blocked phases
191
+ --pr / --no-pr Filter by recorded PR presence
142
192
  `
@@ -8,6 +8,7 @@ import {
8
8
  runTestEffect,
9
9
  } from "../test-utils"
10
10
  import { status } from "./status"
11
+ import { task } from "./task"
11
12
 
12
13
  describe("status command", () => {
13
14
  let root: string
@@ -44,4 +45,47 @@ describe("status command", () => {
44
45
  },
45
46
  ])
46
47
  })
48
+
49
+ test("renders and filters the execution dashboard", async () => {
50
+ await runTestEffect(
51
+ task({
52
+ subcommand: "create",
53
+ args: ["example"],
54
+ repo: "agency",
55
+ branch: "feat/example",
56
+ base: "main",
57
+ cwd: root,
58
+ silent: true,
59
+ }),
60
+ )
61
+
62
+ const logs = await captureLogs(() =>
63
+ runTestEffect(
64
+ status({ cwd: root, repositories: ["agency"], ready: true, pr: false }),
65
+ ),
66
+ )
67
+ expect(logs).toContain("Repositories: 1")
68
+ expect(logs.at(-1)).toContain(
69
+ "KIND WORK PARENT STATUS READINESS REPOSITORIES BRANCH",
70
+ )
71
+ expect(logs.at(-1)).toContain(
72
+ "task example - open ready agency feat/example absent absent",
73
+ )
74
+ })
75
+
76
+ test("reports validation issues without requiring a decodable graph", async () => {
77
+ await mkdir(join(root, "tasks/broken"), { recursive: true })
78
+ await Bun.write(
79
+ join(root, "tasks/broken/TASK.md"),
80
+ "---\nrepo: agency\nstatus: invalid\n---\n",
81
+ )
82
+
83
+ const logs = await captureLogs(() =>
84
+ runTestEffect(status({ cwd: root, json: true })),
85
+ )
86
+ const output = JSON.parse(logs[0]!)
87
+ expect(output.valid).toBe(false)
88
+ expect(output.issues.length).toBeGreaterThan(0)
89
+ expect(output.work).toEqual([])
90
+ })
47
91
  })
@@ -3,9 +3,16 @@ import type { BaseCommandOptions } from "../utils/command"
3
3
  import { WorkbaseService } from "../services/WorkbaseService"
4
4
  import { RepositoryService } from "../services/RepositoryService"
5
5
  import { createLoggers } from "../utils/effect"
6
+ import { formatTable } from "../utils/table"
7
+ import { getWorkViews } from "../work-view"
6
8
 
7
9
  interface StatusOptions extends BaseCommandOptions {
8
10
  readonly json?: boolean
11
+ readonly statuses?: readonly string[]
12
+ readonly repositories?: readonly string[]
13
+ readonly ready?: boolean
14
+ readonly blocked?: boolean
15
+ readonly pr?: boolean
9
16
  }
10
17
 
11
18
  export const status = (options: StatusOptions = {}) =>
@@ -16,7 +23,17 @@ export const status = (options: StatusOptions = {}) =>
16
23
  const cwd = options.cwd ?? process.cwd()
17
24
  const report = yield* workbase.validate(cwd)
18
25
  const repos = yield* repositories.list(report.root)
19
- const data = { ...report, repositories: repos }
26
+ const executionRows = report.valid
27
+ ? (yield* getWorkViews({
28
+ cwd,
29
+ statuses: options.statuses,
30
+ repositories: options.repositories,
31
+ ready: options.ready,
32
+ blocked: options.blocked,
33
+ pr: options.pr,
34
+ })).executionRows
35
+ : []
36
+ const data = { ...report, repositories: repos, work: executionRows }
20
37
 
21
38
  if (options.json) {
22
39
  log(JSON.stringify(data, null, 2))
@@ -31,6 +48,33 @@ export const status = (options: StatusOptions = {}) =>
31
48
  log(
32
49
  `Validation: ${report.valid ? "valid" : `${report.issues.length} issues`}`,
33
50
  )
51
+ log("")
52
+ log(
53
+ formatTable(
54
+ [
55
+ "KIND",
56
+ "WORK",
57
+ "PARENT",
58
+ "STATUS",
59
+ "READINESS",
60
+ "REPOSITORIES",
61
+ "BRANCH",
62
+ "PR",
63
+ "WORKTREE",
64
+ ],
65
+ executionRows.map((row) => [
66
+ row.kind,
67
+ row.kind === "phase" ? row.key : row.id,
68
+ row.parent,
69
+ row.status,
70
+ row.readiness,
71
+ row.repositories,
72
+ row.branch,
73
+ row.pr,
74
+ row.worktree,
75
+ ]),
76
+ ),
77
+ )
34
78
  })
35
79
 
36
80
  export const help = `
@@ -39,5 +83,10 @@ Usage: agency status [options]
39
83
  Show workbase repository, entity, and validation status.
40
84
 
41
85
  Options:
42
- --json Output status as JSON
86
+ --json Output status as JSON
87
+ --status <status> Filter by status; repeatable
88
+ --repository <alias> Filter by repository; repeatable
89
+ --ready Include only ready work
90
+ --blocked Include only blocked work
91
+ --pr / --no-pr Filter by recorded PR presence
43
92
  `
@@ -124,6 +124,26 @@ describe("task and phase command JSON output", () => {
124
124
  })
125
125
  })
126
126
 
127
+ test("renders task and phase operational tables", async () => {
128
+ const taskLogs = await captureLogs(() =>
129
+ runTestEffect(task({ subcommand: "list", args: [], cwd: root })),
130
+ )
131
+ expect(taskLogs[0]).toContain(
132
+ "TASK PARENT STATUS READINESS REPOSITORIES BRANCH",
133
+ )
134
+ expect(taskLogs[0]).toContain("multi - open ready")
135
+
136
+ const phaseLogs = await captureLogs(() =>
137
+ runTestEffect(phase({ subcommand: "list", args: ["multi"], cwd: root })),
138
+ )
139
+ expect(phaseLogs[0]).toContain(
140
+ "PHASE PARENT STATUS READINESS REPOSITORIES BRANCH",
141
+ )
142
+ expect(phaseLogs[0]).toContain(
143
+ "first multi open ready agency task/first absent absent",
144
+ )
145
+ })
146
+
127
147
  test("outputs created task and phase records as JSON", async () => {
128
148
  const taskLogs = await captureLogs(() =>
129
149
  runTestEffect(
@@ -8,6 +8,8 @@ import { createLoggers } from "../utils/effect"
8
8
  import { parseRepositoryReferences } from "../workbase/repository-reference"
9
9
  import { WorkbaseService } from "../services/WorkbaseService"
10
10
  import { choose } from "../utils/chooser"
11
+ import { formatTable } from "../utils/table"
12
+ import { getWorkViews } from "../work-view"
11
13
 
12
14
  interface TaskOptions extends BaseCommandOptions {
13
15
  readonly subcommand?: string
@@ -21,6 +23,11 @@ interface TaskOptions extends BaseCommandOptions {
21
23
  readonly base?: string
22
24
  readonly multiPhase?: boolean
23
25
  readonly json?: boolean
26
+ readonly statuses?: readonly string[]
27
+ readonly repositories?: readonly string[]
28
+ readonly ready?: boolean
29
+ readonly blocked?: boolean
30
+ readonly pr?: boolean
24
31
  }
25
32
 
26
33
  export interface TaskInteraction {
@@ -208,16 +215,51 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
208
215
  }
209
216
  case "list": {
210
217
  const records = yield* tasks.list(cwd)
218
+ const { taskRows } = yield* getWorkViews({
219
+ cwd,
220
+ statuses: options.statuses,
221
+ repositories: options.repositories,
222
+ ready: options.ready,
223
+ blocked: options.blocked,
224
+ pr: options.pr,
225
+ })
226
+ const ordered = taskRows.flatMap((row) => {
227
+ const record = records.find((item) => item.id === row.key)
228
+ return record ? [record] : []
229
+ })
211
230
  if (options.json) {
212
231
  log(
213
232
  JSON.stringify(
214
- records.map(({ content: _, ...record }) => record),
233
+ ordered.map(({ content: _, ...record }) => record),
215
234
  null,
216
235
  2,
217
236
  ),
218
237
  )
219
238
  } else {
220
- for (const record of records) log(record.id)
239
+ log(
240
+ formatTable(
241
+ [
242
+ "TASK",
243
+ "PARENT",
244
+ "STATUS",
245
+ "READINESS",
246
+ "REPOSITORIES",
247
+ "BRANCH",
248
+ "PR",
249
+ "WORKTREE",
250
+ ],
251
+ taskRows.map((row) => [
252
+ row.id,
253
+ row.parent,
254
+ row.status,
255
+ row.readiness,
256
+ row.repositories,
257
+ row.branch,
258
+ row.pr,
259
+ row.worktree,
260
+ ]),
261
+ ),
262
+ )
221
263
  }
222
264
  return
223
265
  }
@@ -286,4 +328,9 @@ through task new, which fails when --no-input is set or no TTY is available.
286
328
  Options:
287
329
  --json Output results as JSON
288
330
  --no-input Never open interactive task creation
331
+ --status <status> Filter list by status; repeatable
332
+ --repository <alias> Filter list by repository; repeatable
333
+ --ready Include only ready tasks
334
+ --blocked Include only blocked tasks
335
+ --pr / --no-pr Filter by recorded PR presence
289
336
  `
@@ -47,8 +47,16 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
47
47
  interface HarnessOptions {
48
48
  readonly workspace?: ExecutionWorkspace
49
49
  readonly materializeError?: Error
50
- readonly available?: Partial<Record<"opencode" | "claude", boolean>>
50
+ readonly available?: Readonly<Record<string, boolean>>
51
51
  readonly chooserCommand?: readonly string[]
52
+ readonly runners?: Record<
53
+ string,
54
+ {
55
+ command: readonly [string, ...string[]]
56
+ resumeCommand?: readonly [string, ...string[]]
57
+ environment?: Record<string, string>
58
+ }
59
+ >
52
60
  readonly multiPhaseTasks?: readonly string[]
53
61
  readonly epicRecords?: readonly any[]
54
62
  readonly taskRecords?: readonly any[]
@@ -72,6 +80,7 @@ const createHarness = (options: HarnessOptions = {}) => {
72
80
  args: readonly string[]
73
81
  cwd: string
74
82
  }> = []
83
+ const launchEnvironments: Array<Readonly<Record<string, string>>> = []
75
84
  const materializeOptions: Array<
76
85
  Parameters<WorktreeService["materialize"]>[3]
77
86
  > = []
@@ -105,6 +114,7 @@ const createHarness = (options: HarnessOptions = {}) => {
105
114
  config: {
106
115
  version: 2 as const,
107
116
  chooserCommand: options.chooserCommand,
117
+ runners: options.runners,
108
118
  },
109
119
  }),
110
120
  }
@@ -204,7 +214,7 @@ const createHarness = (options: HarnessOptions = {}) => {
204
214
  isDirectory: (path: string) =>
205
215
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
206
216
  runCommand: (args: readonly string[]) => {
207
- const cli = args[1] as "opencode" | "claude"
217
+ const cli = args[1]!
208
218
  events.push(`probe:${cli}`)
209
219
  probes.push(cli)
210
220
  return Effect.succeed({
@@ -214,9 +224,15 @@ const createHarness = (options: HarnessOptions = {}) => {
214
224
  })
215
225
  },
216
226
  }
217
- const launch = (cli: string, args: readonly string[], cwd: string) => {
227
+ const launch = (
228
+ cli: string,
229
+ args: readonly string[],
230
+ cwd: string,
231
+ environment: Readonly<Record<string, string>>,
232
+ ) => {
218
233
  events.push(`launch:${cli}`)
219
234
  launches.push({ cli, args, cwd })
235
+ launchEnvironments.push(environment)
220
236
  }
221
237
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
222
238
  const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
@@ -257,6 +273,7 @@ const createHarness = (options: HarnessOptions = {}) => {
257
273
  events,
258
274
  probes,
259
275
  launches,
276
+ launchEnvironments,
260
277
  materializeOptions,
261
278
  statusUpdates,
262
279
  shownTasks,
@@ -353,7 +370,6 @@ describe("work command", () => {
353
370
  cli: "opencode",
354
371
  args: [
355
372
  "opencode",
356
- "--continue",
357
373
  "--prompt",
358
374
  "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
359
375
  ],
@@ -403,7 +419,6 @@ describe("work command", () => {
403
419
  cli: "opencode",
404
420
  args: [
405
421
  "opencode",
406
- "--continue",
407
422
  "--prompt",
408
423
  "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
409
424
  ],
@@ -429,7 +444,6 @@ describe("work command", () => {
429
444
  cli: "opencode",
430
445
  args: [
431
446
  "opencode",
432
- "--continue",
433
447
  "--prompt",
434
448
  "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
435
449
  ],
@@ -596,7 +610,7 @@ describe("work command", () => {
596
610
 
597
611
  await expect(
598
612
  harness.run({ taskId: "example", opencode: true, claude: true }),
599
- ).rejects.toThrow("Cannot use both --opencode and --claude")
613
+ ).rejects.toThrow("Cannot combine --runner, --opencode, and --claude")
600
614
  expect(harness.events).toEqual([])
601
615
  })
602
616
 
@@ -624,7 +638,6 @@ describe("work command", () => {
624
638
  cli: "opencode",
625
639
  args: [
626
640
  "opencode",
627
- "--continue",
628
641
  "--prompt",
629
642
  "Start the task. Read /workbase/tasks/example/TASK.md.",
630
643
  ],
@@ -638,14 +651,18 @@ describe("work command", () => {
638
651
  ])
639
652
  })
640
653
 
641
- test("continues OpenCode with explicit task and phase context", async () => {
654
+ test("resumes OpenCode deterministically when a session identity exists", async () => {
642
655
  const harness = createHarness({ workspace: multiPhaseWorkspace })
643
-
644
- await harness.run({
645
- taskId: "example",
646
- phaseId: "implementation",
647
- opencode: true,
648
- })
656
+ process.env.AGENCY_SESSION_ID = "existing-session"
657
+ try {
658
+ await harness.run({
659
+ taskId: "example",
660
+ phaseId: "implementation",
661
+ opencode: true,
662
+ })
663
+ } finally {
664
+ delete process.env.AGENCY_SESSION_ID
665
+ }
649
666
 
650
667
  expect(harness.launches[0]).toEqual({
651
668
  cli: "opencode",
@@ -659,6 +676,78 @@ describe("work command", () => {
659
676
  })
660
677
  })
661
678
 
679
+ test("expands a named runner with shared context and claim identity", async () => {
680
+ const harness = createHarness({
681
+ available: { codex: true },
682
+ runners: {
683
+ custom: {
684
+ command: ["codex", "--task", "{task}", "{prompt}"],
685
+ environment: {
686
+ CUSTOM_TARGET: "{target}",
687
+ AGENCY_TARGET: "cannot-override",
688
+ },
689
+ },
690
+ },
691
+ })
692
+
693
+ await harness.run({ taskId: "example", runner: "custom" })
694
+
695
+ expect(harness.probes).toEqual(["codex"])
696
+ expect(harness.launches[0]).toEqual({
697
+ cli: "codex",
698
+ args: [
699
+ "codex",
700
+ "--task",
701
+ "example",
702
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
703
+ ],
704
+ cwd: singlePhaseWorkspace.writablePath,
705
+ })
706
+ expect(harness.launchEnvironments[0]).toMatchObject({
707
+ AGENCY_RUNNER: "custom",
708
+ AGENCY_CLAIMANT: process.env.USER ?? "agency",
709
+ AGENCY_WORKBASE: "/workbase",
710
+ AGENCY_TARGET: "execution-unit:task/example",
711
+ AGENCY_TASK_ID: "example",
712
+ AGENCY_PHASE_ID: "",
713
+ AGENCY_CLAIM_REVISION: "1".repeat(64),
714
+ CUSTOM_TARGET: "execution-unit:task/example",
715
+ })
716
+ })
717
+
718
+ test("prints the exact command contract without launching and omits secrets", async () => {
719
+ const harness = createHarness({
720
+ available: { agent: true },
721
+ runners: {
722
+ custom: {
723
+ command: ["agent", "{prompt}"],
724
+ environment: {
725
+ VISIBLE: "{task}",
726
+ API_TOKEN: "do-not-print",
727
+ },
728
+ },
729
+ },
730
+ })
731
+
732
+ const output = await captureLogs(() =>
733
+ harness.run({
734
+ taskId: "example",
735
+ runner: "custom",
736
+ printCommand: true,
737
+ }),
738
+ )
739
+ const printed = JSON.parse(output.join("\n"))
740
+
741
+ expect(harness.launches).toEqual([])
742
+ expect(printed.cwd).toBe(singlePhaseWorkspace.writablePath)
743
+ expect(printed.argv).toEqual([
744
+ "agent",
745
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
746
+ ])
747
+ expect(printed.environment.VISIBLE).toBe("example")
748
+ expect(printed.environment.API_TOKEN).toBeUndefined()
749
+ })
750
+
662
751
  test("automatically falls back to Claude", async () => {
663
752
  const harness = createHarness({ available: { opencode: false } })
664
753
 
@@ -729,7 +818,7 @@ describe("work command", () => {
729
818
  verboseHarness.run({ taskId: "example", verbose: true }),
730
819
  )
731
820
  expect(verboseLogs).toEqual([
732
- "Launching command: opencode --continue --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
821
+ "Launching command: opencode --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
733
822
  ])
734
823
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
735
824