@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
@@ -315,7 +315,7 @@ describe("ArchiveService", () => {
315
315
  ),
316
316
  ),
317
317
  )
318
- await Bun.write(join(workspace.writablePath, "dirty.txt"), "keep me\n")
318
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
319
319
 
320
320
  await expect(
321
321
  runTestEffect(
@@ -747,7 +747,7 @@ describe("ArchiveService", () => {
747
747
  ),
748
748
  ).rejects.toThrow("Another archive or restore operation")
749
749
  expect(
750
- await Bun.file(join(workspace.writablePath, "README.md")).exists(),
750
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
751
751
  ).toBe(true)
752
752
  expect(await Bun.file(join(root, "tasks/locked/TASK.md")).exists()).toBe(
753
753
  true,
@@ -367,6 +367,7 @@ const repositoriesFor = (record: ArchivedRecord) => {
367
367
  ...(record.data.repos ?? []).map((reference) => reference.repo),
368
368
  ]
369
369
  }
370
+ if ("review" in record.data) return [record.data.review.repo]
370
371
  return []
371
372
  }
372
373
 
@@ -147,6 +147,65 @@ describe("claim service", () => {
147
147
  })
148
148
  })
149
149
 
150
+ test("finishes claimed non-PR work with durable completion evidence", async () => {
151
+ const initial = await inspect()
152
+ const acquired = await claim(initial.revision)
153
+ const finished = await runTestEffect(
154
+ ClaimService.pipe(
155
+ Effect.flatMap((service) =>
156
+ service.finish(
157
+ {
158
+ taskId: "single",
159
+ sessionId: "session-1",
160
+ revision: acquired.revision,
161
+ outcome: "done",
162
+ nonPrCompletion: {
163
+ summary: "Review completed; no code change was needed.",
164
+ evidenceUrl: "https://example.com/review",
165
+ },
166
+ now: at("2026-07-17T12:45:00.000Z"),
167
+ },
168
+ root,
169
+ ),
170
+ ),
171
+ ),
172
+ )
173
+
174
+ expect(finished.data).toMatchObject({
175
+ status: "done",
176
+ claim: { state: "finished", outcome: "done" },
177
+ completion: {
178
+ mode: "non-pr",
179
+ completedAt: "2026-07-17T12:45:00.000Z",
180
+ summary: "Review completed; no code change was needed.",
181
+ evidenceUrl: "https://example.com/review",
182
+ },
183
+ })
184
+ })
185
+
186
+ test("rejects invalid claimed non-PR completion", async () => {
187
+ const initial = await inspect()
188
+ const acquired = await claim(initial.revision)
189
+ await expect(
190
+ runTestEffect(
191
+ ClaimService.pipe(
192
+ Effect.flatMap((service) =>
193
+ service.finish(
194
+ {
195
+ taskId: "single",
196
+ sessionId: "session-1",
197
+ revision: acquired.revision,
198
+ outcome: "done",
199
+ nonPrCompletion: { summary: " " },
200
+ },
201
+ root,
202
+ ),
203
+ ),
204
+ ),
205
+ ),
206
+ ).rejects.toThrow("summary must not be empty")
207
+ })
208
+
150
209
  test("returns structured ownership and revision conflicts", async () => {
151
210
  const initial = await inspect()
152
211
  const acquired = await claim(initial.revision)
@@ -31,6 +31,10 @@ import {
31
31
  type PhaseFrontmatter as PhaseData,
32
32
  type TaskFrontmatter as TaskData,
33
33
  } from "../workbase/schemas"
34
+ import {
35
+ buildNonPrCompletion,
36
+ type NonPrCompletionInput,
37
+ } from "../workbase/completion"
34
38
 
35
39
  class ClaimError extends Data.TaggedError("ClaimError")<{
36
40
  readonly message: string
@@ -83,6 +87,7 @@ interface OwnedClaimInput {
83
87
 
84
88
  interface FinishInput extends OwnedClaimInput {
85
89
  readonly outcome: "done" | "dropped"
90
+ readonly nonPrCompletion?: NonPrCompletionInput
86
91
  }
87
92
 
88
93
  interface ExpireClaimInput {
@@ -103,7 +108,8 @@ interface ReconcileInput {
103
108
  const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
104
109
 
105
110
  type SingleTaskData = Extract<TaskData, { readonly repo: string }>
106
- type ExecutionData = SingleTaskData | PhaseData
111
+ type ReviewTaskData = Extract<TaskData, { readonly review: unknown }>
112
+ type ExecutionData = SingleTaskData | ReviewTaskData | PhaseData
107
113
 
108
114
  const isTaggedClaimError = (
109
115
  error: unknown,
@@ -507,6 +513,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
507
513
  finish: (input: FinishInput, startPath: string = process.cwd()) =>
508
514
  Effect.gen(function* () {
509
515
  assertIdentity("Session ID", input.sessionId)
516
+ if (input.nonPrCompletion && input.outcome !== "done") {
517
+ return yield* new ClaimError({
518
+ message: "Non-PR completion is valid only with a done outcome",
519
+ })
520
+ }
510
521
  const service = yield* ClaimService
511
522
  const inspected = yield* service.inspect(
512
523
  input.taskId,
@@ -531,6 +542,22 @@ export class ClaimService extends Effect.Service<ClaimService>()(
531
542
  message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
532
543
  })
533
544
  }
545
+ if (input.nonPrCompletion && "pr" in data && data.pr !== null) {
546
+ throw new ClaimError({
547
+ target: inspected.target.label,
548
+ message:
549
+ "Cannot complete without a pull request while an authoritative pull request is recorded",
550
+ })
551
+ }
552
+ const completionResult = input.nonPrCompletion
553
+ ? buildNonPrCompletion(input.nonPrCompletion, now)
554
+ : undefined
555
+ if (completionResult && "error" in completionResult) {
556
+ throw new ClaimError({
557
+ target: inspected.target.label,
558
+ message: completionResult.error,
559
+ })
560
+ }
534
561
  const claim: ClaimRecord = {
535
562
  ...data.claim,
536
563
  state: "finished",
@@ -540,8 +567,15 @@ export class ClaimService extends Effect.Service<ClaimService>()(
540
567
  return {
541
568
  data: {
542
569
  ...data,
543
- status: input.outcome === "done" ? "working" : "dropped",
570
+ status: completionResult
571
+ ? "done"
572
+ : input.outcome === "done"
573
+ ? "working"
574
+ : "dropped",
544
575
  claim,
576
+ ...(completionResult
577
+ ? { completion: completionResult.value }
578
+ : {}),
545
579
  },
546
580
  claim,
547
581
  }
@@ -54,6 +54,7 @@ interface CheckoutInspection {
54
54
  readonly checkoutCommit: string | null
55
55
  readonly checkoutBranch: string | null
56
56
  readonly detached: boolean | null
57
+ readonly dirty: boolean | null
57
58
  }
58
59
 
59
60
  type ExecutionData = PhaseData & Partial<Pick<TaskData, "ticketUrl" | "epic">>
@@ -92,6 +93,18 @@ const runGit = (fs: FileSystemService, cwd: string, args: readonly string[]) =>
92
93
  Effect.catchAll(() => Effect.succeed(null)),
93
94
  )
94
95
 
96
+ const runGitText = (
97
+ fs: FileSystemService,
98
+ cwd: string,
99
+ args: readonly string[],
100
+ ) =>
101
+ fs.runCommand(["git", "-C", cwd, ...args], { captureOutput: true }).pipe(
102
+ Effect.map((result) =>
103
+ result.exitCode === 0 ? result.stdout.trim() : null,
104
+ ),
105
+ Effect.catchAll(() => Effect.succeed(null)),
106
+ )
107
+
95
108
  const worktreePaths = (output: string | null) =>
96
109
  new Set(
97
110
  (output ?? "")
@@ -765,9 +778,13 @@ export class ContextService extends Effect.Service<ContextService>()(
765
778
  : task?.data && "repo" in task.data
766
779
  ? (task.data as ExecutionData)
767
780
  : null
768
- const references: readonly RepositoryReference[] = executionData
769
- ? (executionData.repos ?? [])
770
- : (epic?.data.repos ?? [])
781
+ const reviewData =
782
+ task?.data && "review" in task.data ? task.data.review : null
783
+ const references: readonly RepositoryReference[] = reviewData
784
+ ? [{ repo: reviewData.repo, ref: reviewData.commit }]
785
+ : executionData
786
+ ? (executionData.repos ?? [])
787
+ : (epic?.data.repos ?? [])
771
788
  const entityDirectory = target.path.replace(
772
789
  /\/(?:EPIC|TASK|PHASE)\.md$/,
773
790
  "",
@@ -812,6 +829,7 @@ export class ContextService extends Effect.Service<ContextService>()(
812
829
  checkoutCommit: null,
813
830
  checkoutBranch: null,
814
831
  detached: null,
832
+ dirty: null,
815
833
  }
816
834
  }
817
835
  const checkoutCommit = yield* runGit(fs, checkoutPath, [
@@ -824,6 +842,10 @@ export class ContextService extends Effect.Service<ContextService>()(
824
842
  "--short",
825
843
  "HEAD",
826
844
  ])
845
+ const checkoutStatus = yield* runGitText(fs, checkoutPath, [
846
+ "status",
847
+ "--porcelain",
848
+ ])
827
849
  const resolvedCheckoutPath = yield* fs.realPath(checkoutPath)
828
850
  registered = registered || listedPaths.has(resolvedCheckoutPath)
829
851
  if (checkoutCommit === null) {
@@ -837,6 +859,8 @@ export class ContextService extends Effect.Service<ContextService>()(
837
859
  checkoutCommit,
838
860
  checkoutBranch,
839
861
  detached: checkoutBranch === null,
862
+ dirty:
863
+ checkoutStatus === null ? null : checkoutStatus.length > 0,
840
864
  }
841
865
  })
842
866
 
@@ -901,6 +925,17 @@ export class ContextService extends Effect.Service<ContextService>()(
901
925
  ...(yield* inspectCheckout(repositoryPath, checkoutPath)),
902
926
  })
903
927
  }
928
+ const reviewSourceCommit = reviewData
929
+ ? yield* runGit(fs, join(root, "repos", reviewData.repo), [
930
+ "ls-remote",
931
+ "origin",
932
+ reviewData.source.kind === "pull-request"
933
+ ? reviewData.source.fetchRef
934
+ : reviewData.source.ref
935
+ .replace(/^refs\/remotes\/origin\//, "")
936
+ .replace(/^origin\//, ""),
937
+ ])
938
+ : null
904
939
 
905
940
  const checkoutStates = [writable, ...referenceCheckouts].filter(
906
941
  (value): value is NonNullable<typeof value> => value !== null,
@@ -956,7 +991,11 @@ export class ContextService extends Effect.Service<ContextService>()(
956
991
  aggregate: aggregateProgress(aggregateStatuses),
957
992
  },
958
993
  authority: {
959
- mode: executionData ? "execution" : "orchestration",
994
+ mode: reviewData
995
+ ? "review"
996
+ : executionData
997
+ ? "execution"
998
+ : "orchestration",
960
999
  writable: writable
961
1000
  ? {
962
1001
  repo: writable.repo,
@@ -972,6 +1011,9 @@ export class ContextService extends Effect.Service<ContextService>()(
972
1011
  repositoryPath: reference.repositoryPath,
973
1012
  checkoutPath: reference.checkoutPath,
974
1013
  })),
1014
+ documents: {
1015
+ writable: reviewData && task ? [task.path] : [],
1016
+ },
975
1017
  },
976
1018
  workspace: options.compact
977
1019
  ? {
@@ -997,6 +1039,16 @@ export class ContextService extends Effect.Service<ContextService>()(
997
1039
  references: referenceCheckouts,
998
1040
  warnings: inspectionWarnings,
999
1041
  },
1042
+ review: reviewData
1043
+ ? {
1044
+ ...reviewData,
1045
+ sourceObservation: {
1046
+ available: reviewSourceCommit !== null,
1047
+ commit: reviewSourceCommit?.split(/\s+/)[0] ?? null,
1048
+ },
1049
+ checkout: referenceCheckouts[0] ?? null,
1050
+ }
1051
+ : null,
1000
1052
  pr: executionData?.pr
1001
1053
  ? normalizePullRequestRecord(executionData.pr)
1002
1054
  : { url: null, state: "none" },
@@ -282,6 +282,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
282
282
  }
283
283
 
284
284
  const refs = new Map<string, Set<string>>()
285
+ const reviewSources: { repo: string; task: string; ref: string }[] =
286
+ []
285
287
  const declareRef = (repo: string, ref: string) => {
286
288
  const values = refs.get(repo) ?? new Set<string>()
287
289
  values.add(ref)
@@ -293,7 +295,19 @@ export class DoctorService extends Effect.Service<DoctorService>()(
293
295
  declareRef(reference.repo, reference.ref)
294
296
  }
295
297
  for (const task of yield* tasks.list(root)) {
296
- if ("repo" in task.data) {
298
+ if ("review" in task.data) {
299
+ declareRef(task.data.review.repo, task.data.review.commit)
300
+ reviewSources.push({
301
+ repo: task.data.review.repo,
302
+ task: task.id,
303
+ ref:
304
+ task.data.review.source.kind === "pull-request"
305
+ ? task.data.review.source.fetchRef
306
+ : task.data.review.source.ref
307
+ .replace(/^refs\/remotes\/origin\//, "")
308
+ .replace(/^origin\//, ""),
309
+ })
310
+ } else if ("repo" in task.data) {
297
311
  declareRef(task.data.repo, task.data.base)
298
312
  for (const reference of task.data.repos ?? [])
299
313
  declareRef(reference.repo, reference.ref)
@@ -357,6 +371,36 @@ export class DoctorService extends Effect.Service<DoctorService>()(
357
371
  })
358
372
  if (!repositoryValid) continue
359
373
 
374
+ for (const source of reviewSources.filter(
375
+ (item) => item.repo === repository.alias,
376
+ )) {
377
+ const observed = yield* fs.runCommand(
378
+ [
379
+ "git",
380
+ "-C",
381
+ repository.path,
382
+ "ls-remote",
383
+ "origin",
384
+ source.ref,
385
+ ],
386
+ { captureOutput: true },
387
+ )
388
+ const available =
389
+ observed.exitCode === 0 && Boolean(observed.stdout.trim())
390
+ add({
391
+ id: `review.${source.task}.source`,
392
+ category: "repository",
393
+ level: "warning",
394
+ status: available ? "pass" : "fail",
395
+ message: available
396
+ ? `Review source '${source.ref}' is available for task '${source.task}'`
397
+ : `Review source '${source.ref}' is unavailable for task '${source.task}'; its pin remains usable`,
398
+ remediation: available
399
+ ? undefined
400
+ : "Use the pinned checkout or refresh after restoring the source.",
401
+ })
402
+ }
403
+
360
404
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
361
405
  const local = yield* fs.runCommand(
362
406
  [
@@ -360,6 +360,11 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
360
360
  "base",
361
361
  "pr",
362
362
  ].some((key) => updates[key as keyof TaskUpdates] !== undefined)
363
+ if ("review" in record.data && executionChange) {
364
+ return yield* new GraphMutationError({
365
+ message: `Review task '${id}' does not have writable execution metadata`,
366
+ })
367
+ }
363
368
  if ("phases" in record.data && executionChange) {
364
369
  return yield* new GraphMutationError({
365
370
  message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
@@ -374,6 +379,16 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
374
379
  message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
375
380
  })
376
381
  }
382
+ if (
383
+ updates.pr &&
384
+ "completion" in record.data &&
385
+ record.data.completion
386
+ ) {
387
+ return yield* new GraphMutationError({
388
+ message:
389
+ "Reopen non-PR completed work before recording a pull request",
390
+ })
391
+ }
377
392
  if (
378
393
  executionChange &&
379
394
  (yield* fs.isDirectory(join(dirname(record.path), "code")))
@@ -504,6 +519,12 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
504
519
  message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
505
520
  })
506
521
  }
522
+ if (updates.pr && record.data.completion) {
523
+ return yield* new GraphMutationError({
524
+ message:
525
+ "Reopen non-PR completed work before recording a pull request",
526
+ })
527
+ }
507
528
  const data: PhaseData = yield* decode(
508
529
  PhaseFrontmatter,
509
530
  {
@@ -54,7 +54,10 @@ interface RepositoryRecord {
54
54
  readonly target: string | null
55
55
  }
56
56
 
57
- type ExecutionData = PhaseData | Extract<TaskData, { readonly repo: string }>
57
+ type ExecutionData =
58
+ | PhaseData
59
+ | Extract<TaskData, { readonly repo: string }>
60
+ | Extract<TaskData, { readonly review: unknown }>
58
61
 
59
62
  export interface GraphOptions {
60
63
  readonly cwd?: string
@@ -548,10 +551,18 @@ export class GraphService extends Effect.Service<GraphService>()(
548
551
  const executionRepositories = (
549
552
  data: TaskData | PhaseData,
550
553
  ): readonly string[] =>
551
- "repo" in data
552
- ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
553
- : []
554
+ "review" in data
555
+ ? [data.review.repo]
556
+ : "repo" in data
557
+ ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
558
+ : []
554
559
  const executionEdges = (id: string, data: TaskData | PhaseData) => {
560
+ if ("review" in data) {
561
+ edges.push(
562
+ edge("references", id, repositoryNodeId(data.review.repo)),
563
+ )
564
+ return
565
+ }
555
566
  if (!("repo" in data)) return
556
567
  edges.push(edge("writes", id, repositoryNodeId(data.repo)))
557
568
  for (const reference of data.repos ?? []) {
@@ -650,8 +661,9 @@ export class GraphService extends Effect.Service<GraphService>()(
650
661
  const executionDetails = (entityPath: string, data: ExecutionData) =>
651
662
  Effect.gen(function* () {
652
663
  const directory = entityPath.replace(/\/(?:TASK|PHASE)\.md$/, "")
653
- const checkoutPath = join(directory, "code", data.repo)
654
- const repositoryPath = join(root, "repos", data.repo)
664
+ const repo = "review" in data ? data.review.repo : data.repo
665
+ const checkoutPath = join(directory, "code", repo)
666
+ const repositoryPath = join(root, "repos", repo)
655
667
  const materialized = yield* fs.isDirectory(checkoutPath)
656
668
  const result: {
657
669
  workspace?: GraphExecutionWorkspace
@@ -666,58 +678,110 @@ export class GraphService extends Effect.Service<GraphService>()(
666
678
  }
667
679
  }
668
680
  if (include.has("git")) {
669
- result.git = {
670
- branch: data.branch,
671
- base: data.base,
672
- branchCommit: yield* run(fs, [
673
- "git",
674
- "-C",
675
- repositoryPath,
676
- "rev-parse",
677
- `${data.branch}^{commit}`,
678
- ]),
679
- baseCommit: yield* run(fs, [
680
- "git",
681
- "-C",
682
- repositoryPath,
683
- "rev-parse",
684
- `${data.base}^{commit}`,
685
- ]),
686
- checkoutCommit: materialized
687
- ? yield* run(fs, [
688
- "git",
689
- "-C",
690
- checkoutPath,
691
- "rev-parse",
692
- "HEAD",
693
- ])
694
- : null,
695
- checkoutBranch: materialized
696
- ? yield* run(fs, [
697
- "git",
698
- "-C",
699
- checkoutPath,
700
- "symbolic-ref",
701
- "--quiet",
702
- "--short",
703
- "HEAD",
704
- ])
705
- : null,
706
- dirty: materialized
707
- ? ((status) =>
708
- status === null ? null : status.length > 0)(
709
- yield* runText(fs, [
681
+ result.git =
682
+ "review" in data
683
+ ? {
684
+ pinnedCommit: data.review.commit,
685
+ sourceCommit: ((value) =>
686
+ value?.split(/\s+/)[0] ?? null)(
687
+ yield* run(fs, [
688
+ "git",
689
+ "-C",
690
+ repositoryPath,
691
+ "ls-remote",
692
+ "origin",
693
+ data.review.source.kind === "pull-request"
694
+ ? data.review.source.fetchRef
695
+ : data.review.source.ref
696
+ .replace(/^refs\/remotes\/origin\//, "")
697
+ .replace(/^origin\//, ""),
698
+ ]),
699
+ ),
700
+ checkoutCommit: materialized
701
+ ? yield* run(fs, [
702
+ "git",
703
+ "-C",
704
+ checkoutPath,
705
+ "rev-parse",
706
+ "HEAD",
707
+ ])
708
+ : null,
709
+ checkoutBranch: materialized
710
+ ? yield* run(fs, [
711
+ "git",
712
+ "-C",
713
+ checkoutPath,
714
+ "symbolic-ref",
715
+ "--quiet",
716
+ "--short",
717
+ "HEAD",
718
+ ])
719
+ : null,
720
+ dirty: materialized
721
+ ? ((status) =>
722
+ status === null ? null : status.length > 0)(
723
+ yield* runText(fs, [
724
+ "git",
725
+ "-C",
726
+ checkoutPath,
727
+ "status",
728
+ "--porcelain",
729
+ ]),
730
+ )
731
+ : null,
732
+ }
733
+ : {
734
+ branch: data.branch,
735
+ base: data.base,
736
+ branchCommit: yield* run(fs, [
710
737
  "git",
711
738
  "-C",
712
- checkoutPath,
713
- "status",
714
- "--porcelain",
739
+ repositoryPath,
740
+ "rev-parse",
741
+ `${data.branch}^{commit}`,
715
742
  ]),
716
- )
717
- : null,
718
- }
743
+ baseCommit: yield* run(fs, [
744
+ "git",
745
+ "-C",
746
+ repositoryPath,
747
+ "rev-parse",
748
+ `${data.base}^{commit}`,
749
+ ]),
750
+ checkoutCommit: materialized
751
+ ? yield* run(fs, [
752
+ "git",
753
+ "-C",
754
+ checkoutPath,
755
+ "rev-parse",
756
+ "HEAD",
757
+ ])
758
+ : null,
759
+ checkoutBranch: materialized
760
+ ? yield* run(fs, [
761
+ "git",
762
+ "-C",
763
+ checkoutPath,
764
+ "symbolic-ref",
765
+ "--quiet",
766
+ "--short",
767
+ "HEAD",
768
+ ])
769
+ : null,
770
+ dirty: materialized
771
+ ? ((status) =>
772
+ status === null ? null : status.length > 0)(
773
+ yield* runText(fs, [
774
+ "git",
775
+ "-C",
776
+ checkoutPath,
777
+ "status",
778
+ "--porcelain",
779
+ ]),
780
+ )
781
+ : null,
782
+ }
719
783
  }
720
- if (include.has("pr")) {
784
+ if (include.has("pr") && !("review" in data)) {
721
785
  if (!data.pr) {
722
786
  result.pr = { url: null, state: "none" }
723
787
  } else {
@@ -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(/archiving, restoring,\s+dropping, or/)
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")