@markjaquith/agency 2.48.1 → 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 +18 -8
- 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 +71 -0
- package/src/commands/claim.ts +26 -2
- package/src/commands/phase.ts +23 -2
- package/src/commands/task.ts +22 -2
- 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 +4 -1
- 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 +7 -2
- package/src/workbase/completion.ts +28 -0
- package/src/workbase/schemas.ts +9 -0
|
@@ -374,6 +374,16 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
374
374
|
message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
375
375
|
})
|
|
376
376
|
}
|
|
377
|
+
if (
|
|
378
|
+
updates.pr &&
|
|
379
|
+
"completion" in record.data &&
|
|
380
|
+
record.data.completion
|
|
381
|
+
) {
|
|
382
|
+
return yield* new GraphMutationError({
|
|
383
|
+
message:
|
|
384
|
+
"Reopen non-PR completed work before recording a pull request",
|
|
385
|
+
})
|
|
386
|
+
}
|
|
377
387
|
if (
|
|
378
388
|
executionChange &&
|
|
379
389
|
(yield* fs.isDirectory(join(dirname(record.path), "code")))
|
|
@@ -504,6 +514,12 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
|
|
|
504
514
|
message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
|
|
505
515
|
})
|
|
506
516
|
}
|
|
517
|
+
if (updates.pr && record.data.completion) {
|
|
518
|
+
return yield* new GraphMutationError({
|
|
519
|
+
message:
|
|
520
|
+
"Reopen non-PR completed work before recording a pull request",
|
|
521
|
+
})
|
|
522
|
+
}
|
|
507
523
|
const data: PhaseData = yield* decode(
|
|
508
524
|
PhaseFrontmatter,
|
|
509
525
|
{
|
|
@@ -261,7 +261,9 @@ describe("IntegrationService", () => {
|
|
|
261
261
|
expect(body).toContain("Only `done` satisfies a dependency")
|
|
262
262
|
expect(body).toContain("Require explicit user intent")
|
|
263
263
|
expect(body).toContain("changing repository")
|
|
264
|
-
expect(body).toMatch(
|
|
264
|
+
expect(body).toMatch(
|
|
265
|
+
/archiving, restoring,\s+dropping, reopening, or completing work without a pull request/,
|
|
266
|
+
)
|
|
265
267
|
expect(body).toContain("Never invent entity IDs")
|
|
266
268
|
expect(body).toContain("Preserve parent backlinks")
|
|
267
269
|
expect(body).toContain("dirty-worktree, active-claim, revision")
|
|
@@ -281,6 +283,7 @@ describe("IntegrationService", () => {
|
|
|
281
283
|
expect(body).toContain("pausing or handing off")
|
|
282
284
|
expect(body).toContain("`agency finish`")
|
|
283
285
|
expect(body).toContain("`agency sync --apply`")
|
|
286
|
+
expect(body).toContain("`--no-pull-request --summary <text>`")
|
|
284
287
|
expect(body).toContain("`TASK.md` or `PHASE.md`")
|
|
285
288
|
expect(body).toContain("PR state, current head, diff summary")
|
|
286
289
|
expect(body).toContain("Run `agency validate` before reporting completion")
|
|
@@ -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")
|