@markjaquith/agency 2.48.1 → 2.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +40 -9
  2. package/cli.ts +32 -0
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +141 -38
  5. package/src/cli-parser.test.ts +120 -0
  6. package/src/cli-parser.ts +122 -7
  7. package/src/cli.test.ts +71 -0
  8. package/src/commands/claim.ts +26 -2
  9. package/src/commands/phase.ts +23 -2
  10. package/src/commands/review.ts +38 -0
  11. package/src/commands/task.ts +49 -7
  12. package/src/commands/work.test.ts +2 -0
  13. package/src/commands/work.ts +2 -2
  14. package/src/graph-schema.ts +35 -13
  15. package/src/protocol.ts +1 -0
  16. package/src/services/ArchiveService.test.ts +2 -2
  17. package/src/services/ArchiveService.ts +1 -0
  18. package/src/services/ClaimService.test.ts +59 -0
  19. package/src/services/ClaimService.ts +36 -2
  20. package/src/services/ContextService.ts +56 -4
  21. package/src/services/DoctorService.ts +45 -1
  22. package/src/services/GraphMutationService.ts +21 -0
  23. package/src/services/GraphService.ts +118 -54
  24. package/src/services/IntegrationService.test.ts +4 -1
  25. package/src/services/PhaseService.ts +55 -3
  26. package/src/services/PullRequestService.test.ts +22 -2
  27. package/src/services/PullRequestService.ts +44 -3
  28. package/src/services/ReadinessService.ts +7 -3
  29. package/src/services/ReviewService.test.ts +472 -0
  30. package/src/services/ReviewService.ts +404 -0
  31. package/src/services/SyncService.test.ts +68 -2
  32. package/src/services/SyncService.ts +123 -6
  33. package/src/services/TaskPhaseService.test.ts +182 -0
  34. package/src/services/TaskService.ts +128 -11
  35. package/src/services/WorkbaseService.test.ts +58 -0
  36. package/src/services/WorkbaseService.ts +22 -1
  37. package/src/services/WorktreeService.test.ts +16 -16
  38. package/src/services/WorktreeService.ts +76 -40
  39. package/src/test-utils.ts +2 -0
  40. package/src/work-view.ts +14 -6
  41. package/src/workbase/AGENTS.md +7 -2
  42. package/src/workbase/completion.ts +28 -0
  43. package/src/workbase/schemas.test.ts +57 -0
  44. package/src/workbase/schemas.ts +65 -0
@@ -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.multiPhase) {
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
- "repo" in data
132
- ? [
133
- data.repo,
134
- ...(data.repos ?? []).map((reference) => reference.repo),
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: [documentWriteStep(root, writes)],
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 (!canTransitionStatus(record.data.status, validStatus)) {
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 data = { ...record.data, status: validStatus }
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 all = [writable, ...referenceAliases].filter(
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, "README.md")).text(),
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, "branch", "--show-current"],
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, "README.md")).text(),
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, "README.md"),
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, "README.md")).text(),
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, "status", "--porcelain"],
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, "uncommitted.txt"),
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, "uncommitted.txt")).text(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).text(),
1651
+ await Bun.file(join(workspace.writablePath!, "README.md")).text(),
1652
1652
  ).toBe("example\n")
1653
1653
  })
1654
1654
  })