@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.
- package/README.md +40 -9
- package/cli.ts +32 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +141 -38
- package/src/cli-parser.test.ts +120 -0
- package/src/cli-parser.ts +122 -7
- package/src/cli.test.ts +71 -0
- package/src/commands/claim.ts +26 -2
- package/src/commands/phase.ts +23 -2
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +49 -7
- package/src/commands/work.test.ts +2 -0
- package/src/commands/work.ts +2 -2
- package/src/graph-schema.ts +35 -13
- package/src/protocol.ts +1 -0
- package/src/services/ArchiveService.test.ts +2 -2
- package/src/services/ArchiveService.ts +1 -0
- package/src/services/ClaimService.test.ts +59 -0
- package/src/services/ClaimService.ts +36 -2
- package/src/services/ContextService.ts +56 -4
- package/src/services/DoctorService.ts +45 -1
- package/src/services/GraphMutationService.ts +21 -0
- package/src/services/GraphService.ts +118 -54
- package/src/services/IntegrationService.test.ts +4 -1
- package/src/services/PhaseService.ts +55 -3
- package/src/services/PullRequestService.test.ts +22 -2
- package/src/services/PullRequestService.ts +44 -3
- package/src/services/ReadinessService.ts +7 -3
- package/src/services/ReviewService.test.ts +472 -0
- package/src/services/ReviewService.ts +404 -0
- package/src/services/SyncService.test.ts +68 -2
- package/src/services/SyncService.ts +123 -6
- package/src/services/TaskPhaseService.test.ts +182 -0
- package/src/services/TaskService.ts +128 -11
- package/src/services/WorkbaseService.test.ts +58 -0
- package/src/services/WorkbaseService.ts +22 -1
- package/src/services/WorktreeService.test.ts +16 -16
- package/src/services/WorktreeService.ts +76 -40
- package/src/test-utils.ts +2 -0
- package/src/work-view.ts +14 -6
- package/src/workbase/AGENTS.md +7 -2
- package/src/workbase/completion.ts +28 -0
- package/src/workbase/schemas.test.ts +57 -0
- 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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
153
|
+
"phases" in workspaceTask.data
|
|
118
154
|
? (yield* phases.show(taskId, phaseId!, workspace.root)).data
|
|
119
|
-
:
|
|
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
|
|
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
|
|
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,
|