@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
|
@@ -283,6 +283,66 @@ describe("task and phase services", () => {
|
|
|
283
283
|
})
|
|
284
284
|
})
|
|
285
285
|
|
|
286
|
+
test("preserves non-PR completion when converting a task to phases", async () => {
|
|
287
|
+
await runTestEffect(
|
|
288
|
+
TaskService.pipe(
|
|
289
|
+
Effect.flatMap((service) =>
|
|
290
|
+
service.create(
|
|
291
|
+
{
|
|
292
|
+
id: "completed",
|
|
293
|
+
ticketUrl: null,
|
|
294
|
+
repo: "agency",
|
|
295
|
+
branch: "task/completed",
|
|
296
|
+
base: "main",
|
|
297
|
+
},
|
|
298
|
+
root,
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
await runTestEffect(
|
|
304
|
+
TaskService.pipe(
|
|
305
|
+
Effect.flatMap((service) =>
|
|
306
|
+
service.setStatus("completed", "done", root, {
|
|
307
|
+
summary: "Investigation completed without changes.",
|
|
308
|
+
}),
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
await runTestEffect(
|
|
313
|
+
PhaseService.pipe(
|
|
314
|
+
Effect.flatMap((service) =>
|
|
315
|
+
service.create(
|
|
316
|
+
{
|
|
317
|
+
taskId: "completed",
|
|
318
|
+
id: "follow-up",
|
|
319
|
+
firstPhase: "investigation",
|
|
320
|
+
repo: "agency",
|
|
321
|
+
branch: "task/completed-follow-up",
|
|
322
|
+
base: "main",
|
|
323
|
+
},
|
|
324
|
+
root,
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
const firstPhase = await runTestEffect(
|
|
331
|
+
PhaseService.pipe(
|
|
332
|
+
Effect.flatMap((service) =>
|
|
333
|
+
service.show("completed", "investigation", root),
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
expect(firstPhase.data).toMatchObject({
|
|
338
|
+
status: "done",
|
|
339
|
+
completion: {
|
|
340
|
+
mode: "non-pr",
|
|
341
|
+
summary: "Investigation completed without changes.",
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
286
346
|
test("updates status on execution units", async () => {
|
|
287
347
|
const createdTask = await runTestEffect(
|
|
288
348
|
TaskService.pipe(
|
|
@@ -327,6 +387,50 @@ describe("task and phase services", () => {
|
|
|
327
387
|
),
|
|
328
388
|
),
|
|
329
389
|
).rejects.toThrow("authoritative pull request is merged")
|
|
390
|
+
const completedTask = await runTestEffect(
|
|
391
|
+
TaskService.pipe(
|
|
392
|
+
Effect.flatMap((service) =>
|
|
393
|
+
service.setStatus("single-status", "done", root, {
|
|
394
|
+
summary: "Investigation completed without repository changes.",
|
|
395
|
+
evidenceUrl: "https://example.com/investigation",
|
|
396
|
+
}),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
expect(completedTask.data).toMatchObject({
|
|
401
|
+
status: "done",
|
|
402
|
+
completion: {
|
|
403
|
+
mode: "non-pr",
|
|
404
|
+
summary: "Investigation completed without repository changes.",
|
|
405
|
+
evidenceUrl: "https://example.com/investigation",
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
expect(completedTask.data.completion?.completedAt).toMatch(
|
|
409
|
+
/^\d{4}-\d{2}-\d{2}T/,
|
|
410
|
+
)
|
|
411
|
+
await expect(
|
|
412
|
+
runTestEffect(
|
|
413
|
+
PullRequestService.pipe(
|
|
414
|
+
Effect.flatMap((service) =>
|
|
415
|
+
service.setUrl(
|
|
416
|
+
"single-status",
|
|
417
|
+
undefined,
|
|
418
|
+
"https://github.com/example/agency/pull/1",
|
|
419
|
+
root,
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
).rejects.toThrow("Reopen non-PR completed work")
|
|
425
|
+
const reopenedTask = await runTestEffect(
|
|
426
|
+
TaskService.pipe(
|
|
427
|
+
Effect.flatMap((service) =>
|
|
428
|
+
service.setStatus("single-status", "open", root),
|
|
429
|
+
),
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
expect(reopenedTask.data.status).toBe("open")
|
|
433
|
+
expect("completion" in reopenedTask.data).toBe(false)
|
|
330
434
|
const droppedTask = await runTestEffect(
|
|
331
435
|
TaskService.pipe(
|
|
332
436
|
Effect.flatMap((service) =>
|
|
@@ -335,6 +439,17 @@ describe("task and phase services", () => {
|
|
|
335
439
|
),
|
|
336
440
|
)
|
|
337
441
|
expect(droppedTask.data.status).toBe("dropped")
|
|
442
|
+
await expect(
|
|
443
|
+
runTestEffect(
|
|
444
|
+
TaskService.pipe(
|
|
445
|
+
Effect.flatMap((service) =>
|
|
446
|
+
service.setStatus("single-status", "done", root, {
|
|
447
|
+
summary: "Cannot bypass reopening.",
|
|
448
|
+
}),
|
|
449
|
+
),
|
|
450
|
+
),
|
|
451
|
+
),
|
|
452
|
+
).rejects.toThrow("reopen it first")
|
|
338
453
|
|
|
339
454
|
await runTestEffect(
|
|
340
455
|
TaskService.pipe(
|
|
@@ -407,6 +522,73 @@ describe("task and phase services", () => {
|
|
|
407
522
|
),
|
|
408
523
|
),
|
|
409
524
|
).rejects.toThrow("authoritative pull request is merged")
|
|
525
|
+
await expect(
|
|
526
|
+
runTestEffect(
|
|
527
|
+
PhaseService.pipe(
|
|
528
|
+
Effect.flatMap((service) =>
|
|
529
|
+
service.setStatus("multi-status", "implementation", "done", root, {
|
|
530
|
+
summary: " ",
|
|
531
|
+
}),
|
|
532
|
+
),
|
|
533
|
+
),
|
|
534
|
+
),
|
|
535
|
+
).rejects.toThrow("summary must not be empty")
|
|
536
|
+
const completedPhase = await runTestEffect(
|
|
537
|
+
PhaseService.pipe(
|
|
538
|
+
Effect.flatMap((service) =>
|
|
539
|
+
service.setStatus("multi-status", "implementation", "done", root, {
|
|
540
|
+
summary: "Operational work completed outside the repository.",
|
|
541
|
+
}),
|
|
542
|
+
),
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
expect(completedPhase.data).toMatchObject({
|
|
546
|
+
status: "done",
|
|
547
|
+
completion: {
|
|
548
|
+
mode: "non-pr",
|
|
549
|
+
summary: "Operational work completed outside the repository.",
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
await runTestEffect(
|
|
554
|
+
TaskService.pipe(
|
|
555
|
+
Effect.flatMap((service) =>
|
|
556
|
+
service.create(
|
|
557
|
+
{
|
|
558
|
+
id: "recorded-pr",
|
|
559
|
+
ticketUrl: null,
|
|
560
|
+
repo: "agency",
|
|
561
|
+
branch: "task/recorded-pr",
|
|
562
|
+
base: "main",
|
|
563
|
+
},
|
|
564
|
+
root,
|
|
565
|
+
),
|
|
566
|
+
),
|
|
567
|
+
),
|
|
568
|
+
)
|
|
569
|
+
await runTestEffect(
|
|
570
|
+
PullRequestService.pipe(
|
|
571
|
+
Effect.flatMap((service) =>
|
|
572
|
+
service.setUrl(
|
|
573
|
+
"recorded-pr",
|
|
574
|
+
undefined,
|
|
575
|
+
"https://github.com/example/agency/pull/1",
|
|
576
|
+
root,
|
|
577
|
+
),
|
|
578
|
+
),
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
await expect(
|
|
582
|
+
runTestEffect(
|
|
583
|
+
TaskService.pipe(
|
|
584
|
+
Effect.flatMap((service) =>
|
|
585
|
+
service.setStatus("recorded-pr", "done", root, {
|
|
586
|
+
summary: "Attempted bypass",
|
|
587
|
+
}),
|
|
588
|
+
),
|
|
589
|
+
),
|
|
590
|
+
),
|
|
591
|
+
).rejects.toThrow("authoritative pull request is recorded")
|
|
410
592
|
await expect(
|
|
411
593
|
runTestEffect(
|
|
412
594
|
TaskService.pipe(
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
EntityId,
|
|
9
9
|
TaskFrontmatter,
|
|
10
10
|
type RepositoryReference,
|
|
11
|
+
type ReviewRecord,
|
|
11
12
|
type TaskFrontmatter as TaskData,
|
|
12
13
|
WorkStatus,
|
|
13
14
|
} from "../workbase/schemas"
|
|
@@ -19,9 +20,14 @@ import {
|
|
|
19
20
|
import { canTransitionStatus } from "../readiness"
|
|
20
21
|
import { documentRevision } from "../workbase/document-revision"
|
|
21
22
|
import { archivedTaskDirectory } from "../workbase/archive"
|
|
23
|
+
import {
|
|
24
|
+
buildNonPrCompletion,
|
|
25
|
+
type NonPrCompletionInput,
|
|
26
|
+
} from "../workbase/completion"
|
|
22
27
|
import {
|
|
23
28
|
documentWriteStep,
|
|
24
29
|
runLifecycleTransaction,
|
|
30
|
+
type TransactionStep,
|
|
25
31
|
} from "./LifecycleTransaction"
|
|
26
32
|
|
|
27
33
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
@@ -46,6 +52,7 @@ export interface CreateTaskInput {
|
|
|
46
52
|
readonly repos?: readonly RepositoryReference[]
|
|
47
53
|
readonly branch?: string
|
|
48
54
|
readonly base?: string
|
|
55
|
+
readonly review?: ReviewRecord
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
const decodeTask = (input: unknown) => {
|
|
@@ -74,6 +81,50 @@ const decodeStatus = (status: string) => {
|
|
|
74
81
|
: Effect.succeed(result.right)
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
const reviewPinRef = (taskId: string) =>
|
|
85
|
+
`refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
|
|
86
|
+
|
|
87
|
+
const reviewPinStep = (
|
|
88
|
+
root: string,
|
|
89
|
+
taskId: string,
|
|
90
|
+
review: ReviewRecord,
|
|
91
|
+
): TransactionStep => {
|
|
92
|
+
const repositoryPath = join(root, "repos", review.repo)
|
|
93
|
+
const ref = reviewPinRef(taskId)
|
|
94
|
+
const run = async (args: readonly string[]) => {
|
|
95
|
+
const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
96
|
+
const [exitCode, stderr] = await Promise.all([
|
|
97
|
+
child.exited,
|
|
98
|
+
new Response(child.stderr).text(),
|
|
99
|
+
])
|
|
100
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
label: `retain review pin for ${taskId}`,
|
|
104
|
+
apply: () =>
|
|
105
|
+
run([
|
|
106
|
+
"git",
|
|
107
|
+
"-C",
|
|
108
|
+
repositoryPath,
|
|
109
|
+
"update-ref",
|
|
110
|
+
ref,
|
|
111
|
+
review.commit,
|
|
112
|
+
"0".repeat(40),
|
|
113
|
+
]),
|
|
114
|
+
rollback: () =>
|
|
115
|
+
run([
|
|
116
|
+
"git",
|
|
117
|
+
"-C",
|
|
118
|
+
repositoryPath,
|
|
119
|
+
"update-ref",
|
|
120
|
+
"-d",
|
|
121
|
+
ref,
|
|
122
|
+
review.commit,
|
|
123
|
+
]),
|
|
124
|
+
manualRecovery: `Delete ${ref} from repository '${review.repo}'`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
77
128
|
export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
78
129
|
sync: () => ({
|
|
79
130
|
create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
|
|
@@ -98,7 +149,28 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
98
149
|
}
|
|
99
150
|
|
|
100
151
|
let data: TaskData
|
|
101
|
-
if (input.
|
|
152
|
+
if (input.review) {
|
|
153
|
+
if (
|
|
154
|
+
input.multiPhase ||
|
|
155
|
+
input.repo !== undefined ||
|
|
156
|
+
input.repos !== undefined ||
|
|
157
|
+
input.branch !== undefined ||
|
|
158
|
+
input.base !== undefined
|
|
159
|
+
) {
|
|
160
|
+
return yield* new TaskError({
|
|
161
|
+
message:
|
|
162
|
+
"Review tasks cannot include writable or multi-phase fields",
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
data = yield* decodeTask({
|
|
166
|
+
ticketUrl: input.ticketUrl,
|
|
167
|
+
...(input.description !== undefined
|
|
168
|
+
? { description: input.description }
|
|
169
|
+
: {}),
|
|
170
|
+
...(input.epic ? { epic: input.epic } : {}),
|
|
171
|
+
review: input.review,
|
|
172
|
+
})
|
|
173
|
+
} else if (input.multiPhase) {
|
|
102
174
|
data = yield* decodeTask({
|
|
103
175
|
ticketUrl: input.ticketUrl,
|
|
104
176
|
...(input.description !== undefined
|
|
@@ -128,12 +200,14 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
128
200
|
}
|
|
129
201
|
|
|
130
202
|
const referencedRepos =
|
|
131
|
-
"
|
|
132
|
-
? [
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
203
|
+
"review" in data
|
|
204
|
+
? [data.review.repo]
|
|
205
|
+
: "repo" in data
|
|
206
|
+
? [
|
|
207
|
+
data.repo,
|
|
208
|
+
...(data.repos ?? []).map((reference) => reference.repo),
|
|
209
|
+
]
|
|
210
|
+
: []
|
|
137
211
|
if (new Set(referencedRepos).size !== referencedRepos.length) {
|
|
138
212
|
return yield* new TaskError({
|
|
139
213
|
message:
|
|
@@ -188,7 +262,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
188
262
|
preconditions: parentEpic
|
|
189
263
|
? [{ path: parentEpic.path, revision: parentEpic.revision }]
|
|
190
264
|
: [],
|
|
191
|
-
steps: [
|
|
265
|
+
steps: [
|
|
266
|
+
...(input.review ? [reviewPinStep(root, id, input.review)] : []),
|
|
267
|
+
documentWriteStep(root, writes),
|
|
268
|
+
],
|
|
192
269
|
})
|
|
193
270
|
|
|
194
271
|
return {
|
|
@@ -247,6 +324,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
247
324
|
id: string,
|
|
248
325
|
status: string,
|
|
249
326
|
startPath: string = process.cwd(),
|
|
327
|
+
nonPrCompletion?: NonPrCompletionInput,
|
|
250
328
|
) =>
|
|
251
329
|
Effect.gen(function* () {
|
|
252
330
|
const fs = yield* FileSystemService
|
|
@@ -269,11 +347,41 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
269
347
|
message: `Task '${id}' has an active claim; use agency release or agency finish`,
|
|
270
348
|
})
|
|
271
349
|
}
|
|
272
|
-
if (
|
|
350
|
+
if (nonPrCompletion && validStatus !== "done") {
|
|
351
|
+
return yield* new TaskError({
|
|
352
|
+
message: "Non-PR completion is valid only with a done status",
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
if (nonPrCompletion && "pr" in record.data && record.data.pr !== null) {
|
|
356
|
+
return yield* new TaskError({
|
|
357
|
+
message:
|
|
358
|
+
"Cannot complete without a pull request while an authoritative pull request is recorded",
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
const completionResult = nonPrCompletion
|
|
362
|
+
? buildNonPrCompletion(nonPrCompletion, new Date())
|
|
363
|
+
: undefined
|
|
364
|
+
if (completionResult && "error" in completionResult) {
|
|
365
|
+
return yield* new TaskError({ message: completionResult.error })
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
completionResult &&
|
|
369
|
+
record.data.status !== "open" &&
|
|
370
|
+
record.data.status !== "working" &&
|
|
371
|
+
record.data.status !== "delegated"
|
|
372
|
+
) {
|
|
373
|
+
return yield* new TaskError({
|
|
374
|
+
message: `Cannot transition task '${id}' from ${record.data.status} to done; reopen it first`,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
if (
|
|
378
|
+
!canTransitionStatus(record.data.status, validStatus) &&
|
|
379
|
+
!completionResult
|
|
380
|
+
) {
|
|
273
381
|
if (validStatus === "done") {
|
|
274
382
|
return yield* new TaskError({
|
|
275
383
|
message:
|
|
276
|
-
"Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
|
|
384
|
+
"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>'",
|
|
277
385
|
})
|
|
278
386
|
}
|
|
279
387
|
return yield* new TaskError({
|
|
@@ -281,7 +389,16 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
281
389
|
})
|
|
282
390
|
}
|
|
283
391
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
284
|
-
const
|
|
392
|
+
const { completion: _, ...withoutCompletion } = record.data
|
|
393
|
+
const data: TaskData = completionResult
|
|
394
|
+
? {
|
|
395
|
+
...record.data,
|
|
396
|
+
status: "done",
|
|
397
|
+
completion: completionResult.value,
|
|
398
|
+
}
|
|
399
|
+
: validStatus === "open"
|
|
400
|
+
? { ...withoutCompletion, status: validStatus }
|
|
401
|
+
: { ...record.data, status: validStatus }
|
|
285
402
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
286
403
|
yield* fs.writeFile(record.path, content)
|
|
287
404
|
return {
|
|
@@ -73,6 +73,64 @@ pr: null
|
|
|
73
73
|
expect(report.issues).toEqual([])
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
+
test("validates non-PR completion invariants without rejecting legacy done work", async () => {
|
|
77
|
+
await write(
|
|
78
|
+
root,
|
|
79
|
+
"agency.json",
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
version: 2,
|
|
82
|
+
repositories: {
|
|
83
|
+
agency: { remote: "https://example.com/agency.git" },
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
await write(
|
|
88
|
+
root,
|
|
89
|
+
"tasks/invalid/TASK.md",
|
|
90
|
+
`---
|
|
91
|
+
ticketUrl: null
|
|
92
|
+
repo: agency
|
|
93
|
+
branch: task/invalid
|
|
94
|
+
base: main
|
|
95
|
+
pr: https://github.com/example/agency/pull/1
|
|
96
|
+
status: working
|
|
97
|
+
completion:
|
|
98
|
+
mode: non-pr
|
|
99
|
+
completedAt: 2026-07-23T18:00:00.000Z
|
|
100
|
+
summary: Investigation completed.
|
|
101
|
+
---
|
|
102
|
+
`,
|
|
103
|
+
)
|
|
104
|
+
await write(
|
|
105
|
+
root,
|
|
106
|
+
"tasks/legacy/TASK.md",
|
|
107
|
+
`---
|
|
108
|
+
ticketUrl: null
|
|
109
|
+
repo: agency
|
|
110
|
+
branch: task/legacy
|
|
111
|
+
base: main
|
|
112
|
+
pr: null
|
|
113
|
+
status: done
|
|
114
|
+
---
|
|
115
|
+
`,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const report = await runTestEffect(
|
|
119
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
120
|
+
)
|
|
121
|
+
expect(report.issues).toContainEqual({
|
|
122
|
+
path: "tasks/invalid/TASK.md",
|
|
123
|
+
message: "Non-PR completion requires status 'done'",
|
|
124
|
+
})
|
|
125
|
+
expect(report.issues).toContainEqual({
|
|
126
|
+
path: "tasks/invalid/TASK.md",
|
|
127
|
+
message: "Non-PR completion cannot have a recorded pull request",
|
|
128
|
+
})
|
|
129
|
+
expect(
|
|
130
|
+
report.issues.some((issue) => issue.path === "tasks/legacy/TASK.md"),
|
|
131
|
+
).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
76
134
|
test("registers canonical workbase paths without duplicates", async () => {
|
|
77
135
|
const workbaseRoot = join(root, "workbase")
|
|
78
136
|
const nested = join(workbaseRoot, "nested")
|
|
@@ -712,7 +712,8 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
712
712
|
)
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
-
const
|
|
715
|
+
const reviewAlias = "review" in data ? data.review.repo : undefined
|
|
716
|
+
const all = [writable, reviewAlias, ...referenceAliases].filter(
|
|
716
717
|
(alias): alias is string => alias !== undefined,
|
|
717
718
|
)
|
|
718
719
|
for (const alias of new Set(all)) {
|
|
@@ -746,6 +747,24 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
746
747
|
}
|
|
747
748
|
}
|
|
748
749
|
|
|
750
|
+
const validateCompletion = (
|
|
751
|
+
record: DocumentRecord<TaskData | PhaseData>,
|
|
752
|
+
) => {
|
|
753
|
+
if (!("repo" in record.data) || !record.data.completion) return
|
|
754
|
+
if (record.data.status !== "done") {
|
|
755
|
+
issue(record.path, "Non-PR completion requires status 'done'")
|
|
756
|
+
}
|
|
757
|
+
if (record.data.pr !== null) {
|
|
758
|
+
issue(
|
|
759
|
+
record.path,
|
|
760
|
+
"Non-PR completion cannot have a recorded pull request",
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
if (record.data.claim?.state === "active") {
|
|
764
|
+
issue(record.path, "Completed work cannot have an active claim")
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
749
768
|
for (const epic of epics.values()) {
|
|
750
769
|
validateRepositories(epic)
|
|
751
770
|
const ids = new Set(epic.data.tasks.map((task) => task.id))
|
|
@@ -776,6 +795,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
776
795
|
for (const task of tasks.values()) {
|
|
777
796
|
validateRepositories(task)
|
|
778
797
|
validateBranchOwnership(task)
|
|
798
|
+
validateCompletion(task)
|
|
779
799
|
if (task.data.epic) {
|
|
780
800
|
const parent = epics.get(task.data.epic)
|
|
781
801
|
if (!parent) {
|
|
@@ -828,6 +848,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
828
848
|
for (const phase of phases.values()) {
|
|
829
849
|
validateRepositories(phase)
|
|
830
850
|
validateBranchOwnership(phase)
|
|
851
|
+
validateCompletion(phase)
|
|
831
852
|
}
|
|
832
853
|
|
|
833
854
|
issues.sort((a, b) =>
|
|
@@ -81,13 +81,13 @@ describe("WorktreeService", () => {
|
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
expect(
|
|
84
|
-
await Bun.file(join(workspace.writablePath
|
|
84
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).text(),
|
|
85
85
|
).toBe("example\n")
|
|
86
86
|
expect(
|
|
87
87
|
await Bun.file(join(workspace.codePath, "effect/README.md")).text(),
|
|
88
88
|
).toBe("example\n")
|
|
89
89
|
const branch = Bun.spawnSync(
|
|
90
|
-
["git", "-C", workspace.writablePath
|
|
90
|
+
["git", "-C", workspace.writablePath!, "branch", "--show-current"],
|
|
91
91
|
{ stdout: "pipe" },
|
|
92
92
|
)
|
|
93
93
|
expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
|
|
@@ -272,7 +272,7 @@ describe("WorktreeService", () => {
|
|
|
272
272
|
),
|
|
273
273
|
)
|
|
274
274
|
expect(
|
|
275
|
-
await Bun.file(join(workspace.writablePath
|
|
275
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).text(),
|
|
276
276
|
).toBe("example\n")
|
|
277
277
|
})
|
|
278
278
|
|
|
@@ -599,7 +599,7 @@ pr: null
|
|
|
599
599
|
|
|
600
600
|
expect(
|
|
601
601
|
await Bun.file(
|
|
602
|
-
join(originalWorkspace.writablePath
|
|
602
|
+
join(originalWorkspace.writablePath!, "README.md"),
|
|
603
603
|
).exists(),
|
|
604
604
|
).toBe(false)
|
|
605
605
|
const movedWorkspace = await runTestEffect(
|
|
@@ -613,10 +613,10 @@ pr: null
|
|
|
613
613
|
join(root, "tasks/promoted/phases/implementation/code/agency"),
|
|
614
614
|
)
|
|
615
615
|
expect(
|
|
616
|
-
await Bun.file(join(movedWorkspace.writablePath
|
|
616
|
+
await Bun.file(join(movedWorkspace.writablePath!, "README.md")).text(),
|
|
617
617
|
).toBe("example\n")
|
|
618
618
|
const status = Bun.spawnSync(
|
|
619
|
-
["git", "-C", movedWorkspace.writablePath
|
|
619
|
+
["git", "-C", movedWorkspace.writablePath!, "status", "--porcelain"],
|
|
620
620
|
{ stdout: "pipe", stderr: "pipe" },
|
|
621
621
|
)
|
|
622
622
|
expect(status.exitCode).toBe(0)
|
|
@@ -1072,7 +1072,7 @@ pr: null
|
|
|
1072
1072
|
),
|
|
1073
1073
|
)
|
|
1074
1074
|
await Bun.write(
|
|
1075
|
-
join(workspace.writablePath
|
|
1075
|
+
join(workspace.writablePath!, "uncommitted.txt"),
|
|
1076
1076
|
"keep me\n",
|
|
1077
1077
|
)
|
|
1078
1078
|
|
|
@@ -1084,7 +1084,7 @@ pr: null
|
|
|
1084
1084
|
),
|
|
1085
1085
|
).rejects.toThrow("Failed to remove worktree for 'agency'")
|
|
1086
1086
|
expect(
|
|
1087
|
-
await Bun.file(join(workspace.writablePath
|
|
1087
|
+
await Bun.file(join(workspace.writablePath!, "uncommitted.txt")).text(),
|
|
1088
1088
|
).toBe("keep me\n")
|
|
1089
1089
|
})
|
|
1090
1090
|
|
|
@@ -1131,7 +1131,7 @@ pr: null
|
|
|
1131
1131
|
"--porcelain",
|
|
1132
1132
|
])
|
|
1133
1133
|
expect(new TextDecoder().decode(worktrees.stdout)).not.toContain(
|
|
1134
|
-
workspace.writablePath
|
|
1134
|
+
workspace.writablePath!,
|
|
1135
1135
|
)
|
|
1136
1136
|
expect(
|
|
1137
1137
|
Bun.spawnSync([
|
|
@@ -1325,7 +1325,7 @@ pr: null
|
|
|
1325
1325
|
),
|
|
1326
1326
|
).rejects.toThrow("multiple Agency owners")
|
|
1327
1327
|
expect(
|
|
1328
|
-
await Bun.file(join(workspace.writablePath
|
|
1328
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1329
1329
|
).toBe(true)
|
|
1330
1330
|
})
|
|
1331
1331
|
|
|
@@ -1371,7 +1371,7 @@ pr: null
|
|
|
1371
1371
|
]),
|
|
1372
1372
|
)
|
|
1373
1373
|
expect(
|
|
1374
|
-
await Bun.file(join(original.writablePath
|
|
1374
|
+
await Bun.file(join(original.writablePath!, "README.md")).exists(),
|
|
1375
1375
|
).toBe(true)
|
|
1376
1376
|
|
|
1377
1377
|
const rebuilt = await runTestEffect(
|
|
@@ -1383,7 +1383,7 @@ pr: null
|
|
|
1383
1383
|
)
|
|
1384
1384
|
expect(rebuilt.inspection.conflicts).toEqual([])
|
|
1385
1385
|
expect(
|
|
1386
|
-
await Bun.file(join(original.writablePath
|
|
1386
|
+
await Bun.file(join(original.writablePath!, "README.md")).exists(),
|
|
1387
1387
|
).toBe(true)
|
|
1388
1388
|
})
|
|
1389
1389
|
|
|
@@ -1435,7 +1435,7 @@ pr: null
|
|
|
1435
1435
|
),
|
|
1436
1436
|
).rejects.toThrow("original worktrees were restored")
|
|
1437
1437
|
expect(
|
|
1438
|
-
await Bun.file(join(workspace.writablePath
|
|
1438
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1439
1439
|
).toBe(true)
|
|
1440
1440
|
})
|
|
1441
1441
|
|
|
@@ -1473,7 +1473,7 @@ pr: null
|
|
|
1473
1473
|
),
|
|
1474
1474
|
)
|
|
1475
1475
|
expect(plan.actions.join("\n")).toContain("worktree prune --expire now")
|
|
1476
|
-
expect(plan.actions).toContain(`prepare ${workspace.writablePath}`)
|
|
1476
|
+
expect(plan.actions).toContain(`prepare ${workspace.writablePath!}`)
|
|
1477
1477
|
|
|
1478
1478
|
const repaired = await runTestEffect(
|
|
1479
1479
|
WorktreeService.pipe(
|
|
@@ -1484,7 +1484,7 @@ pr: null
|
|
|
1484
1484
|
)
|
|
1485
1485
|
expect(repaired.inspection.conflicts).toEqual([])
|
|
1486
1486
|
expect(
|
|
1487
|
-
await Bun.file(join(workspace.writablePath
|
|
1487
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1488
1488
|
).toBe(true)
|
|
1489
1489
|
expect(
|
|
1490
1490
|
Bun.spawnSync([
|
|
@@ -1648,7 +1648,7 @@ pr: null
|
|
|
1648
1648
|
),
|
|
1649
1649
|
)
|
|
1650
1650
|
expect(
|
|
1651
|
-
await Bun.file(join(workspace.writablePath
|
|
1651
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).text(),
|
|
1652
1652
|
).toBe("example\n")
|
|
1653
1653
|
})
|
|
1654
1654
|
})
|