@markjaquith/agency 3.13.0 → 3.15.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 CHANGED
@@ -710,7 +710,7 @@ external chooser continues to offer goals and **Browse items** through that choo
710
710
  | Split work | Add a phase / split this task; name the existing work's first phase | `split` |
711
711
  | Work on a task or phase | Work on this item | `work` |
712
712
  | Move from investigation to execution | Create implementation follow-up | `handoff` |
713
- | Review someone else's work | Review a PR, or a remote branch/commit | `review`, `review-ref` |
713
+ | Review someone else's work | Review a PR, or a remote branch/commit; finish a review | `review`, `review-ref`, `finish-review` |
714
714
  | Publish or update PR status | Push without a PR, refresh provider state, create a PR, mark a GitHub PR ready, or close it | `push`, `sync`, `pr`, `pr-ready`, `pr-close` |
715
715
  | Maintain review work | Fetch and repin a review task to its current source | `review-refresh` |
716
716
  | Organize work | Rename an item, move a task into/out of an epic, or add/remove a sibling dependency | `rename`, `move-to-epic`, `remove-from-epic`, `dependency-add`, `dependency-remove` |
@@ -723,7 +723,9 @@ on the new item now** or **Finish**. Only choosing Work prepares checkouts and
723
723
  launches the configured runner; creation alone leaves the item for later.
724
724
  Investigation handoff creates a distinct implementation item with source and
725
725
  revision provenance. Completing a non-PR outcome requires a durable summary.
726
- PR-backed completion comes from provider reconciliation after merge.
726
+ **Finish review** marks a review task done without a summary; review tasks offer
727
+ it instead of non-PR completion. PR-backed completion comes from provider
728
+ reconciliation after merge.
727
729
 
728
730
  **Refresh Agency state** reads the provider and reconciles local records. The
729
731
  explicit **GitHub PR ready/close** actions mutate the recorded GitHub URL, then
@@ -1060,6 +1062,7 @@ Create a pinned, read-only review task from the selected alias's origin:
1060
1062
  agency task create <id> --review <alias> --pull-request <url-or-number>
1061
1063
  agency task create <id> --review <alias> --ref <remote-ref>
1062
1064
  agency review refresh <id> [--if-revision <hash>] [--json]
1065
+ agency review finish <id> [--if-revision <hash>] [--json]
1063
1066
  ```
1064
1067
 
1065
1068
  Review creation fetches the source and records its exact 40-character commit.
@@ -1069,7 +1072,10 @@ detached checkout and no writable branch. Source movement is observed separately
1069
1072
  from the pin and applied only by `review refresh`; sync, doctor, work, cleanup,
1070
1073
  and archive never move the pin implicitly. Dirty or structurally unexpected
1071
1074
  review checkouts block refresh and cleanup. Review tasks support the normal status
1072
- lifecycle, but reject phase conversion and delivery PR operations.
1075
+ lifecycle, but reject phase conversion and delivery PR operations. Because a
1076
+ review has no delivery pull request, `review finish` marks an open, working, or
1077
+ delegated review done without the `--no-pull-request --summary` requirement
1078
+ that applies to other work.
1073
1079
  Each active or archived review task owns one internal task-scoped pin ref. A
1074
1080
  refresh advances that ref transactionally; failed creation removes it. Archiving
1075
1081
  retains the pin so a deleted source can still be restored and inspected.
package/cli-main.ts CHANGED
@@ -726,7 +726,7 @@ Commands:
726
726
  next List or select ready execution units
727
727
  pr create / pr [...] Create an Agency PR or run gh pr with repository focus
728
728
  push Validate and publish the current execution unit
729
- review refresh Explicitly refresh a pinned review task
729
+ review refresh|finish Refresh or finish a pinned review task
730
730
  repo <subcommand> Manage workbase repositories
731
731
  status Show status for the current workbase
732
732
  doctor Diagnose workbase health and integrations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli-parser.ts CHANGED
@@ -783,7 +783,8 @@ const commands = {
783
783
  },
784
784
  },
785
785
  review: {
786
- usage: "agency review refresh <task-id> [--if-revision <hash>] [--json]",
786
+ usage:
787
+ "agency review <refresh|finish> <task-id> [--if-revision <hash>] [--json]",
787
788
  options: {
788
789
  ...outputOptions,
789
790
  "if-revision": { type: "string" },
@@ -796,6 +797,12 @@ const commands = {
796
797
  maxArgs: 1,
797
798
  options: ["if-revision", "json"],
798
799
  },
800
+ finish: {
801
+ usage: "agency review finish <task-id> [--if-revision <hash>] [--json]",
802
+ minArgs: 1,
803
+ maxArgs: 1,
804
+ options: ["if-revision", "json"],
805
+ },
799
806
  },
800
807
  },
801
808
  worktree: {
@@ -234,6 +234,11 @@ const actionPresentation: Record<
234
234
  icon: "󰑐",
235
235
  color: macchiato.sapphire,
236
236
  },
237
+ "finish-review": {
238
+ description: "Mark this review done; no completion summary is required.",
239
+ icon: "󰄬",
240
+ color: macchiato.green,
241
+ },
237
242
  reopen: {
238
243
  description: "Return terminal work to open status.",
239
244
  icon: "󰑓",
@@ -371,7 +376,7 @@ export const actionGroups = [
371
376
  id: "review",
372
377
  label: "Review someone else's work",
373
378
  icon: "󰍉",
374
- actions: ["review", "review-ref"],
379
+ actions: ["review", "review-ref", "finish-review"],
375
380
  },
376
381
  {
377
382
  id: "split",
@@ -875,6 +880,7 @@ export const actActions = (
875
880
  const purpose =
876
881
  parent && "purpose" in parent.data ? parent.data.purpose : undefined
877
882
  const terminal = node.status === "done" || node.status === "dropped"
883
+ const isReview = node.kind === "task" && "review" in node.data
878
884
  const blockers = (execution ?? node).readiness.blockers
879
885
  const validation = blockers.find(
880
886
  (blocker) => blocker.kind === "validation",
@@ -1044,9 +1050,7 @@ export const actActions = (
1044
1050
  details(
1045
1051
  "review-refresh",
1046
1052
  "Refresh review source",
1047
- node.kind === "task" && "review" in node.data
1048
- ? null
1049
- : "Select a review task",
1053
+ isReview ? null : "Select a review task",
1050
1054
  ),
1051
1055
  {
1052
1056
  command: [
@@ -1065,6 +1069,33 @@ export const actActions = (
1065
1069
  }),
1066
1070
  },
1067
1071
  ),
1072
+ immediate(
1073
+ details(
1074
+ "finish-review",
1075
+ "Finish review",
1076
+ !isReview
1077
+ ? "Select a review task"
1078
+ : terminal
1079
+ ? "Review is already terminal"
1080
+ : null,
1081
+ ),
1082
+ {
1083
+ command: [
1084
+ "agency",
1085
+ "review",
1086
+ "finish",
1087
+ target.taskId,
1088
+ "--if-revision",
1089
+ node.data.sha256,
1090
+ ],
1091
+ run: review({
1092
+ ...options,
1093
+ subcommand: "finish",
1094
+ taskId: target.taskId,
1095
+ ifRevision: node.data.sha256,
1096
+ }),
1097
+ },
1098
+ ),
1068
1099
  ...(["reopen", "drop"] as const).map((id) => {
1069
1100
  const status = id === "reopen" ? "open" : "dropped"
1070
1101
  return immediate(
@@ -1300,11 +1331,13 @@ export const actActions = (
1300
1331
  "complete",
1301
1332
  "Complete without a pull request",
1302
1333
  noExecution ??
1303
- (terminal
1304
- ? "Item is already terminal"
1305
- : prUrl
1306
- ? "Reconcile the recorded pull request instead"
1307
- : null),
1334
+ (isReview
1335
+ ? "Use Finish review for review tasks"
1336
+ : terminal
1337
+ ? "Item is already terminal"
1338
+ : prUrl
1339
+ ? "Reconcile the recorded pull request instead"
1340
+ : null),
1308
1341
  ),
1309
1342
  inputs: [
1310
1343
  input("summary", "Completed outcome summary", { multiline: true }),
@@ -12,17 +12,34 @@ interface ReviewOptions extends BaseCommandOptions {
12
12
 
13
13
  export const review = (options: ReviewOptions) =>
14
14
  Effect.gen(function* () {
15
- if (options.subcommand !== "refresh" || !options.taskId) {
15
+ if (
16
+ (options.subcommand !== "refresh" && options.subcommand !== "finish") ||
17
+ !options.taskId
18
+ ) {
16
19
  return yield* Effect.fail(
17
- new Error("Usage: agency review refresh <task>"),
20
+ new Error("Usage: agency review <refresh|finish> <task>"),
18
21
  )
19
22
  }
20
- const result = yield* (yield* ReviewService).refresh(
23
+ const service = yield* ReviewService
24
+ const { log } = createLoggers(options)
25
+ if (options.subcommand === "finish") {
26
+ const result = yield* service.finish(
27
+ options.taskId,
28
+ options.cwd,
29
+ options.ifRevision,
30
+ )
31
+ log(
32
+ options.json
33
+ ? JSON.stringify(result, null, 2)
34
+ : `Finished review '${options.taskId}'`,
35
+ )
36
+ return
37
+ }
38
+ const result = yield* service.refresh(
21
39
  options.taskId,
22
40
  options.cwd,
23
41
  options.ifRevision,
24
42
  )
25
- const { log } = createLoggers(options)
26
43
  log(
27
44
  options.json
28
45
  ? JSON.stringify(result, null, 2)
@@ -31,8 +48,12 @@ export const review = (options: ReviewOptions) =>
31
48
  })
32
49
 
33
50
  export const help = `
34
- Usage: agency review refresh <task-id> [--if-revision <hash>] [--json]
51
+ Usage: agency review <refresh|finish> <task-id> [--if-revision <hash>] [--json]
35
52
 
36
- Fetch the review source explicitly and replace the pinned commit and any clean,
37
- detached review checkout. Review sources never move implicitly.
53
+ Subcommands:
54
+ refresh Fetch the review source explicitly and replace the pinned commit
55
+ and any clean, detached review checkout. Review sources never
56
+ move implicitly.
57
+ finish Mark an open, working, or delegated review task done. Reviews
58
+ have no delivery pull request and need no completion summary.
38
59
  `
@@ -138,6 +138,7 @@ Options:
138
138
 
139
139
  Sync may materialize declared repositories and unambiguous missing checkouts,
140
140
  adopt legacy repositories with portable origins, record a uniquely matched PR,
141
- and mark merged work done. Dirty, stale, or
141
+ adopt a recorded PR's retargeted base or renamed head branch, and mark merged
142
+ work done. Dirty, stale, or
142
143
  conflicting checkouts are always left unresolved.
143
144
  `
@@ -424,6 +424,51 @@ export class ReviewService extends Effect.Service<ReviewService>()(
424
424
  }),
425
425
  )
426
426
  }),
427
+
428
+ finish: (
429
+ taskId: string,
430
+ startPath: string = process.cwd(),
431
+ ifRevision?: string,
432
+ ) =>
433
+ Effect.gen(function* () {
434
+ const workbase = yield* WorkbaseService
435
+ const tasks = yield* TaskService
436
+ const root = yield* workbase.discover(startPath)
437
+ const task = yield* tasks.show(taskId, root)
438
+ if (!("review" in task.data)) {
439
+ return yield* new ReviewError({
440
+ message: `Task '${taskId}' is not a review task`,
441
+ })
442
+ }
443
+ if (ifRevision && task.revision !== ifRevision) {
444
+ return yield* new RevisionConflictError({
445
+ path: task.path,
446
+ target: `task '${taskId}'`,
447
+ expectedRevision: ifRevision,
448
+ currentRevision: task.revision,
449
+ message: `Revision conflict for task '${taskId}'`,
450
+ })
451
+ }
452
+ if (task.data.status === "done" || task.data.status === "dropped") {
453
+ return yield* new ReviewError({
454
+ message: `Cannot finish review task '${taskId}' from ${task.data.status}; reopen it first`,
455
+ })
456
+ }
457
+ const parsed = yield* parseFrontmatter(task.content, task.path)
458
+ const data = { ...task.data, status: "done" as const }
459
+ const content = formatMarkdownDocument(data, parsed.body)
460
+ yield* runLifecycleTransaction({
461
+ root,
462
+ preconditions: [{ path: task.path, revision: task.revision }],
463
+ steps: [documentWriteStep(root, [{ path: task.path, content }])],
464
+ })
465
+ return {
466
+ id: taskId,
467
+ path: task.path,
468
+ revision: documentRevision(content),
469
+ data,
470
+ }
471
+ }),
427
472
  }),
428
473
  },
429
474
  ) {}
@@ -58,7 +58,12 @@ interface RegisteredWorktree {
58
58
  }
59
59
 
60
60
  interface SyncChange {
61
- readonly kind: "materialize-workspace" | "record-pr" | "mark-done"
61
+ readonly kind:
62
+ | "materialize-workspace"
63
+ | "adopt-pr-branch"
64
+ | "adopt-pr-base"
65
+ | "record-pr"
66
+ | "mark-done"
62
67
  readonly target: string
63
68
  readonly message: string
64
69
  readonly status: "planned" | "applied"
@@ -162,14 +167,34 @@ interface PullRequestQuery {
162
167
  | undefined
163
168
  }
164
169
 
170
+ const canonicalRecord = (record: PullRequestRecord | null) =>
171
+ record
172
+ ? JSON.stringify(
173
+ Object.entries(record)
174
+ .filter(([, value]) => value !== undefined)
175
+ .sort(([a], [b]) => a.localeCompare(b)),
176
+ )
177
+ : null
178
+
179
+ const samePullRequestRecord = (
180
+ current: PullRequestRecord,
181
+ existing: PullRequestRecord | null,
182
+ ) => canonicalRecord(current) === canonicalRecord(existing)
183
+
184
+ const belongsToWritableRepository = (
185
+ current: PullRequestRecord,
186
+ remoteRepository: string,
187
+ ) =>
188
+ current.headRepository?.toLowerCase() === remoteRepository.toLowerCase() &&
189
+ current.baseRepository?.toLowerCase() === current.repository.toLowerCase()
190
+
165
191
  const mergedPullRequestFromGitHub = (
166
192
  data: ExecutionData,
167
193
  query: PullRequestQuery | undefined,
168
194
  details: readonly PullRequestRecord[] | undefined,
169
195
  ) => {
170
196
  if (!query?.result || query.result.exitCode !== 0 || !details) return null
171
- const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
172
- const matches = existing
197
+ const matches = data.pr
173
198
  ? details
174
199
  : details.filter(
175
200
  (item) =>
@@ -177,18 +202,10 @@ const mergedPullRequestFromGitHub = (
177
202
  )
178
203
  if (matches.length !== 1) return null
179
204
  const current = matches[0]!
180
- if (
181
- current.merged !== true ||
182
- current.headRepository?.toLowerCase() !==
183
- query.remoteRepository.toLowerCase() ||
184
- current.headBranch !== data.branch ||
185
- current.baseRepository?.toLowerCase() !==
186
- current.repository.toLowerCase() ||
187
- current.baseBranch !== data.base
188
- ) {
189
- return null
190
- }
191
- return current
205
+ return current.merged === true &&
206
+ belongsToWritableRepository(current, query.remoteRepository)
207
+ ? current
208
+ : null
192
209
  }
193
210
 
194
211
  const mutateExecution = (
@@ -650,6 +667,22 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
650
667
  { concurrency: 8 },
651
668
  )
652
669
 
670
+ const branchOwners = new Map<string, string>()
671
+ const branchKey = (repo: string, branch: string) =>
672
+ JSON.stringify([repo, branch])
673
+ for (const task of allTaskRecords) {
674
+ if ("repo" in task.data)
675
+ branchOwners.set(
676
+ branchKey(task.data.repo, task.data.branch),
677
+ `task:${task.id}`,
678
+ )
679
+ for (const phase of documents.phasesByTask.get(task.id) ?? [])
680
+ branchOwners.set(
681
+ branchKey(phase.data.repo, phase.data.branch),
682
+ `phase:${task.id}/${phase.id}`,
683
+ )
684
+ }
685
+
653
686
  for (const record of records.sort((a, b) =>
654
687
  a.key.localeCompare(b.key),
655
688
  )) {
@@ -662,6 +695,126 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
662
695
  const githubDetails = githubResponse?.ok
663
696
  ? githubResponse.details
664
697
  : undefined
698
+ let renamedFrom: string | null = null
699
+ let adoptionConflict = false
700
+ const recordedPr =
701
+ !config.delivery && data.pr ? githubDetails?.[0] : undefined
702
+ if (
703
+ query &&
704
+ recordedPr?.headBranch &&
705
+ recordedPr.baseBranch &&
706
+ belongsToWritableRepository(recordedPr, query.remoteRepository) &&
707
+ (recordedPr.headBranch !== data.branch ||
708
+ recordedPr.baseBranch !== data.base)
709
+ ) {
710
+ const previous = { branch: data.branch, base: data.base }
711
+ const adopted = {
712
+ branch: recordedPr.headBranch,
713
+ base: recordedPr.baseBranch,
714
+ }
715
+ const repositoryPath = join(root, "repos", data.repo)
716
+ let renameLocal = false
717
+ if (adopted.branch !== previous.branch) {
718
+ const owner = branchOwners.get(
719
+ branchKey(data.repo, adopted.branch),
720
+ )
721
+ const [previousLocal, adoptedLocal] = yield* Effect.all([
722
+ backend.resolveRevision(
723
+ repositoryPath,
724
+ `refs/heads/${previous.branch}`,
725
+ ),
726
+ backend.resolveRevision(
727
+ repositoryPath,
728
+ `refs/heads/${adopted.branch}`,
729
+ ),
730
+ ])
731
+ if (owner && owner !== record.key) {
732
+ adoptionConflict = true
733
+ unresolved.push({
734
+ kind: "pr-branch-conflict",
735
+ target: record.key,
736
+ message: `Recorded PR head '${adopted.branch}' is declared by '${owner}'`,
737
+ action:
738
+ "Correct the recorded PR URL or the other declaration",
739
+ })
740
+ } else if (previousLocal && adoptedLocal) {
741
+ adoptionConflict = true
742
+ unresolved.push({
743
+ kind: "pr-branch-conflict",
744
+ target: record.key,
745
+ message: `Cannot rename local branch '${previous.branch}' to recorded PR head '${adopted.branch}' because it already exists`,
746
+ action: `Reconcile or delete local branch '${adopted.branch}' manually`,
747
+ })
748
+ } else {
749
+ renameLocal = previousLocal !== null
750
+ }
751
+ }
752
+ if (!adoptionConflict && apply) {
753
+ const renameBranch = (from: string, to: string) =>
754
+ runExternal(["git", "branch", "-m", from, to], {
755
+ cwd: repositoryPath,
756
+ })
757
+ const renamed = renameLocal
758
+ ? yield* renameBranch(previous.branch, adopted.branch)
759
+ : null
760
+ if (renamed && renamed.exitCode !== 0) {
761
+ adoptionConflict = true
762
+ unresolved.push({
763
+ kind: "pr-branch-rename-failed",
764
+ target: record.key,
765
+ message: `Could not rename local branch '${previous.branch}' to '${adopted.branch}': ${commandErrorSummary(renamed.stderr, "git branch failed")}`,
766
+ action: "Rename the local branch manually",
767
+ })
768
+ } else {
769
+ const updated = yield* mutateExecution(root, record, revision, {
770
+ ...data,
771
+ ...adopted,
772
+ }).pipe(
773
+ Effect.tapError(() =>
774
+ renamed
775
+ ? renameBranch(adopted.branch, previous.branch)
776
+ : Effect.void,
777
+ ),
778
+ )
779
+ data = updated.data
780
+ revision = updated.revision
781
+ const cached = registeredByRepository.get(repositoryPath)
782
+ for (const [index, item] of (cached ?? []).entries()) {
783
+ if (item.branch === `refs/heads/${previous.branch}`)
784
+ cached![index] = {
785
+ ...item,
786
+ branch: `refs/heads/${adopted.branch}`,
787
+ }
788
+ }
789
+ }
790
+ } else if (!adoptionConflict) {
791
+ data = { ...data, ...adopted }
792
+ if (renameLocal) renamedFrom = previous.branch
793
+ }
794
+ if (!adoptionConflict) {
795
+ if (adopted.branch !== previous.branch) {
796
+ branchOwners.delete(branchKey(data.repo, previous.branch))
797
+ branchOwners.set(
798
+ branchKey(data.repo, adopted.branch),
799
+ record.key,
800
+ )
801
+ changes.push({
802
+ kind: "adopt-pr-branch",
803
+ target: record.key,
804
+ message: `Adopt pull request head '${adopted.branch}' (was '${previous.branch}')`,
805
+ status: apply ? "applied" : "planned",
806
+ })
807
+ }
808
+ if (adopted.base !== previous.base) {
809
+ changes.push({
810
+ kind: "adopt-pr-base",
811
+ target: record.key,
812
+ message: `Adopt pull request base '${adopted.base}' (was '${previous.base}')`,
813
+ status: apply ? "applied" : "planned",
814
+ })
815
+ }
816
+ }
817
+ }
665
818
  const remoteMergedPr = config.delivery
666
819
  ? null
667
820
  : mergedPullRequestFromGitHub(data, query, githubDetails)
@@ -838,7 +991,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
838
991
  action: "Review and preserve or discard local changes manually",
839
992
  })
840
993
  }
841
- if (atPath && branchRef && atPath.branch !== branchRef) {
994
+ if (
995
+ atPath &&
996
+ branchRef &&
997
+ atPath.branch !== branchRef &&
998
+ !(renamedFrom && atPath.branch === `refs/heads/${renamedFrom}`)
999
+ ) {
842
1000
  unresolved.push({
843
1001
  kind: "wrong-branch",
844
1002
  target: record.key,
@@ -1022,21 +1180,26 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1022
1180
  if (githubDetails) {
1023
1181
  current = githubDetails[0]!
1024
1182
  pr = current
1025
- if (
1026
- current.headRepository?.toLowerCase() !==
1027
- remoteRepository.toLowerCase() ||
1028
- current.headBranch !== data.branch ||
1029
- current.baseRepository?.toLowerCase() !==
1030
- current.repository.toLowerCase() ||
1031
- current.baseBranch !== data.base
1032
- ) {
1183
+ if (!belongsToWritableRepository(current, remoteRepository)) {
1033
1184
  prConflict = true
1034
1185
  unresolved.push({
1035
1186
  kind: "pr-repository-conflict",
1036
1187
  target: record.key,
1037
- message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
1188
+ message: `Recorded PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
1038
1189
  action: "Correct the declaration or recorded PR URL",
1039
1190
  })
1191
+ } else if (
1192
+ current.headBranch !== data.branch ||
1193
+ current.baseBranch !== data.base
1194
+ ) {
1195
+ prConflict = true
1196
+ if (!adoptionConflict)
1197
+ unresolved.push({
1198
+ kind: "pr-branch-conflict",
1199
+ target: record.key,
1200
+ message: `Recorded PR head '${current.headBranch ?? "unknown"}' or base '${current.baseBranch ?? "unknown"}' could not be adopted`,
1201
+ action: "Correct the declaration or recorded PR URL",
1202
+ })
1040
1203
  }
1041
1204
  }
1042
1205
  } else {
@@ -1092,7 +1255,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1092
1255
  }
1093
1256
  }
1094
1257
 
1095
- if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
1258
+ if (current && !samePullRequestRecord(current, existing)) {
1096
1259
  if (apply) {
1097
1260
  const recorded = yield* mutateExecution(root, record, revision, {
1098
1261
  ...data,
@@ -652,6 +652,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
652
652
  !canTransitionStatus(record.data.status, validStatus) &&
653
653
  !completionResult
654
654
  ) {
655
+ if (
656
+ validStatus === "done" &&
657
+ "review" in record.data &&
658
+ record.data.status !== "dropped"
659
+ ) {
660
+ return yield* new TaskError({
661
+ message: `Finish review task '${id}' with 'agency review finish ${id}'; reviews do not require a completion summary`,
662
+ })
663
+ }
655
664
  if (validStatus === "done") {
656
665
  return yield* new TaskError({
657
666
  message:
@@ -108,6 +108,8 @@ retain `--if-revision` guards when shown, and do not add flags that are not show
108
108
  `agency context <new-task> --json`. Do not prepare or start it unless requested.
109
109
  15. Refresh a pinned review task with the current revision:
110
110
  `agency review refresh <task> --if-revision <revision> --json`.
111
+ 16. Finish a review task with the current revision; no summary is required:
112
+ `agency review finish <task> --if-revision <revision> --json`.
111
113
 
112
114
  Never pass `--work` or `--auto` to `agency task create`. Do not run separate
113
115
  `agency validate`, `agency worktree prepare`, `agency graph`, `agency task list`,
@@ -182,8 +184,8 @@ agency validate --json
182
184
  Require explicit user intent before initializing a workbase; changing repository
183
185
  aliases or applying repository setup or workbase sync changes; launching another
184
186
  agent from an active agent session; creating a pull request; archiving, restoring,
185
- dropping, reopening, or completing work without a pull request; or using `--force`
186
- to override readiness.
187
+ dropping, reopening, finishing a review, or completing work without a pull
188
+ request; or using `--force` to override readiness.
187
189
 
188
190
  ## Investigation Handoffs
189
191
 
@@ -272,7 +274,9 @@ its pull request is open. It becomes `done` only after its authoritative pull
272
274
  request is merged and Agency reconciles that state. Do not mark committed or
273
275
  review-ready work `done` manually. A genuine investigation, operational action,
274
276
  or no-change result may complete without a pull request only with explicit user
275
- intent, `--no-pull-request`, and a durable outcome summary.
277
+ intent, `--no-pull-request`, and a durable outcome summary. A review task has no
278
+ delivery pull request; with explicit user intent, finish it with
279
+ `agency review finish` instead, which requires no summary.
276
280
 
277
281
  At each closeout trigger (creating or updating a PR, marking it ready, completing
278
282
  a refinement loop, or pausing or handing off completed implementation work):