@markjaquith/agency 2.18.0 → 2.19.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 CHANGED
@@ -371,7 +371,7 @@ status, validation, graph export, reconciliation, and PR creation.
371
371
  ```text
372
372
  agency epic create <id> --ticket-url <url> [--description <text>] [--json]
373
373
  --repo <alias>:<ref> [--repo <alias>:<ref>...]
374
- agency epic list [--json]
374
+ agency epic list [filters] [--json]
375
375
  agency epic show <id> [--json]
376
376
  ```
377
377
 
@@ -425,7 +425,7 @@ fully independent of process cwd and prompts.
425
425
  Inspect tasks:
426
426
 
427
427
  ```text
428
- agency task list [--json]
428
+ agency task list [filters] [--json]
429
429
  agency task show <id> [--json]
430
430
  agency task status <id> <open|done|dropped> [--json]
431
431
  ```
@@ -452,7 +452,7 @@ agency phase create <task-id> <phase-id>
452
452
  [--description <text>] [--reference <alias>:<ref>...]
453
453
  [--depends-on <phase-id>...] [--first-phase <phase-id>] [--json]
454
454
 
455
- agency phase list <task-id> [--json]
455
+ agency phase list <task-id> [filters] [--json]
456
456
  agency phase show <task-id> <phase-id> [--json]
457
457
  agency phase status <task-id> <phase-id> <open|done|dropped> [--json]
458
458
  ```
@@ -470,6 +470,13 @@ work before changing its outcome.
470
470
  Delegation is now explicit: the claimant identifies the orchestrator and the
471
471
  runner identifies the assigned agent.
472
472
 
473
+ Human list output is a compact table with lifecycle, readiness, parent,
474
+ repository, branch, recorded PR, and worktree state where applicable. List and
475
+ status views accept composable `--status <status>` and `--repository <alias>`
476
+ filters, plus `--ready`, `--blocked`, `--pr`, and `--no-pr`. Status and repository
477
+ filters are repeatable. Rows follow task and phase declaration order; plain text
478
+ labels remain complete without color or icon fonts.
479
+
473
480
  ### Claims
474
481
 
475
482
  Claim mutations require the SHA-256 revision exposed by `agency context` or
@@ -555,7 +562,7 @@ the owning `TASK.md` or `PHASE.md`.
555
562
  ### Status and Validation
556
563
 
557
564
  ```text
558
- agency status [--json]
565
+ agency status [filters] [--json]
559
566
  agency validate [path] [--json]
560
567
  ```
561
568
 
package/cli.ts CHANGED
@@ -237,6 +237,11 @@ const commands: Record<string, Command> = {
237
237
  description: options.description,
238
238
  repos: options.repo,
239
239
  json: options.json,
240
+ statuses: options.status,
241
+ repositories: options.repository,
242
+ ready: options.ready,
243
+ blocked: options.blocked,
244
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
240
245
  silent: options.silent,
241
246
  verbose: options.verbose,
242
247
  cwd: options.cwd,
@@ -280,6 +285,11 @@ const commands: Record<string, Command> = {
280
285
  dependsOn: options["depends-on"],
281
286
  firstPhase: options["first-phase"],
282
287
  json: options.json,
288
+ statuses: options.status,
289
+ repositories: options.repository,
290
+ ready: options.ready,
291
+ blocked: options.blocked,
292
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
283
293
  silent: options.silent,
284
294
  verbose: options.verbose,
285
295
  cwd: options.cwd,
@@ -379,6 +389,11 @@ const commands: Record<string, Command> = {
379
389
  base: options.base,
380
390
  multiPhase: options["multi-phase"],
381
391
  json: options.json,
392
+ statuses: options.status,
393
+ repositories: options.repository,
394
+ ready: options.ready,
395
+ blocked: options.blocked,
396
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
382
397
  silent: options.silent,
383
398
  verbose: options.verbose,
384
399
  inputAllowed: options.inputAllowed,
@@ -437,6 +452,11 @@ const commands: Record<string, Command> = {
437
452
  silent: options.silent,
438
453
  verbose: options.verbose,
439
454
  json: options.json,
455
+ statuses: options.status,
456
+ repositories: options.repository,
457
+ ready: options.ready,
458
+ blocked: options.blocked,
459
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
440
460
  cwd: options.cwd,
441
461
  }),
442
462
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -10,9 +10,9 @@ describe("strict CLI parsing", () => {
10
10
  expectUsageError(["task", "list", "--josn"], "agency task")
11
11
  expectUsageError(
12
12
  ["task", "list", "--repo", "agency"],
13
- "agency task list [--json]",
13
+ "agency task list [filters] [--json]",
14
14
  )
15
- expectUsageError(["status", "--draft"], "agency status [--json]")
15
+ expectUsageError(["status", "--draft"], "agency status [filters] [--json]")
16
16
  })
17
17
 
18
18
  test("rejects duplicate scalar, boolean, and single-value multiple options", () => {
@@ -77,6 +77,41 @@ describe("strict CLI parsing", () => {
77
77
  })
78
78
  })
79
79
 
80
+ test("parses composable view filters", () => {
81
+ expect(
82
+ parseCli([
83
+ "task",
84
+ "list",
85
+ "--status",
86
+ "open",
87
+ "--status",
88
+ "working",
89
+ "--repository",
90
+ "agency",
91
+ "--ready",
92
+ "--pr",
93
+ ]).values,
94
+ ).toMatchObject({
95
+ status: ["open", "working"],
96
+ repository: ["agency"],
97
+ ready: true,
98
+ pr: true,
99
+ })
100
+ expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
101
+ })
102
+
103
+ test("validates view filter values and conflicts", () => {
104
+ expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
105
+ "Invalid '--status' value",
106
+ )
107
+ expect(() =>
108
+ parseCli(["phase", "list", "task", "--ready", "--blocked"]),
109
+ ).toThrow("cannot be combined")
110
+ expect(() => parseCli(["status", "--pr", "--no-pr"])).toThrow(
111
+ "cannot be combined",
112
+ )
113
+ })
114
+
80
115
  test("enforces exact maximum positional arity for every leaf command", () => {
81
116
  for (const [args, usage] of [
82
117
  [["init", "one", "two"], "agency init"],
package/src/cli-parser.ts CHANGED
@@ -40,6 +40,29 @@ const outputOptions = {
40
40
  json: { type: "boolean" },
41
41
  } satisfies OptionConfig
42
42
 
43
+ const viewOptions = {
44
+ status: { type: "string", multiple: true },
45
+ repository: { type: "string", multiple: true },
46
+ ready: { type: "boolean" },
47
+ blocked: { type: "boolean" },
48
+ pr: { type: "boolean" },
49
+ "no-pr": { type: "boolean" },
50
+ } satisfies OptionConfig
51
+
52
+ const viewOptionNames = [
53
+ "status",
54
+ "repository",
55
+ "ready",
56
+ "blocked",
57
+ "pr",
58
+ "no-pr",
59
+ ] as const
60
+
61
+ const viewConflicts = [
62
+ ["ready", "blocked"],
63
+ ["pr", "no-pr"],
64
+ ] as const
65
+
43
66
  const createOptions = {
44
67
  ...outputOptions,
45
68
  "ticket-url": { type: "string" },
@@ -178,7 +201,7 @@ const commands = {
178
201
  },
179
202
  epic: {
180
203
  usage: "agency epic <create|list|show>",
181
- options: { ...createOptions, epic: { type: "string" } },
204
+ options: { ...createOptions, ...viewOptions, epic: { type: "string" } },
182
205
  subcommands: {
183
206
  create: {
184
207
  usage:
@@ -190,10 +213,12 @@ const commands = {
190
213
  repeatable: ["repo"],
191
214
  },
192
215
  list: {
193
- usage: "agency epic list [--json]",
216
+ usage: "agency epic list [filters] [--json]",
194
217
  minArgs: 0,
195
218
  maxArgs: 0,
196
- options: ["json"],
219
+ options: ["json", ...viewOptionNames],
220
+ repeatable: ["status", "repository"],
221
+ conflicts: viewConflicts,
197
222
  },
198
223
  show: {
199
224
  usage: "agency epic show <id> [--json]",
@@ -205,7 +230,7 @@ const commands = {
205
230
  },
206
231
  task: {
207
232
  usage: "agency task <new|create|list|show|status>",
208
- options: { ...taskCreateOptions, task: { type: "string" } },
233
+ options: { ...taskCreateOptions, ...viewOptions, task: { type: "string" } },
209
234
  subcommands: {
210
235
  new: {
211
236
  usage: "agency task new [id] [options]",
@@ -243,10 +268,12 @@ const commands = {
243
268
  repeatable: ["reference"],
244
269
  },
245
270
  list: {
246
- usage: "agency task list [--json]",
271
+ usage: "agency task list [filters] [--json]",
247
272
  minArgs: 0,
248
273
  maxArgs: 0,
249
- options: ["json"],
274
+ options: ["json", ...viewOptionNames],
275
+ repeatable: ["status", "repository"],
276
+ conflicts: viewConflicts,
250
277
  },
251
278
  show: {
252
279
  usage: "agency task show <id> [--json]",
@@ -266,6 +293,7 @@ const commands = {
266
293
  usage: "agency phase <create|list|show|status>",
267
294
  options: {
268
295
  ...phaseCreateOptions,
296
+ ...viewOptions,
269
297
  task: { type: "string" },
270
298
  phase: { type: "string" },
271
299
  },
@@ -291,10 +319,12 @@ const commands = {
291
319
  repeatable: ["reference", "depends-on"],
292
320
  },
293
321
  list: {
294
- usage: "agency phase list <task-id> [--json]",
322
+ usage: "agency phase list <task-id> [filters] [--json]",
295
323
  minArgs: 1,
296
324
  maxArgs: 1,
297
- options: ["json", "task"],
325
+ options: ["json", "task", ...viewOptionNames],
326
+ repeatable: ["status", "repository"],
327
+ conflicts: viewConflicts,
298
328
  },
299
329
  show: {
300
330
  usage: "agency phase show <task-id> <phase-id> [--json]",
@@ -473,13 +503,15 @@ const commands = {
473
503
  },
474
504
  },
475
505
  status: {
476
- usage: "agency status [--json]",
477
- options: outputOptions,
506
+ usage: "agency status [filters] [--json]",
507
+ options: { ...outputOptions, ...viewOptions },
478
508
  command: {
479
- usage: "agency status [--json]",
509
+ usage: "agency status [filters] [--json]",
480
510
  minArgs: 0,
481
511
  maxArgs: 0,
482
- options: ["json"],
512
+ options: ["json", ...viewOptionNames],
513
+ repeatable: ["status", "repository"],
514
+ conflicts: viewConflicts,
483
515
  },
484
516
  },
485
517
  validate: {
@@ -772,6 +804,24 @@ function validateGraphOptions(values: ParsedCli["values"], spec: LeafCommand) {
772
804
  }
773
805
  }
774
806
 
807
+ function validateViewOptions(values: ParsedCli["values"], spec: LeafCommand) {
808
+ const supplied = values.status
809
+ const statuses = Array.isArray(supplied)
810
+ ? supplied
811
+ : supplied === undefined
812
+ ? []
813
+ : [supplied]
814
+ const accepted = new Set(["open", "working", "delegated", "done", "dropped"])
815
+ for (const status of statuses) {
816
+ if (typeof status !== "string" || !accepted.has(status)) {
817
+ throw usageError(
818
+ `Invalid '--status' value '${String(status)}'. Expected one of: ${[...accepted].join(", ")}.`,
819
+ spec.usage,
820
+ )
821
+ }
822
+ }
823
+ }
824
+
775
825
  export function parseCli(args: readonly string[]): ParsedCli {
776
826
  const commandIndex = findCommandIndex(args)
777
827
  if (commandIndex === -1) {
@@ -924,6 +974,12 @@ export function parseCli(args: readonly string[]): ParsedCli {
924
974
  if (commandName === "graph") {
925
975
  validateGraphOptions(parsed.values, spec)
926
976
  }
977
+ if (
978
+ commandName === "status" ||
979
+ (["epic", "task", "phase"].includes(commandName) && subcommand === "list")
980
+ ) {
981
+ validateViewOptions(parsed.values, spec)
982
+ }
927
983
  if (
928
984
  commandName === "finish" &&
929
985
  parsed.values.outcome !== "done" &&
package/src/cli.test.ts CHANGED
@@ -774,10 +774,20 @@ status: open
774
774
  await runCli(["phase", "list", "ship", "--json"], root),
775
775
  )
776
776
  expect(phaseList.map((phase: { id: string }) => phase.id)).toEqual([
777
- "implement",
778
777
  "prepare",
778
+ "implement",
779
779
  "release",
780
780
  ])
781
+ const phaseTable = await runCli(
782
+ ["phase", "list", "ship", "--repository", "agency", "--no-pr"],
783
+ root,
784
+ )
785
+ expect(phaseTable.stdout).toMatch(
786
+ /PHASE\s+PARENT\s+STATUS\s+READINESS\s+REPOSITORIES\s+BRANCH/,
787
+ )
788
+ expect(phaseTable.stdout.indexOf("prepare")).toBeLessThan(
789
+ phaseTable.stdout.indexOf("implement"),
790
+ )
781
791
 
782
792
  const phaseShow = parseJson(
783
793
  await runCli(["phase", "show", "ship", "release", "--json"], root),
@@ -100,4 +100,26 @@ describe("epic command", () => {
100
100
  },
101
101
  })
102
102
  })
103
+
104
+ test("renders a readable operational table", async () => {
105
+ await runTestEffect(
106
+ epic({
107
+ subcommand: "create",
108
+ args: ["example"],
109
+ ticketUrl: "https://example.com/epic",
110
+ repos: ["agency:main"],
111
+ cwd: root,
112
+ silent: true,
113
+ }),
114
+ )
115
+
116
+ const logs = await captureLogs(() =>
117
+ runTestEffect(epic({ subcommand: "list", args: [], cwd: root })),
118
+ )
119
+ expect(logs[0]).toContain(
120
+ "EPIC STATUS READINESS REPOSITORIES PR WORKTREE",
121
+ )
122
+ expect(logs[0]).toContain("example open waiting agency")
123
+ expect(logs[0]).not.toMatch(/[\u{e000}-\u{f8ff}]/u)
124
+ })
103
125
  })
@@ -2,6 +2,8 @@ import { Effect } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
3
  import { EpicService } from "../services/EpicService"
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 EpicOptions extends BaseCommandOptions {
@@ -11,6 +13,11 @@ interface EpicOptions extends BaseCommandOptions {
11
13
  readonly description?: string
12
14
  readonly repos?: readonly string[]
13
15
  readonly json?: boolean
16
+ readonly statuses?: readonly string[]
17
+ readonly repositories?: readonly string[]
18
+ readonly ready?: boolean
19
+ readonly blocked?: boolean
20
+ readonly pr?: boolean
14
21
  }
15
22
 
16
23
  export const epic = (options: EpicOptions) =>
@@ -47,16 +54,40 @@ export const epic = (options: EpicOptions) =>
47
54
 
48
55
  case "list": {
49
56
  const records = yield* epics.list(cwd)
57
+ const { epicRows } = yield* getWorkViews({
58
+ cwd,
59
+ statuses: options.statuses,
60
+ repositories: options.repositories,
61
+ ready: options.ready,
62
+ blocked: options.blocked,
63
+ pr: options.pr,
64
+ })
65
+ const ordered = epicRows.flatMap((row) => {
66
+ const record = records.find((item) => item.id === row.key)
67
+ return record ? [record] : []
68
+ })
50
69
  if (options.json) {
51
70
  log(
52
71
  JSON.stringify(
53
- records.map(({ content: _, ...record }) => record),
72
+ ordered.map(({ content: _, ...record }) => record),
54
73
  null,
55
74
  2,
56
75
  ),
57
76
  )
58
77
  } else {
59
- for (const record of records) log(record.id)
78
+ log(
79
+ formatTable(
80
+ ["EPIC", "STATUS", "READINESS", "REPOSITORIES", "PR", "WORKTREE"],
81
+ epicRows.map((row) => [
82
+ row.id,
83
+ row.status,
84
+ row.readiness,
85
+ row.repositories,
86
+ row.pr,
87
+ row.worktree,
88
+ ]),
89
+ ),
90
+ )
60
91
  }
61
92
  return
62
93
  }
@@ -98,4 +129,9 @@ Create options:
98
129
 
99
130
  Options:
100
131
  --json Output results as JSON
132
+ --status <status> Filter list by status; repeatable
133
+ --repository <alias> Filter list by repository; repeatable
134
+ --ready Include only ready epics
135
+ --blocked Include only blocked epics
136
+ --pr / --no-pr Filter by recorded PR presence
101
137
  `
@@ -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
  `
@@ -0,0 +1,19 @@
1
+ export const formatTable = (
2
+ headings: readonly string[],
3
+ rows: readonly (readonly string[])[],
4
+ ) => {
5
+ const widths = headings.map((heading, index) =>
6
+ Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0)),
7
+ )
8
+ const line = (cells: readonly string[]) =>
9
+ cells
10
+ .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
11
+ .join(" ")
12
+ .trimEnd()
13
+
14
+ return [
15
+ line(headings),
16
+ line(widths.map((width) => "-".repeat(width))),
17
+ ...rows.map(line),
18
+ ].join("\n")
19
+ }
@@ -0,0 +1,151 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { dirname, join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "./test-utils"
6
+ import { getWorkViews } from "./work-view"
7
+
8
+ const write = async (root: string, path: string, content: string) => {
9
+ const fullPath = join(root, path)
10
+ await mkdir(dirname(fullPath), { recursive: true })
11
+ await Bun.write(fullPath, content)
12
+ }
13
+
14
+ describe("work views", () => {
15
+ let root: string
16
+
17
+ beforeEach(async () => {
18
+ root = await createTempDir()
19
+ await write(root, "agency.json", '{"version":2}\n')
20
+ await mkdir(join(root, "repos/agency"), { recursive: true })
21
+ await write(
22
+ root,
23
+ "epics/delivery/EPIC.md",
24
+ `---
25
+ ticketUrl: https://example.com/delivery
26
+ repos:
27
+ - repo: agency
28
+ ref: main
29
+ tasks:
30
+ - id: zeta
31
+ - id: alpha
32
+ ---
33
+ `,
34
+ )
35
+ await write(
36
+ root,
37
+ "tasks/zeta/TASK.md",
38
+ `---
39
+ ticketUrl: null
40
+ epic: delivery
41
+ phases:
42
+ - id: verify
43
+ dependsOn: [implement]
44
+ - id: implement
45
+ ---
46
+ `,
47
+ )
48
+ await write(
49
+ root,
50
+ "tasks/zeta/phases/verify/PHASE.md",
51
+ `---
52
+ repo: agency
53
+ branch: feat/verify
54
+ base: main
55
+ pr: null
56
+ status: open
57
+ ---
58
+ `,
59
+ )
60
+ await write(
61
+ root,
62
+ "tasks/zeta/phases/implement/PHASE.md",
63
+ `---
64
+ repo: agency
65
+ branch: feat/implement
66
+ base: main
67
+ pr: https://github.com/example/agency/pull/1
68
+ status: dropped
69
+ ---
70
+ `,
71
+ )
72
+ await mkdir(join(root, "tasks/zeta/phases/implement/code/agency"), {
73
+ recursive: true,
74
+ })
75
+ await write(
76
+ root,
77
+ "tasks/alpha/TASK.md",
78
+ `---
79
+ ticketUrl: null
80
+ epic: delivery
81
+ repo: agency
82
+ branch: feat/alpha
83
+ base: main
84
+ pr: null
85
+ status: open
86
+ ---
87
+ `,
88
+ )
89
+ })
90
+
91
+ afterEach(async () => cleanupTempDir(root))
92
+
93
+ const views = (options: Record<string, unknown> = {}) =>
94
+ runTestEffect(Effect.suspend(() => getWorkViews({ cwd: root, ...options })))
95
+
96
+ test("uses declared graph order and exposes operational state", async () => {
97
+ const result = await views()
98
+
99
+ expect(result.taskRows.map((row) => row.id)).toEqual(["zeta", "alpha"])
100
+ expect(result.phaseRows.map((row) => row.id)).toEqual([
101
+ "verify",
102
+ "implement",
103
+ ])
104
+ expect(result.executionRows.map((row) => row.key)).toEqual([
105
+ "zeta/verify",
106
+ "zeta/implement",
107
+ "alpha",
108
+ ])
109
+ expect(result.phaseRows[0]).toMatchObject({
110
+ parent: "zeta",
111
+ status: "open",
112
+ readiness: "blocked",
113
+ repositories: "agency",
114
+ branch: "feat/verify",
115
+ pr: "absent",
116
+ worktree: "absent",
117
+ })
118
+ expect(result.phaseRows[1]).toMatchObject({
119
+ readiness: "terminal",
120
+ pr: "present",
121
+ worktree: "materialized",
122
+ })
123
+ expect(result.taskRows[0]).toMatchObject({
124
+ parent: "delivery",
125
+ branch: "multiple",
126
+ pr: "1/2 present",
127
+ worktree: "1/2 materialized",
128
+ })
129
+ })
130
+
131
+ test("composes lifecycle, repository, readiness, and PR filters", async () => {
132
+ expect(
133
+ (await views({ statuses: ["dropped"] })).executionRows.map(
134
+ (row) => row.key,
135
+ ),
136
+ ).toEqual(["zeta/implement"])
137
+ expect(
138
+ (await views({ repositories: ["agency"], blocked: true })).phaseRows.map(
139
+ (row) => row.id,
140
+ ),
141
+ ).toEqual(["verify"])
142
+ expect(
143
+ (await views({ ready: true, pr: false })).executionRows.map(
144
+ (row) => row.key,
145
+ ),
146
+ ).toEqual(["alpha"])
147
+ expect((await views({ pr: true })).phaseRows.map((row) => row.id)).toEqual([
148
+ "implement",
149
+ ])
150
+ })
151
+ })
@@ -0,0 +1,253 @@
1
+ import { Effect } from "effect"
2
+ import type { GraphNode } from "./graph-schema"
3
+ import { GraphService } from "./services/GraphService"
4
+ import type { WorkStatus } from "./workbase/schemas"
5
+
6
+ type WorkNode = Extract<
7
+ GraphNode,
8
+ { readonly kind: "epic" | "task" | "phase" | "execution-unit" }
9
+ >
10
+ type EntityNode = Exclude<WorkNode, { readonly kind: "execution-unit" }>
11
+ type ExecutionNode = Extract<WorkNode, { readonly kind: "execution-unit" }>
12
+
13
+ export interface WorkViewOptions {
14
+ readonly cwd?: string
15
+ readonly statuses?: readonly string[]
16
+ readonly repositories?: readonly string[]
17
+ readonly ready?: boolean
18
+ readonly blocked?: boolean
19
+ readonly pr?: boolean
20
+ }
21
+
22
+ export interface WorkViewRow {
23
+ readonly kind: "epic" | "task" | "phase"
24
+ readonly id: string
25
+ readonly key: string
26
+ readonly parent: string
27
+ readonly status: WorkStatus
28
+ readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
29
+ readonly repositories: string
30
+ readonly branch: string
31
+ readonly pr: string
32
+ readonly worktree: string
33
+ readonly hasPr: boolean
34
+ }
35
+
36
+ const allowedStatuses = new Set<WorkStatus>([
37
+ "open",
38
+ "working",
39
+ "delegated",
40
+ "done",
41
+ "dropped",
42
+ ])
43
+
44
+ const validatedStatuses = (values: readonly string[] | undefined) =>
45
+ (values ?? []).map((value) => {
46
+ if (!allowedStatuses.has(value as WorkStatus)) {
47
+ throw new Error(
48
+ `Invalid --status value '${value}'. Expected one of: ${[...allowedStatuses].join(", ")}`,
49
+ )
50
+ }
51
+ return value as WorkStatus
52
+ })
53
+
54
+ const readinessLabel = (node: EntityNode): WorkViewRow["readiness"] =>
55
+ node.readiness.ready
56
+ ? "ready"
57
+ : node.readiness.terminal
58
+ ? "terminal"
59
+ : node.readiness.blocked
60
+ ? "blocked"
61
+ : "waiting"
62
+
63
+ const aggregateLabel = (
64
+ executions: readonly ExecutionNode[],
65
+ predicate: (node: ExecutionNode) => boolean,
66
+ singular: readonly [present: string, absent: string],
67
+ ) => {
68
+ if (executions.length === 0) return "-"
69
+ const count = executions.filter(predicate).length
70
+ if (executions.length === 1) return count === 1 ? singular[0] : singular[1]
71
+ return `${count}/${executions.length} ${singular[0]}`
72
+ }
73
+
74
+ const rowFor = (
75
+ node: EntityNode,
76
+ executions: readonly ExecutionNode[],
77
+ ): WorkViewRow => {
78
+ const branches = [
79
+ ...new Set(executions.map((execution) => execution.data.branch)),
80
+ ]
81
+ const parent =
82
+ node.kind === "task"
83
+ ? (node.data.epic ?? "-")
84
+ : node.kind === "phase"
85
+ ? node.key.slice(0, node.key.indexOf("/"))
86
+ : "-"
87
+ const id =
88
+ node.kind === "phase" ? node.key.slice(node.key.indexOf("/") + 1) : node.key
89
+
90
+ return {
91
+ kind: node.kind,
92
+ id,
93
+ key: node.key,
94
+ parent,
95
+ status: node.status,
96
+ readiness: readinessLabel(node),
97
+ repositories: node.repositories.join(",") || "-",
98
+ branch:
99
+ branches.length === 0
100
+ ? "-"
101
+ : branches.length === 1
102
+ ? branches[0]!
103
+ : "multiple",
104
+ pr: aggregateLabel(executions, (execution) => Boolean(execution.data.pr), [
105
+ "present",
106
+ "absent",
107
+ ]),
108
+ worktree: aggregateLabel(
109
+ executions,
110
+ (execution) => execution.workspace?.materialized === true,
111
+ ["materialized", "absent"],
112
+ ),
113
+ hasPr: executions.some((execution) => Boolean(execution.data.pr)),
114
+ }
115
+ }
116
+
117
+ const orderedTasks = (
118
+ epics: readonly Extract<EntityNode, { readonly kind: "epic" }>[],
119
+ tasks: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "task" }>>,
120
+ ) => {
121
+ const ordered: Extract<EntityNode, { readonly kind: "task" }>[] = []
122
+ const seen = new Set<string>()
123
+ for (const epic of epics) {
124
+ for (const item of epic.data.tasks) {
125
+ const task = tasks.get(item.id)
126
+ if (task && !seen.has(task.key)) {
127
+ ordered.push(task)
128
+ seen.add(task.key)
129
+ }
130
+ }
131
+ }
132
+ for (const task of [...tasks.values()].sort((a, b) =>
133
+ a.key.localeCompare(b.key),
134
+ )) {
135
+ if (!seen.has(task.key)) ordered.push(task)
136
+ }
137
+ return ordered
138
+ }
139
+
140
+ const orderedPhases = (
141
+ task: Extract<EntityNode, { readonly kind: "task" }>,
142
+ phases: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "phase" }>>,
143
+ ) => {
144
+ const ordered: Extract<EntityNode, { readonly kind: "phase" }>[] = []
145
+ const seen = new Set<string>()
146
+ if ("phases" in task.data) {
147
+ for (const item of task.data.phases) {
148
+ const phase = phases.get(`${task.key}/${item.id}`)
149
+ if (phase) {
150
+ ordered.push(phase)
151
+ seen.add(phase.key)
152
+ }
153
+ }
154
+ }
155
+ for (const phase of [...phases.values()]
156
+ .filter((value) => value.key.startsWith(`${task.key}/`))
157
+ .sort((a, b) => a.key.localeCompare(b.key))) {
158
+ if (!seen.has(phase.key)) ordered.push(phase)
159
+ }
160
+ return ordered
161
+ }
162
+
163
+ export const getWorkViews = (options: WorkViewOptions = {}) =>
164
+ Effect.gen(function* () {
165
+ const graphService = yield* GraphService
166
+ const statuses = yield* Effect.sync(() =>
167
+ validatedStatuses(options.statuses),
168
+ )
169
+ const graph = yield* graphService.get({
170
+ cwd: options.cwd,
171
+ include: ["workspace"],
172
+ })
173
+ const workNodes = graph.nodes.filter(
174
+ (node): node is WorkNode => node.kind !== "repository",
175
+ )
176
+ const epics = workNodes
177
+ .filter(
178
+ (node): node is Extract<EntityNode, { readonly kind: "epic" }> =>
179
+ node.kind === "epic",
180
+ )
181
+ .sort((a, b) => a.key.localeCompare(b.key))
182
+ const tasks = new Map(
183
+ workNodes
184
+ .filter(
185
+ (node): node is Extract<EntityNode, { readonly kind: "task" }> =>
186
+ node.kind === "task",
187
+ )
188
+ .map((node) => [node.key, node]),
189
+ )
190
+ const phases = new Map(
191
+ workNodes
192
+ .filter(
193
+ (node): node is Extract<EntityNode, { readonly kind: "phase" }> =>
194
+ node.kind === "phase",
195
+ )
196
+ .map((node) => [node.key, node]),
197
+ )
198
+ const executions = workNodes.filter(
199
+ (node): node is ExecutionNode => node.kind === "execution-unit",
200
+ )
201
+ const taskOrder = orderedTasks(epics, tasks)
202
+ const phaseOrder = taskOrder.flatMap((task) => orderedPhases(task, phases))
203
+ const executionsFor = (node: EntityNode) => {
204
+ if (node.kind === "phase") {
205
+ const separator = node.key.indexOf("/")
206
+ const taskId = node.key.slice(0, separator)
207
+ const phaseId = node.key.slice(separator + 1)
208
+ return executions.filter(
209
+ (execution) =>
210
+ execution.data.taskId === taskId &&
211
+ execution.data.phaseId === phaseId,
212
+ )
213
+ }
214
+ if (node.kind === "task") {
215
+ return executions.filter(
216
+ (execution) => execution.data.taskId === node.key,
217
+ )
218
+ }
219
+ const taskIds = new Set(node.data.tasks.map((item) => item.id))
220
+ return executions.filter((execution) =>
221
+ taskIds.has(execution.data.taskId),
222
+ )
223
+ }
224
+ const makeRows = (nodes: readonly EntityNode[]) =>
225
+ nodes.map((node) => rowFor(node, executionsFor(node)))
226
+ const filterRows = (rows: readonly WorkViewRow[]) =>
227
+ rows
228
+ .filter((row) => statuses.length === 0 || statuses.includes(row.status))
229
+ .filter(
230
+ (row) =>
231
+ !options.repositories?.length ||
232
+ row.repositories
233
+ .split(",")
234
+ .some((repo) => options.repositories!.includes(repo)),
235
+ )
236
+ .filter((row) => options.ready !== true || row.readiness === "ready")
237
+ .filter(
238
+ (row) => options.blocked !== true || row.readiness === "blocked",
239
+ )
240
+ .filter((row) => options.pr === undefined || row.hasPr === options.pr)
241
+
242
+ const epicRows = filterRows(makeRows(epics))
243
+ const taskRows = filterRows(makeRows(taskOrder))
244
+ const phaseRows = filterRows(makeRows(phaseOrder))
245
+ const executionEntities: EntityNode[] = []
246
+ for (const task of taskOrder) {
247
+ const taskPhases = orderedPhases(task, phases)
248
+ executionEntities.push(...(taskPhases.length > 0 ? taskPhases : [task]))
249
+ }
250
+ const executionRows = filterRows(makeRows(executionEntities))
251
+
252
+ return { epicRows, taskRows, phaseRows, executionRows }
253
+ })