@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 +11 -4
- package/cli.ts +20 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +37 -2
- package/src/cli-parser.ts +68 -12
- package/src/cli.test.ts +11 -1
- package/src/commands/epic.test.ts +22 -0
- package/src/commands/epic.ts +38 -2
- package/src/commands/phase.ts +52 -2
- package/src/commands/status.test.ts +44 -0
- package/src/commands/status.ts +51 -2
- package/src/commands/task-phase.test.ts +20 -0
- package/src/commands/task.ts +49 -2
- package/src/utils/table.ts +19 -0
- package/src/work-view.test.ts +151 -0
- package/src/work-view.ts +253 -0
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
package/src/cli-parser.test.ts
CHANGED
|
@@ -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
|
})
|
package/src/commands/epic.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
+
ordered.map(({ content: _, ...record }) => record),
|
|
54
73
|
null,
|
|
55
74
|
2,
|
|
56
75
|
),
|
|
57
76
|
)
|
|
58
77
|
} else {
|
|
59
|
-
|
|
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
|
`
|
package/src/commands/phase.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
+
ordered.map(({ content: _, ...record }) => record),
|
|
71
91
|
null,
|
|
72
92
|
2,
|
|
73
93
|
),
|
|
74
94
|
)
|
|
75
|
-
} else
|
|
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
|
})
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
package/src/commands/task.ts
CHANGED
|
@@ -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
|
-
|
|
233
|
+
ordered.map(({ content: _, ...record }) => record),
|
|
215
234
|
null,
|
|
216
235
|
2,
|
|
217
236
|
),
|
|
218
237
|
)
|
|
219
238
|
} else {
|
|
220
|
-
|
|
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
|
+
})
|
package/src/work-view.ts
ADDED
|
@@ -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
|
+
})
|