@markjaquith/agency 2.48.1 → 2.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +40 -9
  2. package/cli.ts +32 -0
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +141 -38
  5. package/src/cli-parser.test.ts +120 -0
  6. package/src/cli-parser.ts +122 -7
  7. package/src/cli.test.ts +71 -0
  8. package/src/commands/claim.ts +26 -2
  9. package/src/commands/phase.ts +23 -2
  10. package/src/commands/review.ts +38 -0
  11. package/src/commands/task.ts +49 -7
  12. package/src/commands/work.test.ts +2 -0
  13. package/src/commands/work.ts +2 -2
  14. package/src/graph-schema.ts +35 -13
  15. package/src/protocol.ts +1 -0
  16. package/src/services/ArchiveService.test.ts +2 -2
  17. package/src/services/ArchiveService.ts +1 -0
  18. package/src/services/ClaimService.test.ts +59 -0
  19. package/src/services/ClaimService.ts +36 -2
  20. package/src/services/ContextService.ts +56 -4
  21. package/src/services/DoctorService.ts +45 -1
  22. package/src/services/GraphMutationService.ts +21 -0
  23. package/src/services/GraphService.ts +118 -54
  24. package/src/services/IntegrationService.test.ts +4 -1
  25. package/src/services/PhaseService.ts +55 -3
  26. package/src/services/PullRequestService.test.ts +22 -2
  27. package/src/services/PullRequestService.ts +44 -3
  28. package/src/services/ReadinessService.ts +7 -3
  29. package/src/services/ReviewService.test.ts +472 -0
  30. package/src/services/ReviewService.ts +404 -0
  31. package/src/services/SyncService.test.ts +68 -2
  32. package/src/services/SyncService.ts +123 -6
  33. package/src/services/TaskPhaseService.test.ts +182 -0
  34. package/src/services/TaskService.ts +128 -11
  35. package/src/services/WorkbaseService.test.ts +58 -0
  36. package/src/services/WorkbaseService.ts +22 -1
  37. package/src/services/WorktreeService.test.ts +16 -16
  38. package/src/services/WorktreeService.ts +76 -40
  39. package/src/test-utils.ts +2 -0
  40. package/src/work-view.ts +14 -6
  41. package/src/workbase/AGENTS.md +7 -2
  42. package/src/workbase/completion.ts +28 -0
  43. package/src/workbase/schemas.test.ts +57 -0
  44. package/src/workbase/schemas.ts +65 -0
@@ -26,6 +26,10 @@ import {
26
26
  type TransactionStep,
27
27
  } from "./LifecycleTransaction"
28
28
  import { withWorktreeLocks } from "./WorktreeLock"
29
+ import {
30
+ buildNonPrCompletion,
31
+ type NonPrCompletionInput,
32
+ } from "../workbase/completion"
29
33
 
30
34
  class PhaseError extends Data.TaggedError("PhaseError")<{
31
35
  readonly message: string
@@ -93,6 +97,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
93
97
  const taskId = yield* decodeId(input.taskId, "task")
94
98
  const id = yield* decodeId(input.id, "phase")
95
99
  const task = yield* tasks.show(taskId, root)
100
+ if ("review" in task.data) {
101
+ return yield* new PhaseError({
102
+ message: `Review task '${taskId}' cannot be converted to phases`,
103
+ })
104
+ }
96
105
  const isMultiPhase = "phases" in task.data
97
106
  let firstPhaseId: string | undefined
98
107
  if (!isMultiPhase) {
@@ -221,6 +230,9 @@ export class PhaseService extends Effect.Service<PhaseService>()(
221
230
  pr: task.data.pr,
222
231
  status: task.data.status,
223
232
  ...(task.data.claim ? { claim: task.data.claim } : {}),
233
+ ...(task.data.completion
234
+ ? { completion: task.data.completion }
235
+ : {}),
224
236
  })
225
237
  const firstTitle = firstPhaseId!
226
238
  .split("-")
@@ -469,6 +481,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
469
481
  id: string,
470
482
  status: string,
471
483
  startPath: string = process.cwd(),
484
+ nonPrCompletion?: NonPrCompletionInput,
472
485
  ) =>
473
486
  Effect.gen(function* () {
474
487
  const fs = yield* FileSystemService
@@ -486,11 +499,41 @@ export class PhaseService extends Effect.Service<PhaseService>()(
486
499
  message: `Phase '${id}' has an active claim; use agency release or agency finish`,
487
500
  })
488
501
  }
489
- if (!canTransitionStatus(record.data.status, validStatus)) {
502
+ if (nonPrCompletion && validStatus !== "done") {
503
+ return yield* new PhaseError({
504
+ message: "Non-PR completion is valid only with a done status",
505
+ })
506
+ }
507
+ if (nonPrCompletion && record.data.pr !== null) {
508
+ return yield* new PhaseError({
509
+ message:
510
+ "Cannot complete without a pull request while an authoritative pull request is recorded",
511
+ })
512
+ }
513
+ const completionResult = nonPrCompletion
514
+ ? buildNonPrCompletion(nonPrCompletion, new Date())
515
+ : undefined
516
+ if (completionResult && "error" in completionResult) {
517
+ return yield* new PhaseError({ message: completionResult.error })
518
+ }
519
+ if (
520
+ completionResult &&
521
+ record.data.status !== "open" &&
522
+ record.data.status !== "working" &&
523
+ record.data.status !== "delegated"
524
+ ) {
525
+ return yield* new PhaseError({
526
+ message: `Cannot transition phase '${id}' from ${record.data.status} to done; reopen it first`,
527
+ })
528
+ }
529
+ if (
530
+ !canTransitionStatus(record.data.status, validStatus) &&
531
+ !completionResult
532
+ ) {
490
533
  if (validStatus === "done") {
491
534
  return yield* new PhaseError({
492
535
  message:
493
- "Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
536
+ "Work becomes done after its authoritative pull request is merged; run 'agency sync --apply', or explicitly complete a non-PR outcome with '--no-pull-request --summary <text>'",
494
537
  })
495
538
  }
496
539
  return yield* new PhaseError({
@@ -498,7 +541,16 @@ export class PhaseService extends Effect.Service<PhaseService>()(
498
541
  })
499
542
  }
500
543
  const parsed = yield* parseFrontmatter(record.content, record.path)
501
- const data = { ...record.data, status: validStatus }
544
+ const { completion: _, ...withoutCompletion } = record.data
545
+ const data: PhaseData = completionResult
546
+ ? {
547
+ ...record.data,
548
+ status: "done",
549
+ completion: completionResult.value,
550
+ }
551
+ : validStatus === "open"
552
+ ? { ...withoutCompletion, status: validStatus }
553
+ : { ...record.data, status: validStatus }
502
554
  const content = formatMarkdownDocument(data, parsed.body)
503
555
  yield* fs.writeFile(record.path, content)
504
556
  return {
@@ -279,6 +279,26 @@ process.exit(${exitCode})
279
279
  expect(await createPullRequest("example", undefined, false, true)).toBe(url)
280
280
  })
281
281
 
282
+ test("requires reopening non-PR completed work even when forced", async () => {
283
+ await createTask()
284
+ await runTestEffect(
285
+ TaskService.pipe(
286
+ Effect.flatMap((service) =>
287
+ service.setStatus("example", "done", root, {
288
+ summary: "Investigation completed without changes.",
289
+ }),
290
+ ),
291
+ ),
292
+ )
293
+
294
+ await expect(
295
+ createPullRequest("example", undefined, false, true),
296
+ ).rejects.toThrow("Reopen non-PR completed work")
297
+ expect(
298
+ await Bun.file(join(root, "tasks/example/code/agency")).exists(),
299
+ ).toBe(false)
300
+ })
301
+
282
302
  test("updates only PHASE.md for a phase PR", async () => {
283
303
  await runTestEffect(
284
304
  TaskService.pipe(
@@ -333,7 +353,7 @@ process.exit(${exitCode})
333
353
  test("blocks a dirty checkout before push or gh", async () => {
334
354
  await createTask()
335
355
  const workspace = await materialize()
336
- await Bun.write(join(workspace.writablePath, "dirty.txt"), "dirty\n")
356
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "dirty\n")
337
357
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
338
358
 
339
359
  await expect(createPullRequest()).rejects.toThrow(
@@ -498,7 +518,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(providerRecord))})
498
518
  test("reports git status failure before push or gh", async () => {
499
519
  await createTask()
500
520
  const workspace = await materialize()
501
- await rm(join(workspace.writablePath, ".git"))
521
+ await rm(join(workspace.writablePath!, ".git"))
502
522
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/48" })
503
523
 
504
524
  await expect(createPullRequest()).rejects.toThrow(
@@ -50,6 +50,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
50
50
  const phases = yield* PhaseService
51
51
  const root = yield* workbase.discover(startPath)
52
52
  const task = yield* tasks.show(taskId, root)
53
+ if ("review" in task.data) {
54
+ return yield* new PullRequestError({
55
+ message: `Review task '${taskId}' cannot record a delivery pull request`,
56
+ })
57
+ }
53
58
  const target =
54
59
  "phases" in task.data
55
60
  ? phaseId
@@ -58,6 +63,12 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
58
63
  message: `Task '${taskId}' requires a phase ID`,
59
64
  })
60
65
  : task
66
+ if ("completion" in target.data && target.data.completion) {
67
+ return yield* new PullRequestError({
68
+ message:
69
+ "Reopen non-PR completed work before recording a pull request",
70
+ })
71
+ }
61
72
  const parsed = yield* parseFrontmatter(target.content, target.path)
62
73
  yield* fs.writeFile(
63
74
  target.path,
@@ -99,6 +110,26 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
99
110
  const worktrees = yield* WorktreeService
100
111
  const readiness = yield* ReadinessService
101
112
  const workbase = yield* WorkbaseService
113
+ const requestedTask = yield* tasks.show(taskId, startPath)
114
+ if ("review" in requestedTask.data) {
115
+ return yield* new PullRequestError({
116
+ message: `Review task '${taskId}' cannot create a delivery pull request`,
117
+ })
118
+ }
119
+ const target =
120
+ "phases" in requestedTask.data
121
+ ? phaseId
122
+ ? yield* phases.show(taskId, phaseId, startPath)
123
+ : yield* new PullRequestError({
124
+ message: `Task '${taskId}' requires a phase ID`,
125
+ })
126
+ : requestedTask
127
+ if ("completion" in target.data && target.data.completion) {
128
+ return yield* new PullRequestError({
129
+ message:
130
+ "Reopen non-PR completed work before creating a pull request",
131
+ })
132
+ }
102
133
  yield* readiness.guard(
103
134
  "pr",
104
135
  taskId,
@@ -112,12 +143,22 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
112
143
  startPath,
113
144
  options,
114
145
  )
115
- const task = yield* tasks.show(taskId, workspace.root)
146
+ const workspaceTask = yield* tasks.show(taskId, workspace.root)
147
+ if ("review" in workspaceTask.data) {
148
+ return yield* new PullRequestError({
149
+ message: `Review task '${taskId}' cannot create a delivery pull request`,
150
+ })
151
+ }
116
152
  const execution =
117
- "phases" in task.data
153
+ "phases" in workspaceTask.data
118
154
  ? (yield* phases.show(taskId, phaseId!, workspace.root)).data
119
- : task.data
155
+ : workspaceTask.data
120
156
  const { config } = yield* workbase.loadConfig(workspace.root)
157
+ if (!workspace.writablePath) {
158
+ return yield* new PullRequestError({
159
+ message: `Task '${taskId}' has no writable checkout`,
160
+ })
161
+ }
121
162
  const remote = config.delivery?.remote ?? "origin"
122
163
 
123
164
  const status = yield* fs.runCommand(
@@ -77,7 +77,7 @@ const itemFor = (
77
77
  ? task.data.epic
78
78
  : undefined
79
79
  const dependentIds = new Set(node.dependents)
80
- if (node.data.phaseId) {
80
+ if ("phaseId" in node.data && node.data.phaseId) {
81
81
  const siblings = graph.nodes.filter(
82
82
  (candidate): candidate is ExecutionNode =>
83
83
  candidate.kind === "execution-unit" &&
@@ -93,10 +93,14 @@ const itemFor = (
93
93
  rank,
94
94
  key: node.key,
95
95
  taskId: node.data.taskId,
96
- ...(node.data.phaseId ? { phaseId: node.data.phaseId } : {}),
96
+ ...("phaseId" in node.data && node.data.phaseId
97
+ ? { phaseId: node.data.phaseId }
98
+ : {}),
97
99
  ...(node.data.description ? { description: node.data.description } : {}),
98
100
  parent: {
99
- ...(node.data.phaseId ? { taskId: node.data.taskId } : {}),
101
+ ...("phaseId" in node.data && node.data.phaseId
102
+ ? { taskId: node.data.taskId }
103
+ : {}),
100
104
  ...(epicId ? { epicId } : {}),
101
105
  },
102
106
  status: node.status,