@markjaquith/agency 2.48.0 → 2.49.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 +22 -19
- package/cli.ts +9 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +58 -0
- package/src/cli-parser.ts +67 -6
- package/src/cli.test.ts +82 -3
- package/src/commands/claim.ts +26 -2
- package/src/commands/init.test.ts +3 -5
- package/src/commands/integration.test.ts +1 -16
- package/src/commands/integration.ts +0 -2
- package/src/commands/phase.ts +23 -2
- package/src/commands/task.ts +22 -2
- package/src/commands/work.test.ts +38 -4
- package/src/commands/work.ts +1 -1
- package/src/services/ClaimService.test.ts +59 -0
- package/src/services/ClaimService.ts +34 -1
- package/src/services/GraphMutationService.ts +16 -0
- package/src/services/IntegrationService.test.ts +32 -75
- package/src/services/IntegrationService.ts +27 -52
- package/src/services/PhaseService.ts +50 -3
- package/src/services/PullRequestService.test.ts +20 -0
- package/src/services/PullRequestService.ts +24 -3
- package/src/services/SyncService.test.ts +66 -0
- package/src/services/SyncService.ts +13 -0
- package/src/services/TaskPhaseService.test.ts +182 -0
- package/src/services/TaskService.ts +47 -3
- package/src/services/WorkbaseService.test.ts +58 -0
- package/src/services/WorkbaseService.ts +20 -0
- package/src/workbase/AGENTS.md +13 -8
- package/src/workbase/completion.ts +28 -0
- package/src/workbase/schemas.ts +9 -0
- package/src/workbase/AGENCY_COMMAND.md +0 -48
- package/src/workbase/opencode-command-file.ts +0 -30
|
@@ -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
|
|
@@ -221,6 +225,9 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
221
225
|
pr: task.data.pr,
|
|
222
226
|
status: task.data.status,
|
|
223
227
|
...(task.data.claim ? { claim: task.data.claim } : {}),
|
|
228
|
+
...(task.data.completion
|
|
229
|
+
? { completion: task.data.completion }
|
|
230
|
+
: {}),
|
|
224
231
|
})
|
|
225
232
|
const firstTitle = firstPhaseId!
|
|
226
233
|
.split("-")
|
|
@@ -469,6 +476,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
469
476
|
id: string,
|
|
470
477
|
status: string,
|
|
471
478
|
startPath: string = process.cwd(),
|
|
479
|
+
nonPrCompletion?: NonPrCompletionInput,
|
|
472
480
|
) =>
|
|
473
481
|
Effect.gen(function* () {
|
|
474
482
|
const fs = yield* FileSystemService
|
|
@@ -486,11 +494,41 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
486
494
|
message: `Phase '${id}' has an active claim; use agency release or agency finish`,
|
|
487
495
|
})
|
|
488
496
|
}
|
|
489
|
-
if (
|
|
497
|
+
if (nonPrCompletion && validStatus !== "done") {
|
|
498
|
+
return yield* new PhaseError({
|
|
499
|
+
message: "Non-PR completion is valid only with a done status",
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
if (nonPrCompletion && record.data.pr !== null) {
|
|
503
|
+
return yield* new PhaseError({
|
|
504
|
+
message:
|
|
505
|
+
"Cannot complete without a pull request while an authoritative pull request is recorded",
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
const completionResult = nonPrCompletion
|
|
509
|
+
? buildNonPrCompletion(nonPrCompletion, new Date())
|
|
510
|
+
: undefined
|
|
511
|
+
if (completionResult && "error" in completionResult) {
|
|
512
|
+
return yield* new PhaseError({ message: completionResult.error })
|
|
513
|
+
}
|
|
514
|
+
if (
|
|
515
|
+
completionResult &&
|
|
516
|
+
record.data.status !== "open" &&
|
|
517
|
+
record.data.status !== "working" &&
|
|
518
|
+
record.data.status !== "delegated"
|
|
519
|
+
) {
|
|
520
|
+
return yield* new PhaseError({
|
|
521
|
+
message: `Cannot transition phase '${id}' from ${record.data.status} to done; reopen it first`,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
if (
|
|
525
|
+
!canTransitionStatus(record.data.status, validStatus) &&
|
|
526
|
+
!completionResult
|
|
527
|
+
) {
|
|
490
528
|
if (validStatus === "done") {
|
|
491
529
|
return yield* new PhaseError({
|
|
492
530
|
message:
|
|
493
|
-
"Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
|
|
531
|
+
"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
532
|
})
|
|
495
533
|
}
|
|
496
534
|
return yield* new PhaseError({
|
|
@@ -498,7 +536,16 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
498
536
|
})
|
|
499
537
|
}
|
|
500
538
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
501
|
-
const
|
|
539
|
+
const { completion: _, ...withoutCompletion } = record.data
|
|
540
|
+
const data: PhaseData = completionResult
|
|
541
|
+
? {
|
|
542
|
+
...record.data,
|
|
543
|
+
status: "done",
|
|
544
|
+
completion: completionResult.value,
|
|
545
|
+
}
|
|
546
|
+
: validStatus === "open"
|
|
547
|
+
? { ...withoutCompletion, status: validStatus }
|
|
548
|
+
: { ...record.data, status: validStatus }
|
|
502
549
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
503
550
|
yield* fs.writeFile(record.path, content)
|
|
504
551
|
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(
|
|
@@ -58,6 +58,12 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
58
58
|
message: `Task '${taskId}' requires a phase ID`,
|
|
59
59
|
})
|
|
60
60
|
: task
|
|
61
|
+
if ("completion" in target.data && target.data.completion) {
|
|
62
|
+
return yield* new PullRequestError({
|
|
63
|
+
message:
|
|
64
|
+
"Reopen non-PR completed work before recording a pull request",
|
|
65
|
+
})
|
|
66
|
+
}
|
|
61
67
|
const parsed = yield* parseFrontmatter(target.content, target.path)
|
|
62
68
|
yield* fs.writeFile(
|
|
63
69
|
target.path,
|
|
@@ -99,6 +105,21 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
99
105
|
const worktrees = yield* WorktreeService
|
|
100
106
|
const readiness = yield* ReadinessService
|
|
101
107
|
const workbase = yield* WorkbaseService
|
|
108
|
+
const task = yield* tasks.show(taskId, startPath)
|
|
109
|
+
const target =
|
|
110
|
+
"phases" in task.data
|
|
111
|
+
? phaseId
|
|
112
|
+
? yield* phases.show(taskId, phaseId, startPath)
|
|
113
|
+
: yield* new PullRequestError({
|
|
114
|
+
message: `Task '${taskId}' requires a phase ID`,
|
|
115
|
+
})
|
|
116
|
+
: task
|
|
117
|
+
if ("completion" in target.data && target.data.completion) {
|
|
118
|
+
return yield* new PullRequestError({
|
|
119
|
+
message:
|
|
120
|
+
"Reopen non-PR completed work before creating a pull request",
|
|
121
|
+
})
|
|
122
|
+
}
|
|
102
123
|
yield* readiness.guard(
|
|
103
124
|
"pr",
|
|
104
125
|
taskId,
|
|
@@ -112,11 +133,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
112
133
|
startPath,
|
|
113
134
|
options,
|
|
114
135
|
)
|
|
115
|
-
const
|
|
136
|
+
const workspaceTask = yield* tasks.show(taskId, workspace.root)
|
|
116
137
|
const execution =
|
|
117
|
-
"phases" in
|
|
138
|
+
"phases" in workspaceTask.data
|
|
118
139
|
? (yield* phases.show(taskId, phaseId!, workspace.root)).data
|
|
119
|
-
:
|
|
140
|
+
: workspaceTask.data
|
|
120
141
|
const { config } = yield* workbase.loadConfig(workspace.root)
|
|
121
142
|
const remote = config.delivery?.remote ?? "origin"
|
|
122
143
|
|
|
@@ -8,6 +8,7 @@ import { PullRequestService } from "./PullRequestService"
|
|
|
8
8
|
import { SyncService } from "./SyncService"
|
|
9
9
|
import { TaskService } from "./TaskService"
|
|
10
10
|
import { WorktreeService } from "./WorktreeService"
|
|
11
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
11
12
|
|
|
12
13
|
const git = async (args: string[], cwd?: string) => {
|
|
13
14
|
const process = Bun.spawn(["git", ...args], {
|
|
@@ -663,4 +664,69 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
|
|
|
663
664
|
)
|
|
664
665
|
expect(task.data).toMatchObject({ status: "open" })
|
|
665
666
|
})
|
|
667
|
+
|
|
668
|
+
test("leaves non-PR completion unchanged when a matching PR is discoverable", async () => {
|
|
669
|
+
await runTestEffect(
|
|
670
|
+
TaskService.pipe(
|
|
671
|
+
Effect.flatMap((service) =>
|
|
672
|
+
service.create(
|
|
673
|
+
{
|
|
674
|
+
id: "non-pr",
|
|
675
|
+
ticketUrl: null,
|
|
676
|
+
repo: "agency",
|
|
677
|
+
branch: "feat/example",
|
|
678
|
+
base: "main",
|
|
679
|
+
},
|
|
680
|
+
root,
|
|
681
|
+
),
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
await runTestEffect(
|
|
686
|
+
TaskService.pipe(
|
|
687
|
+
Effect.flatMap((service) =>
|
|
688
|
+
service.setStatus("non-pr", "done", root, {
|
|
689
|
+
summary: "Investigation completed without changes.",
|
|
690
|
+
}),
|
|
691
|
+
),
|
|
692
|
+
),
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
696
|
+
const applied = await runTestEffect(
|
|
697
|
+
SyncService.pipe(
|
|
698
|
+
Effect.flatMap((service) =>
|
|
699
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
700
|
+
),
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
expect(
|
|
704
|
+
applied.changes.some(
|
|
705
|
+
(change) =>
|
|
706
|
+
change.kind === "record-pr" || change.kind === "mark-done",
|
|
707
|
+
),
|
|
708
|
+
).toBe(false)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const task = await runTestEffect(
|
|
712
|
+
TaskService.pipe(
|
|
713
|
+
Effect.flatMap((service) => service.show("non-pr", root)),
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
expect(task.data).toMatchObject({
|
|
717
|
+
status: "done",
|
|
718
|
+
pr: null,
|
|
719
|
+
completion: {
|
|
720
|
+
mode: "non-pr",
|
|
721
|
+
summary: "Investigation completed without changes.",
|
|
722
|
+
},
|
|
723
|
+
})
|
|
724
|
+
expect(
|
|
725
|
+
await runTestEffect(
|
|
726
|
+
WorkbaseService.pipe(
|
|
727
|
+
Effect.flatMap((service) => service.validate(root)),
|
|
728
|
+
),
|
|
729
|
+
),
|
|
730
|
+
).toMatchObject({ valid: true, issues: [] })
|
|
731
|
+
})
|
|
666
732
|
})
|
|
@@ -580,6 +580,19 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
580
580
|
})
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
+
if (data.completion) {
|
|
584
|
+
executions.push({
|
|
585
|
+
target: record.key,
|
|
586
|
+
status: data.status,
|
|
587
|
+
branch: data.branch,
|
|
588
|
+
base: data.base,
|
|
589
|
+
claim: data.claim ?? null,
|
|
590
|
+
checkouts: checkoutStates,
|
|
591
|
+
pr: { url: null, state: "none" },
|
|
592
|
+
})
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
|
|
583
596
|
const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
|
|
584
597
|
let current: PullRequestRecord | null = existing
|
|
585
598
|
let pr: Record<string, unknown> = existing ?? {
|
|
@@ -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(
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
import { canTransitionStatus } from "../readiness"
|
|
20
20
|
import { documentRevision } from "../workbase/document-revision"
|
|
21
21
|
import { archivedTaskDirectory } from "../workbase/archive"
|
|
22
|
+
import {
|
|
23
|
+
buildNonPrCompletion,
|
|
24
|
+
type NonPrCompletionInput,
|
|
25
|
+
} from "../workbase/completion"
|
|
22
26
|
import {
|
|
23
27
|
documentWriteStep,
|
|
24
28
|
runLifecycleTransaction,
|
|
@@ -247,6 +251,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
247
251
|
id: string,
|
|
248
252
|
status: string,
|
|
249
253
|
startPath: string = process.cwd(),
|
|
254
|
+
nonPrCompletion?: NonPrCompletionInput,
|
|
250
255
|
) =>
|
|
251
256
|
Effect.gen(function* () {
|
|
252
257
|
const fs = yield* FileSystemService
|
|
@@ -269,11 +274,41 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
269
274
|
message: `Task '${id}' has an active claim; use agency release or agency finish`,
|
|
270
275
|
})
|
|
271
276
|
}
|
|
272
|
-
if (
|
|
277
|
+
if (nonPrCompletion && validStatus !== "done") {
|
|
278
|
+
return yield* new TaskError({
|
|
279
|
+
message: "Non-PR completion is valid only with a done status",
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
if (nonPrCompletion && record.data.pr !== null) {
|
|
283
|
+
return yield* new TaskError({
|
|
284
|
+
message:
|
|
285
|
+
"Cannot complete without a pull request while an authoritative pull request is recorded",
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
const completionResult = nonPrCompletion
|
|
289
|
+
? buildNonPrCompletion(nonPrCompletion, new Date())
|
|
290
|
+
: undefined
|
|
291
|
+
if (completionResult && "error" in completionResult) {
|
|
292
|
+
return yield* new TaskError({ message: completionResult.error })
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
completionResult &&
|
|
296
|
+
record.data.status !== "open" &&
|
|
297
|
+
record.data.status !== "working" &&
|
|
298
|
+
record.data.status !== "delegated"
|
|
299
|
+
) {
|
|
300
|
+
return yield* new TaskError({
|
|
301
|
+
message: `Cannot transition task '${id}' from ${record.data.status} to done; reopen it first`,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
!canTransitionStatus(record.data.status, validStatus) &&
|
|
306
|
+
!completionResult
|
|
307
|
+
) {
|
|
273
308
|
if (validStatus === "done") {
|
|
274
309
|
return yield* new TaskError({
|
|
275
310
|
message:
|
|
276
|
-
"Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
|
|
311
|
+
"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
312
|
})
|
|
278
313
|
}
|
|
279
314
|
return yield* new TaskError({
|
|
@@ -281,7 +316,16 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
281
316
|
})
|
|
282
317
|
}
|
|
283
318
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
284
|
-
const
|
|
319
|
+
const { completion: _, ...withoutCompletion } = record.data
|
|
320
|
+
const data: TaskData = completionResult
|
|
321
|
+
? {
|
|
322
|
+
...record.data,
|
|
323
|
+
status: "done",
|
|
324
|
+
completion: completionResult.value,
|
|
325
|
+
}
|
|
326
|
+
: validStatus === "open"
|
|
327
|
+
? { ...withoutCompletion, status: validStatus }
|
|
328
|
+
: { ...record.data, status: validStatus }
|
|
285
329
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
286
330
|
yield* fs.writeFile(record.path, content)
|
|
287
331
|
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")
|
|
@@ -746,6 +746,24 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
746
746
|
}
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
+
const validateCompletion = (
|
|
750
|
+
record: DocumentRecord<TaskData | PhaseData>,
|
|
751
|
+
) => {
|
|
752
|
+
if (!("repo" in record.data) || !record.data.completion) return
|
|
753
|
+
if (record.data.status !== "done") {
|
|
754
|
+
issue(record.path, "Non-PR completion requires status 'done'")
|
|
755
|
+
}
|
|
756
|
+
if (record.data.pr !== null) {
|
|
757
|
+
issue(
|
|
758
|
+
record.path,
|
|
759
|
+
"Non-PR completion cannot have a recorded pull request",
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
if (record.data.claim?.state === "active") {
|
|
763
|
+
issue(record.path, "Completed work cannot have an active claim")
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
749
767
|
for (const epic of epics.values()) {
|
|
750
768
|
validateRepositories(epic)
|
|
751
769
|
const ids = new Set(epic.data.tasks.map((task) => task.id))
|
|
@@ -776,6 +794,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
776
794
|
for (const task of tasks.values()) {
|
|
777
795
|
validateRepositories(task)
|
|
778
796
|
validateBranchOwnership(task)
|
|
797
|
+
validateCompletion(task)
|
|
779
798
|
if (task.data.epic) {
|
|
780
799
|
const parent = epics.get(task.data.epic)
|
|
781
800
|
if (!parent) {
|
|
@@ -828,6 +847,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
828
847
|
for (const phase of phases.values()) {
|
|
829
848
|
validateRepositories(phase)
|
|
830
849
|
validateBranchOwnership(phase)
|
|
850
|
+
validateCompletion(phase)
|
|
831
851
|
}
|
|
832
852
|
|
|
833
853
|
issues.sort((a, b) =>
|