@markjaquith/agency 3.5.1 → 3.6.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.
@@ -0,0 +1,734 @@
1
+ import { Effect } from "effect"
2
+ import type { GraphNode } from "../graph-schema"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import type { ActionPrompts } from "./act-prompts"
5
+ import { archive } from "./archive"
6
+ import { repo } from "./repo"
7
+ import { task } from "./task"
8
+ import { phase } from "./phase"
9
+ import { sync } from "./sync"
10
+ import { status } from "./status"
11
+ import { pr, prCreate } from "./pr"
12
+ import { work as startWork, type StartWork } from "./work"
13
+
14
+ export type ActEntity = Extract<
15
+ GraphNode,
16
+ { readonly kind: "epic" | "task" | "phase" }
17
+ >
18
+ type NativeOperation =
19
+ | ReturnType<typeof task>
20
+ | ReturnType<typeof phase>
21
+ | ReturnType<typeof repo>
22
+ | ReturnType<typeof sync>
23
+ | ReturnType<typeof status>
24
+ | ReturnType<typeof prCreate>
25
+ | ReturnType<typeof archive>
26
+ | ReturnType<StartWork>
27
+ type Operation = Effect.Effect<
28
+ unknown,
29
+ Effect.Effect.Error<NativeOperation>,
30
+ Effect.Effect.Context<NativeOperation>
31
+ >
32
+ interface Plan {
33
+ readonly command: readonly string[]
34
+ readonly run: Operation
35
+ readonly followUpCommands?: readonly (readonly string[])[]
36
+ readonly next?: { readonly taskId: string; readonly phaseId?: string }
37
+ }
38
+ interface Input {
39
+ readonly id: string
40
+ readonly label: string
41
+ readonly required: boolean
42
+ readonly multiline?: boolean
43
+ /** An optional input is appended as this native CLI option. */
44
+ readonly option?: string
45
+ }
46
+ interface Details {
47
+ readonly id: string
48
+ readonly label: string
49
+ readonly blockedReason: string | null
50
+ readonly inputs?: readonly Input[]
51
+ }
52
+ export interface ActAction extends Details {
53
+ readonly preview: Plan
54
+ /** Replayable input collection. All mutations belong in the returned plan.run. */
55
+ readonly prepare: (prompts: ActionPrompts) => Effect.Effect<Plan, Error>
56
+ }
57
+
58
+ // A typed argument builder is used for both discovery and execution. Prompts are
59
+ // ordinary TypeScript; discovery does not expose or interpret form instructions.
60
+ const action = <A>(
61
+ details: Details,
62
+ example: A,
63
+ read: (p: ActionPrompts) => Effect.Effect<A, Error>,
64
+ build: (args: A) => Plan,
65
+ ): ActAction => ({
66
+ ...details,
67
+ preview: build(example),
68
+ prepare: (p) => Effect.map(read(p), build),
69
+ })
70
+ const immediate = (details: Details, plan: Plan): ActAction => ({
71
+ ...details,
72
+ preview: plan,
73
+ prepare: () => Effect.succeed(plan),
74
+ })
75
+ const input = (
76
+ id: string,
77
+ label: string,
78
+ options: { option?: string; multiline?: boolean } = {},
79
+ ): Input => ({
80
+ id,
81
+ label,
82
+ required: !options.option,
83
+ ...options,
84
+ })
85
+ const taskTarget = (node: ActEntity) =>
86
+ node.kind === "phase"
87
+ ? { taskId: node.key.split("/")[0]!, phaseId: node.key.split("/")[1]! }
88
+ : { taskId: node.key }
89
+
90
+ export const actionGroups = [
91
+ {
92
+ id: "create",
93
+ label: "Create a task",
94
+ icon: "󰐕",
95
+ actions: ["task-create", "investigation-create"],
96
+ },
97
+ {
98
+ id: "work",
99
+ label: "Work on a task or phase",
100
+ icon: "",
101
+ actions: ["work"],
102
+ },
103
+ {
104
+ id: "review",
105
+ label: "Review someone else's work",
106
+ icon: "󰍉",
107
+ actions: ["review", "review-ref"],
108
+ },
109
+ {
110
+ id: "split",
111
+ label: "Split a task / add a phase",
112
+ icon: "",
113
+ actions: ["split"],
114
+ },
115
+ {
116
+ id: "handoff",
117
+ label: "Turn investigation into implementation",
118
+ icon: "",
119
+ actions: ["handoff"],
120
+ },
121
+ {
122
+ id: "current-work",
123
+ label: "See current work",
124
+ icon: "",
125
+ actions: ["current-work"],
126
+ },
127
+ {
128
+ id: "browse",
129
+ label: "Browse items",
130
+ icon: "",
131
+ actions: [],
132
+ },
133
+ {
134
+ id: "pull-request",
135
+ label: "Update pull-request status",
136
+ icon: "",
137
+ actions: ["sync", "pr", "pr-ready", "pr-close"],
138
+ },
139
+ {
140
+ id: "close",
141
+ label: "Close or reopen work",
142
+ icon: "󰄬",
143
+ actions: ["complete", "drop", "sync", "reopen"],
144
+ },
145
+ {
146
+ id: "archive",
147
+ label: "Archive finished work",
148
+ icon: "",
149
+ actions: ["archive"],
150
+ },
151
+ {
152
+ id: "repository",
153
+ label: "Add a repository",
154
+ icon: "",
155
+ actions: ["repo-add", "repo-link"],
156
+ },
157
+ ] as const
158
+
159
+ export const actActions = (
160
+ nodes: readonly GraphNode[],
161
+ options: BaseCommandOptions & { auto?: boolean; draft?: boolean },
162
+ work: StartWork = startWork,
163
+ node?: ActEntity,
164
+ ): readonly ActAction[] => {
165
+ const repositories = nodes
166
+ .filter((node) => node.kind === "repository")
167
+ .map((node) => node.key)
168
+ const needsRepository = repositories.length
169
+ ? null
170
+ : "Add or link a repository first"
171
+ if (!node)
172
+ return [
173
+ ...(["add", "link"] as const).map((operation) =>
174
+ action(
175
+ {
176
+ id: `repo-${operation}`,
177
+ label:
178
+ operation === "add"
179
+ ? "Add from a remote"
180
+ : "Link a local repository",
181
+ blockedReason: null,
182
+ inputs: [
183
+ input("alias", "Repository alias"),
184
+ input(
185
+ "source",
186
+ operation === "add" ? "Remote URL" : "Local path",
187
+ ),
188
+ ],
189
+ },
190
+ { alias: "<alias>", source: "<source>" },
191
+ (p) =>
192
+ Effect.gen(function* () {
193
+ return {
194
+ alias: yield* p.text("Repository alias"),
195
+ source: yield* p.text(
196
+ operation === "add"
197
+ ? "Repository remote URL"
198
+ : "Local repository path",
199
+ ),
200
+ }
201
+ }),
202
+ ({ alias, source }) => ({
203
+ command: ["agency", "repo", operation, alias, source],
204
+ run: repo({
205
+ ...options,
206
+ subcommand: operation,
207
+ args: [alias, source],
208
+ }),
209
+ }),
210
+ ),
211
+ ),
212
+ ...([false, true] as const).map((investigation) =>
213
+ action(
214
+ {
215
+ id: investigation ? "investigation-create" : "task-create",
216
+ label: investigation
217
+ ? "Create an investigation"
218
+ : "Create a standard task",
219
+ blockedReason: needsRepository,
220
+ inputs: [
221
+ input("description", "Outcome", { multiline: true }),
222
+ input("id", "Task ID"),
223
+ input("repo", "Repository alias"),
224
+ input("base", "Base branch"),
225
+ ],
226
+ },
227
+ {
228
+ id: "<id>",
229
+ description: "<description>",
230
+ repo: "<repo>",
231
+ base: "<base>",
232
+ },
233
+ (p) =>
234
+ Effect.gen(function* () {
235
+ const description = yield* p.text("Outcome")
236
+ return {
237
+ description,
238
+ id: yield* p.id("New task ID", description),
239
+ repo: yield* p.repository(),
240
+ base: yield* p.text("Base branch", "main"),
241
+ }
242
+ }),
243
+ (args) => ({
244
+ command: [
245
+ "agency",
246
+ "task",
247
+ "create",
248
+ args.id,
249
+ "--repo",
250
+ args.repo,
251
+ "--base",
252
+ args.base,
253
+ "--description",
254
+ args.description,
255
+ ...(investigation ? ["--purpose", "investigation"] : []),
256
+ ],
257
+ run: task({
258
+ ...options,
259
+ subcommand: "create",
260
+ args: [args.id],
261
+ ...args,
262
+ purpose: investigation ? "investigation" : undefined,
263
+ }),
264
+ next: { taskId: args.id },
265
+ }),
266
+ ),
267
+ ),
268
+ ...(["pull-request", "ref"] as const).map((sourceType) =>
269
+ action(
270
+ {
271
+ id: sourceType === "ref" ? "review-ref" : "review",
272
+ label:
273
+ sourceType === "ref"
274
+ ? "Review a branch or commit"
275
+ : "Review a pull request",
276
+ blockedReason: needsRepository,
277
+ inputs: [
278
+ input("repo", "Repository alias"),
279
+ input(
280
+ "source",
281
+ sourceType === "ref" ? "Remote ref" : "PR URL or number",
282
+ ),
283
+ input("id", "Review task ID"),
284
+ ],
285
+ },
286
+ { id: "<id>", repo: "<repo>", source: "<source>" },
287
+ (p) =>
288
+ Effect.gen(function* () {
289
+ const repo = yield* p.repository()
290
+ const source = yield* p.text(
291
+ sourceType === "ref"
292
+ ? "Remote branch or commit"
293
+ : "Pull request URL or number",
294
+ sourceType === "ref" ? "main" : "",
295
+ )
296
+ return {
297
+ repo,
298
+ source,
299
+ id: yield* p.id(
300
+ "New review task ID",
301
+ `review-${source.split("/").pop()}`,
302
+ ),
303
+ }
304
+ }),
305
+ ({ id, repo, source }) => ({
306
+ command: [
307
+ "agency",
308
+ "task",
309
+ "create",
310
+ id,
311
+ "--review",
312
+ repo,
313
+ `--${sourceType}`,
314
+ source,
315
+ ],
316
+ run: task({
317
+ ...options,
318
+ subcommand: "create",
319
+ args: [id],
320
+ review: repo,
321
+ ...(sourceType === "ref"
322
+ ? { ref: source }
323
+ : { pullRequest: source }),
324
+ }),
325
+ next: { taskId: id },
326
+ }),
327
+ ),
328
+ ),
329
+ immediate(
330
+ {
331
+ id: "current-work",
332
+ label: "See current work",
333
+ blockedReason: null,
334
+ },
335
+ {
336
+ command: ["agency", "status", "--status", "working"],
337
+ run: status({ ...options, statuses: ["working"] }),
338
+ },
339
+ ),
340
+ ]
341
+
342
+ const target = taskTarget(node)
343
+ const args = [target.taskId, ...(target.phaseId ? [target.phaseId] : [])]
344
+ const execution = nodes.find(
345
+ (candidate): candidate is Extract<GraphNode, { kind: "execution-unit" }> =>
346
+ candidate.kind === "execution-unit" &&
347
+ candidate.id === `execution-unit:${node.kind}/${node.key}`,
348
+ )
349
+ const parent = nodes.find(
350
+ (candidate) => candidate.kind === "task" && candidate.key === target.taskId,
351
+ )
352
+ const purpose =
353
+ parent && "purpose" in parent.data ? parent.data.purpose : undefined
354
+ const terminal = node.status === "done" || node.status === "dropped"
355
+ const blockers = (execution ?? node).readiness.blockers
356
+ const validation = blockers.find(
357
+ (blocker) => blocker.kind === "validation",
358
+ )?.reason
359
+ const dependency = blockers.find(
360
+ (blocker) => blocker.kind === "dependency",
361
+ )?.reason
362
+ const recordedPr = "pr" in node.data ? node.data.pr : null
363
+ const prUrl = typeof recordedPr === "string" ? recordedPr : recordedPr?.url
364
+ const base = "base" in node.data ? node.data.base : "main"
365
+ const preferredRepo = "repo" in node.data ? node.data.repo : undefined
366
+ const details = (
367
+ id: string,
368
+ label: string,
369
+ reason: string | null = null,
370
+ ): Details => ({
371
+ id,
372
+ label,
373
+ blockedReason: validation ?? reason,
374
+ })
375
+ const noExecution = execution ? null : "Select an execution unit"
376
+ const statusCommand = (status: string) => [
377
+ "agency",
378
+ target.phaseId ? "phase" : "task",
379
+ "status",
380
+ ...args,
381
+ status,
382
+ "--if-revision",
383
+ node.data.sha256,
384
+ ]
385
+ const changeStatus = (status: string, summary?: string) => {
386
+ const update = {
387
+ ...options,
388
+ subcommand: "status",
389
+ args: [...args, status],
390
+ ifRevision: node.data.sha256,
391
+ ...(summary ? { noPullRequest: true, summary } : {}),
392
+ }
393
+ return target.phaseId ? phase(update) : task(update)
394
+ }
395
+ const workTarget = node.kind === "epic" ? { epicId: node.key } : target
396
+ const canWork =
397
+ node.readiness.ready ||
398
+ Boolean(
399
+ execution && node.status === "working" && !dependency && !validation,
400
+ )
401
+ return [
402
+ immediate(
403
+ details(
404
+ "work",
405
+ "Work on this item",
406
+ canWork
407
+ ? null
408
+ : blockers.map((blocker) => blocker.reason).join("; ") ||
409
+ "Item is not ready for work",
410
+ ),
411
+ {
412
+ command: [
413
+ "agency",
414
+ "work",
415
+ ...(node.kind === "epic"
416
+ ? ["--epic", node.key]
417
+ : [
418
+ "--task",
419
+ target.taskId,
420
+ ...(target.phaseId ? ["--phase", target.phaseId] : []),
421
+ ]),
422
+ ...(options.auto ? ["--auto"] : []),
423
+ ],
424
+ run: Effect.suspend(() => work({ ...options, ...workTarget })),
425
+ },
426
+ ),
427
+ immediate(
428
+ details(
429
+ "pr",
430
+ "Create pull request",
431
+ noExecution ??
432
+ (parent && "review" in parent.data
433
+ ? "Review tasks do not create delivery PRs"
434
+ : purpose === "investigation"
435
+ ? "Create an implementation follow-up first"
436
+ : terminal
437
+ ? "Item is terminal"
438
+ : prUrl
439
+ ? "Item already has a recorded PR"
440
+ : (dependency ?? null)),
441
+ ),
442
+ {
443
+ command: [
444
+ "agency",
445
+ "pr",
446
+ "create",
447
+ ...args,
448
+ ...(options.draft ? ["--draft"] : []),
449
+ ],
450
+ run: prCreate({ ...options, ...target }),
451
+ },
452
+ ),
453
+ ...(["reopen", "drop"] as const).map((id) => {
454
+ const status = id === "reopen" ? "open" : "dropped"
455
+ return immediate(
456
+ details(
457
+ id,
458
+ id === "reopen" ? "Reopen" : "Drop",
459
+ noExecution ??
460
+ (id === "reopen"
461
+ ? terminal
462
+ ? null
463
+ : "Item is not terminal"
464
+ : terminal
465
+ ? "Item is already terminal"
466
+ : null),
467
+ ),
468
+ { command: statusCommand(status), run: changeStatus(status) },
469
+ )
470
+ }),
471
+ immediate(
472
+ details(
473
+ "archive",
474
+ "Archive",
475
+ node.readiness.terminal
476
+ ? null
477
+ : "Item and all children must be terminal",
478
+ ),
479
+ {
480
+ command: ["agency", "archive", node.kind, ...args],
481
+ run: archive({ ...options, type: node.kind, args }),
482
+ },
483
+ ),
484
+ action(
485
+ {
486
+ ...details(
487
+ "split",
488
+ "Add a phase / split this task",
489
+ node.kind !== "task"
490
+ ? "Select a task"
491
+ : "review" in node.data
492
+ ? "Review tasks cannot be split into execution phases"
493
+ : terminal
494
+ ? "Task is terminal"
495
+ : null,
496
+ ),
497
+ inputs: [
498
+ input("id", "New phase ID"),
499
+ ...(node.kind === "task" && !("phases" in node.data)
500
+ ? [input("firstPhase", "Existing work's phase ID")]
501
+ : []),
502
+ input("repo", "Repository alias"),
503
+ input("base", "Base branch"),
504
+ input("branch", "New phase branch"),
505
+ input("dependsOn", "Completion dependency", {
506
+ option: "--depends-on",
507
+ }),
508
+ ],
509
+ },
510
+ {
511
+ id: "<id>",
512
+ firstPhase:
513
+ node.kind === "task" && !("phases" in node.data)
514
+ ? "<firstPhase>"
515
+ : undefined,
516
+ repo: "<repo>",
517
+ base: "<base>",
518
+ branch: "<branch>",
519
+ dependsOn: "",
520
+ },
521
+ (p) =>
522
+ Effect.gen(function* () {
523
+ const id = yield* p.id(
524
+ "New phase ID",
525
+ "phases" in node.data
526
+ ? `phase-${node.data.phases.length + 1}`
527
+ : "next",
528
+ `${node.key}/`,
529
+ )
530
+ const firstPhase =
531
+ "phases" in node.data
532
+ ? undefined
533
+ : yield* p.text(
534
+ "Name for the existing work's first phase",
535
+ "initial",
536
+ )
537
+ return {
538
+ id,
539
+ firstPhase,
540
+ repo: yield* p.repository(preferredRepo),
541
+ base: yield* p.text("Base branch", base),
542
+ branch: yield* p.text("New phase branch", `task/${node.key}-${id}`),
543
+ dependsOn: yield* p.text(
544
+ "Wait for phase (optional; independent of base branch)",
545
+ "",
546
+ false,
547
+ ),
548
+ }
549
+ }),
550
+ (values) => ({
551
+ command: [
552
+ "agency",
553
+ "phase",
554
+ "create",
555
+ target.taskId,
556
+ values.id,
557
+ "--repo",
558
+ values.repo,
559
+ "--base",
560
+ values.base,
561
+ "--branch",
562
+ values.branch,
563
+ ...(values.firstPhase ? ["--first-phase", values.firstPhase] : []),
564
+ ...(values.dependsOn ? ["--depends-on", values.dependsOn] : []),
565
+ ],
566
+ run: phase({
567
+ ...options,
568
+ subcommand: "create",
569
+ args: [target.taskId, values.id],
570
+ ...values,
571
+ dependsOn: values.dependsOn ? [values.dependsOn] : undefined,
572
+ }),
573
+ next: { taskId: target.taskId, phaseId: values.id },
574
+ }),
575
+ ),
576
+ action(
577
+ {
578
+ ...details(
579
+ "handoff",
580
+ "Create implementation follow-up",
581
+ noExecution ??
582
+ (purpose === "investigation"
583
+ ? null
584
+ : "Select an investigation execution unit"),
585
+ ),
586
+ inputs: [
587
+ input("id", "New task ID"),
588
+ input("repo", "Repository alias"),
589
+ input("base", "Base branch"),
590
+ ],
591
+ },
592
+ { id: "<id>", repo: "<repo>", base: "<base>" },
593
+ (p) =>
594
+ Effect.gen(function* () {
595
+ return {
596
+ id: yield* p.id(
597
+ "New implementation task ID",
598
+ `${target.taskId}-implementation`,
599
+ ),
600
+ repo: yield* p.repository(preferredRepo),
601
+ base: yield* p.text("Base branch", base),
602
+ }
603
+ }),
604
+ (values) => ({
605
+ command: [
606
+ "agency",
607
+ "task",
608
+ "handoff",
609
+ target.taskId,
610
+ values.id,
611
+ "--repo",
612
+ values.repo,
613
+ "--base",
614
+ values.base,
615
+ ...(target.phaseId ? ["--source-phase", target.phaseId] : []),
616
+ ],
617
+ run: task({
618
+ ...options,
619
+ subcommand: "handoff",
620
+ args: [target.taskId, values.id],
621
+ ...values,
622
+ sourcePhase: target.phaseId,
623
+ }),
624
+ next: { taskId: values.id },
625
+ }),
626
+ ),
627
+ action(
628
+ {
629
+ ...details(
630
+ "complete",
631
+ "Complete without a pull request",
632
+ noExecution ??
633
+ (terminal
634
+ ? "Item is already terminal"
635
+ : prUrl
636
+ ? "Reconcile the recorded pull request instead"
637
+ : null),
638
+ ),
639
+ inputs: [
640
+ input("summary", "Completed outcome summary", { multiline: true }),
641
+ ],
642
+ },
643
+ "<summary>",
644
+ (p) => p.text("Completed non-PR outcome summary"),
645
+ (summary) => ({
646
+ command: [
647
+ ...statusCommand("done"),
648
+ "--no-pull-request",
649
+ "--summary",
650
+ summary,
651
+ ],
652
+ run: changeStatus("done", summary),
653
+ }),
654
+ ),
655
+ immediate(
656
+ details(
657
+ "sync",
658
+ "Refresh Agency state from GitHub / provider",
659
+ node.kind === "epic" ? "Select a task or phase" : null,
660
+ ),
661
+ {
662
+ command: ["agency", "sync", ...args],
663
+ run: sync({ ...options, ...target }),
664
+ },
665
+ ),
666
+ ...(["ready", "close"] as const).map((operation) =>
667
+ immediate(
668
+ details(
669
+ `pr-${operation}`,
670
+ operation === "ready"
671
+ ? "Mark GitHub PR ready for review"
672
+ : "Close GitHub PR",
673
+ prUrl?.startsWith("https://github.com/")
674
+ ? null
675
+ : "Requires a recorded GitHub pull request URL",
676
+ ),
677
+ {
678
+ command: ["agency", "pr", operation, prUrl ?? "<pull-request-url>"],
679
+ followUpCommands: [["agency", "sync", ...args]],
680
+ run: Effect.gen(function* () {
681
+ const code = yield* pr([operation, prUrl ?? ""], options.cwd)
682
+ if (code !== 0)
683
+ return yield* Effect.fail(
684
+ new Error(`GitHub PR ${operation} failed (${code})`),
685
+ )
686
+ yield* sync({ ...options, ...target })
687
+ }),
688
+ },
689
+ ),
690
+ ),
691
+ ]
692
+ }
693
+
694
+ export const actionOutput = (action: ActAction, auto?: boolean) => {
695
+ const { id, label, blockedReason, preview, inputs = [] } = action
696
+ return {
697
+ id,
698
+ label,
699
+ available: blockedReason === null,
700
+ blockedReason,
701
+ ...(blockedReason
702
+ ? {}
703
+ : {
704
+ inputs,
705
+ command: inputs.some((input) => input.required)
706
+ ? null
707
+ : preview.command,
708
+ ...(inputs.length ? { commandTemplate: preview.command } : {}),
709
+ ...(preview.followUpCommands
710
+ ? { followUpCommands: preview.followUpCommands }
711
+ : {}),
712
+ ...(preview.next
713
+ ? {
714
+ nextActions: [
715
+ {
716
+ id: "work",
717
+ requiresSelection: true,
718
+ commandTemplate: [
719
+ "agency",
720
+ "work",
721
+ "--task",
722
+ preview.next.taskId,
723
+ ...(preview.next.phaseId
724
+ ? ["--phase", preview.next.phaseId]
725
+ : []),
726
+ ...(auto ? ["--auto"] : []),
727
+ ],
728
+ },
729
+ ],
730
+ }
731
+ : {}),
732
+ }),
733
+ }
734
+ }