@markjaquith/agency 3.14.0 → 3.16.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.
@@ -1223,6 +1229,35 @@ parent epic implicitly. Versioned lifecycle provenance preserves parent
1223
1229
  declarations and dependency edges for restoration. Archived IDs are reserved
1224
1230
  until restored.
1225
1231
 
1232
+ #### Automatic archiving
1233
+
1234
+ ```text
1235
+ agency config auto-archive # show the effective setting
1236
+ agency config auto-archive on # enable for this workbase
1237
+ agency config auto-archive off # disable
1238
+ ```
1239
+
1240
+ The boolean `autoArchive` setting in the workbase's `agency.json` defaults to
1241
+ `false`. Enabling it authorizes automatic task archiving on future terminal
1242
+ transitions; it does not sweep existing terminal work. `agency act --json`
1243
+ lists these configuration commands, and context/status JSON includes the
1244
+ effective setting.
1245
+
1246
+ Explicit `task status` / `phase status` changes to `done` or `dropped`, non-PR
1247
+ completion, `review finish`, and merged-PR reconciliation automatically
1248
+ attempt to archive the owning task. A multi-phase task waits until every phase
1249
+ is terminal; individual phases and epics are never auto-archived. Sync defers
1250
+ cleanup until its reconciliation pass finishes, and `sync --dry-run` never
1251
+ archives anything.
1252
+
1253
+ Automatic cleanup uses the same preflight as `archive task --dry-run`, without
1254
+ forcing past dirty checkouts, retained dependents, or other safety
1255
+ failures. A blocked attempt leaves the terminal status intact and the task
1256
+ unarchived, with the reason reported in human output and the triggering command's
1257
+ JSON `autoArchive` result (`sync` returns an array). After resolving the blocker,
1258
+ use explicit `archive` to retry safely. Successful automatic archiving removes
1259
+ clean managed checkouts, so paths inside the task may cease to exist.
1260
+
1226
1261
  ### Work, Publication, and Pull Requests
1227
1262
 
1228
1263
  ```text
package/cli-main.ts CHANGED
@@ -10,6 +10,7 @@ import { push, help as pushHelp } from "./src/commands/push"
10
10
  import { work, workPrepare, help as workHelp } from "./src/commands/work"
11
11
  import { worktree, help as worktreeHelp } from "./src/commands/worktree"
12
12
  import { status, help as statusHelp } from "./src/commands/status"
13
+ import { config, help as configHelp } from "./src/commands/config"
13
14
  import { doctor, help as doctorHelp } from "./src/commands/doctor"
14
15
  import { validate, help as validateHelp } from "./src/commands/validate"
15
16
  import { context, help as contextHelp } from "./src/commands/context"
@@ -579,6 +580,23 @@ const commands: Record<string, Command> = {
579
580
  )
580
581
  },
581
582
  },
583
+ config: {
584
+ async run(args: string[], options: Record<string, any>) {
585
+ if (options.help) {
586
+ console.log(configHelp)
587
+ return
588
+ }
589
+ await runCommand(
590
+ config({
591
+ silent: options.silent,
592
+ verbose: options.verbose,
593
+ json: options.json,
594
+ cwd: options.cwd,
595
+ args,
596
+ }),
597
+ )
598
+ },
599
+ },
582
600
  status: {
583
601
  run: async (_args: string[], options: Record<string, any>) => {
584
602
  if (options.help) {
@@ -726,9 +744,10 @@ Commands:
726
744
  next List or select ready execution units
727
745
  pr create / pr [...] Create an Agency PR or run gh pr with repository focus
728
746
  push Validate and publish the current execution unit
729
- review refresh Explicitly refresh a pinned review task
747
+ review refresh|finish Refresh or finish a pinned review task
730
748
  repo <subcommand> Manage workbase repositories
731
749
  status Show status for the current workbase
750
+ config Show or change workbase settings
732
751
  doctor Diagnose workbase health and integrations
733
752
  validate [path] Validate a workbase
734
753
  context [target] Return complete target context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.14.0",
3
+ "version": "3.16.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/act-schema.ts CHANGED
@@ -64,6 +64,7 @@ export const ActDiscovery = Schema.Struct({
64
64
  }),
65
65
  workbase: Schema.Struct({
66
66
  root: Schema.String,
67
+ autoArchive: Schema.optional(Schema.Boolean),
67
68
  repositories: Argv,
68
69
  actions: Schema.Array(Action),
69
70
  }),
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: {
@@ -951,6 +958,18 @@ const commands = {
951
958
  options: ["select", "json"],
952
959
  },
953
960
  },
961
+ config: {
962
+ usage: "agency config auto-archive [on|off] [--json]",
963
+ options: outputOptions,
964
+ subcommands: {
965
+ "auto-archive": {
966
+ usage: "agency config auto-archive [on|off] [--json]",
967
+ minArgs: 0,
968
+ maxArgs: 1,
969
+ options: ["json"],
970
+ },
971
+ },
972
+ },
954
973
  status: {
955
974
  usage: "agency status [filters] [--json]",
956
975
  options: { ...outputOptions, ...viewOptions },
@@ -14,6 +14,7 @@ import { phase } from "./phase"
14
14
  import { push } from "./push"
15
15
  import { sync } from "./sync"
16
16
  import { status } from "./status"
17
+ import { config } from "./config"
17
18
  import { pr, prCreate } from "./pr"
18
19
  import { validate } from "./validate"
19
20
  import { work as startWork, type StartWork } from "./work"
@@ -33,6 +34,7 @@ type NativeOperation =
33
34
  | ReturnType<typeof push>
34
35
  | ReturnType<typeof sync>
35
36
  | ReturnType<typeof status>
37
+ | ReturnType<typeof config>
36
38
  | ReturnType<typeof doctor>
37
39
  | ReturnType<typeof integration>
38
40
  | ReturnType<typeof validate>
@@ -188,6 +190,22 @@ const actionPresentation: Record<
188
190
  icon: "󰄬",
189
191
  color: macchiato.green,
190
192
  },
193
+ "auto-archive-show": {
194
+ description: "Show the effective automatic archive setting.",
195
+ icon: "",
196
+ color: macchiato.blue,
197
+ },
198
+ "auto-archive-enable": {
199
+ description:
200
+ "Automatically archive tasks on future terminal transitions, subject to safety checks.",
201
+ icon: "",
202
+ color: macchiato.green,
203
+ },
204
+ "auto-archive-disable": {
205
+ description: "Leave terminal tasks active until explicitly archived.",
206
+ icon: "",
207
+ color: macchiato.yellow,
208
+ },
191
209
  doctor: {
192
210
  description: "Diagnose workbase, repository, and integration health.",
193
211
  icon: "󰒡",
@@ -234,6 +252,11 @@ const actionPresentation: Record<
234
252
  icon: "󰑐",
235
253
  color: macchiato.sapphire,
236
254
  },
255
+ "finish-review": {
256
+ description: "Mark this review done; no completion summary is required.",
257
+ icon: "󰄬",
258
+ color: macchiato.green,
259
+ },
237
260
  reopen: {
238
261
  description: "Return terminal work to open status.",
239
262
  icon: "󰑓",
@@ -371,7 +394,7 @@ export const actionGroups = [
371
394
  id: "review",
372
395
  label: "Review someone else's work",
373
396
  icon: "󰍉",
374
- actions: ["review", "review-ref"],
397
+ actions: ["review", "review-ref", "finish-review"],
375
398
  },
376
399
  {
377
400
  id: "split",
@@ -436,6 +459,9 @@ export const actionGroups = [
436
459
  "sync-all",
437
460
  "integration-status",
438
461
  "integration-sync",
462
+ "auto-archive-show",
463
+ "auto-archive-enable",
464
+ "auto-archive-disable",
439
465
  ],
440
466
  },
441
467
  {
@@ -825,6 +851,25 @@ export const actActions = (
825
851
  }),
826
852
  ),
827
853
  ),
854
+ ...(["show", "enable", "disable"] as const).map((operation) => {
855
+ const args = [
856
+ "auto-archive",
857
+ ...(operation === "show"
858
+ ? []
859
+ : [operation === "enable" ? "on" : "off"]),
860
+ ]
861
+ return immediate(
862
+ {
863
+ id: `auto-archive-${operation}`,
864
+ label: `${operation === "show" ? "Show" : operation === "enable" ? "Enable" : "Disable"} auto archive`,
865
+ blockedReason: null,
866
+ },
867
+ {
868
+ command: ["agency", "config", ...args],
869
+ run: config({ ...options, args }),
870
+ },
871
+ )
872
+ }),
828
873
  immediate(
829
874
  { id: "validate", label: "Validate workbase", blockedReason: null },
830
875
  {
@@ -875,6 +920,7 @@ export const actActions = (
875
920
  const purpose =
876
921
  parent && "purpose" in parent.data ? parent.data.purpose : undefined
877
922
  const terminal = node.status === "done" || node.status === "dropped"
923
+ const isReview = node.kind === "task" && "review" in node.data
878
924
  const blockers = (execution ?? node).readiness.blockers
879
925
  const validation = blockers.find(
880
926
  (blocker) => blocker.kind === "validation",
@@ -1044,9 +1090,7 @@ export const actActions = (
1044
1090
  details(
1045
1091
  "review-refresh",
1046
1092
  "Refresh review source",
1047
- node.kind === "task" && "review" in node.data
1048
- ? null
1049
- : "Select a review task",
1093
+ isReview ? null : "Select a review task",
1050
1094
  ),
1051
1095
  {
1052
1096
  command: [
@@ -1065,6 +1109,33 @@ export const actActions = (
1065
1109
  }),
1066
1110
  },
1067
1111
  ),
1112
+ immediate(
1113
+ details(
1114
+ "finish-review",
1115
+ "Finish review",
1116
+ !isReview
1117
+ ? "Select a review task"
1118
+ : terminal
1119
+ ? "Review is already terminal"
1120
+ : null,
1121
+ ),
1122
+ {
1123
+ command: [
1124
+ "agency",
1125
+ "review",
1126
+ "finish",
1127
+ target.taskId,
1128
+ "--if-revision",
1129
+ node.data.sha256,
1130
+ ],
1131
+ run: review({
1132
+ ...options,
1133
+ subcommand: "finish",
1134
+ taskId: target.taskId,
1135
+ ifRevision: node.data.sha256,
1136
+ }),
1137
+ },
1138
+ ),
1068
1139
  ...(["reopen", "drop"] as const).map((id) => {
1069
1140
  const status = id === "reopen" ? "open" : "dropped"
1070
1141
  return immediate(
@@ -1300,11 +1371,13 @@ export const actActions = (
1300
1371
  "complete",
1301
1372
  "Complete without a pull request",
1302
1373
  noExecution ??
1303
- (terminal
1304
- ? "Item is already terminal"
1305
- : prUrl
1306
- ? "Reconcile the recorded pull request instead"
1307
- : null),
1374
+ (isReview
1375
+ ? "Use Finish review for review tasks"
1376
+ : terminal
1377
+ ? "Item is already terminal"
1378
+ : prUrl
1379
+ ? "Reconcile the recorded pull request instead"
1380
+ : null),
1308
1381
  ),
1309
1382
  inputs: [
1310
1383
  input("summary", "Completed outcome summary", { multiline: true }),
@@ -481,6 +481,7 @@ const actStep = (
481
481
  creationDefaults: creationDefaults(config),
482
482
  workbase: {
483
483
  root,
484
+ autoArchive: config.autoArchive ?? false,
484
485
  repositories: graph.nodes
485
486
  .filter((node) => node.kind === "repository")
486
487
  .map((node) => node.key),
@@ -790,6 +791,7 @@ A workbase path opens Workbase actions. Otherwise the positional value is a
790
791
  task ID. Selectors skip item selection.
791
792
  JSON discovery includes creation defaults identifying when callers should omit
792
793
  --branch so the workbase branchNameCommand can choose it.
794
+ JSON discovery also includes the effective auto-archive setting and its commands.
793
795
 
794
796
  Options:
795
797
  --action <id> Start an action or filter discovery (IDs from --json)
@@ -0,0 +1,43 @@
1
+ import { Effect } from "effect"
2
+ import { WorkbaseService } from "../services/WorkbaseService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ export const config = (
7
+ options: BaseCommandOptions & { readonly args: readonly string[] },
8
+ ) =>
9
+ Effect.gen(function* () {
10
+ const [setting, value] = options.args
11
+ if (
12
+ setting !== "auto-archive" ||
13
+ (value !== undefined && value !== "on" && value !== "off")
14
+ ) {
15
+ return yield* Effect.fail(
16
+ new Error("Usage: agency config auto-archive [on|off]"),
17
+ )
18
+ }
19
+ const workbase = yield* WorkbaseService
20
+ const { log } = createLoggers(options)
21
+ const result =
22
+ value === undefined
23
+ ? yield* workbase.loadConfig(options.cwd).pipe(
24
+ Effect.map(({ root, config }) => ({
25
+ root,
26
+ autoArchive: config.autoArchive ?? false,
27
+ })),
28
+ )
29
+ : yield* workbase.setAutoArchive(value === "on", options.cwd)
30
+ log(
31
+ options.json
32
+ ? JSON.stringify(result, null, 2)
33
+ : `Auto archive: ${result.autoArchive ? "on" : "off"}`,
34
+ )
35
+ })
36
+
37
+ export const help = `
38
+ Usage: agency config auto-archive [on|off] [--json]
39
+
40
+ Show, enable, or disable automatic archiving in this workbase.
41
+ Defaults to off. Enabling applies to future terminal transitions, not existing
42
+ terminal work. Archive safety checks are never bypassed.
43
+ `
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { autoArchiveMessage } from "../services/auto-archive"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { PhaseService } from "../services/PhaseService"
4
5
  import { createLoggers } from "../utils/effect"
@@ -199,6 +200,8 @@ export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
199
200
  ? JSON.stringify(output, null, 2)
200
201
  : `Marked phase '${phaseId}' as ${record.data.status}`,
201
202
  )
203
+ const notice = autoArchiveMessage(record.autoArchive)
204
+ if (!options.json && notice) log(notice)
202
205
  return
203
206
  }
204
207
  case "update": {
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { ReviewService } from "../services/ReviewService"
3
+ import { autoArchiveMessage } from "../services/auto-archive"
3
4
  import type { BaseCommandOptions } from "../utils/command"
4
5
  import { createLoggers } from "../utils/effect"
5
6
 
@@ -12,17 +13,36 @@ interface ReviewOptions extends BaseCommandOptions {
12
13
 
13
14
  export const review = (options: ReviewOptions) =>
14
15
  Effect.gen(function* () {
15
- if (options.subcommand !== "refresh" || !options.taskId) {
16
+ if (
17
+ (options.subcommand !== "refresh" && options.subcommand !== "finish") ||
18
+ !options.taskId
19
+ ) {
16
20
  return yield* Effect.fail(
17
- new Error("Usage: agency review refresh <task>"),
21
+ new Error("Usage: agency review <refresh|finish> <task>"),
18
22
  )
19
23
  }
20
- const result = yield* (yield* ReviewService).refresh(
24
+ const service = yield* ReviewService
25
+ const { log } = createLoggers(options)
26
+ if (options.subcommand === "finish") {
27
+ const result = yield* service.finish(
28
+ options.taskId,
29
+ options.cwd,
30
+ options.ifRevision,
31
+ )
32
+ log(
33
+ options.json
34
+ ? JSON.stringify(result, null, 2)
35
+ : `Finished review '${options.taskId}'`,
36
+ )
37
+ const notice = autoArchiveMessage(result.autoArchive)
38
+ if (!options.json && notice) log(notice)
39
+ return
40
+ }
41
+ const result = yield* service.refresh(
21
42
  options.taskId,
22
43
  options.cwd,
23
44
  options.ifRevision,
24
45
  )
25
- const { log } = createLoggers(options)
26
46
  log(
27
47
  options.json
28
48
  ? JSON.stringify(result, null, 2)
@@ -31,8 +51,12 @@ export const review = (options: ReviewOptions) =>
31
51
  })
32
52
 
33
53
  export const help = `
34
- Usage: agency review refresh <task-id> [--if-revision <hash>] [--json]
54
+ Usage: agency review <refresh|finish> <task-id> [--if-revision <hash>] [--json]
35
55
 
36
- Fetch the review source explicitly and replace the pinned commit and any clean,
37
- detached review checkout. Review sources never move implicitly.
56
+ Subcommands:
57
+ refresh Fetch the review source explicitly and replace the pinned commit
58
+ and any clean, detached review checkout. Review sources never
59
+ move implicitly.
60
+ finish Mark an open, working, or delegated review task done. Reviews
61
+ have no delivery pull request and need no completion summary.
38
62
  `
@@ -34,7 +34,13 @@ export const status = (options: StatusOptions = {}) =>
34
34
  pr: options.pr,
35
35
  })).executionRows
36
36
  : []
37
- const data = { ...report, repositories: repos, work: executionRows }
37
+ const { config } = yield* workbase.loadConfig(cwd)
38
+ const data = {
39
+ ...report,
40
+ autoArchive: config.autoArchive ?? false,
41
+ repositories: repos,
42
+ work: executionRows,
43
+ }
38
44
 
39
45
  if (options.json) {
40
46
  log(JSON.stringify(data, null, 2))
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { SyncService } from "../services/SyncService"
3
+ import { autoArchiveMessage } from "../services/auto-archive"
3
4
  import type { BaseCommandOptions } from "../utils/command"
4
5
  import { createLoggers } from "../utils/effect"
5
6
  import { createProgress, type Progress } from "../utils/progress"
@@ -87,6 +88,10 @@ export const sync = (
87
88
  `${change.status === "applied" ? "Applied" : "Planned"} ${change.kind} '${change.target}': ${change.message}`,
88
89
  )
89
90
  }
91
+ for (const archive of result.autoArchive) {
92
+ const notice = autoArchiveMessage(archive)
93
+ if (notice) log(notice)
94
+ }
90
95
  for (const { notice: warning, targets } of groupedNotices(
91
96
  result.warnings,
92
97
  )) {
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { autoArchiveMessage } from "../services/auto-archive"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { TaskService } from "../services/TaskService"
4
5
  import { EpicService } from "../services/EpicService"
@@ -491,6 +492,8 @@ export const task = (
491
492
  ? JSON.stringify(output, null, 2)
492
493
  : `Marked task '${id}' as ${record.data.status}`,
493
494
  )
495
+ const notice = autoArchiveMessage(record.autoArchive)
496
+ if (!options.json && notice) log(notice)
494
497
  return
495
498
  }
496
499
  case "update": {
@@ -329,6 +329,7 @@ export class ContextService extends Effect.Service<ContextService>()(
329
329
  root,
330
330
  configPath: join(root, "agency.json"),
331
331
  version: config.version,
332
+ autoArchive: config.autoArchive ?? false,
332
333
  vcs: "git",
333
334
  },
334
335
  target: { kind: "workbase", path: root },
@@ -1174,6 +1175,7 @@ export class ContextService extends Effect.Service<ContextService>()(
1174
1175
  root,
1175
1176
  configPath: join(root, "agency.json"),
1176
1177
  version: config.version,
1178
+ autoArchive: config.autoArchive ?? false,
1177
1179
  vcs: "git",
1178
1180
  },
1179
1181
  target,
@@ -17,7 +17,8 @@ import {
17
17
  formatWorkDocumentBody,
18
18
  parseFrontmatter,
19
19
  } from "../workbase/frontmatter"
20
- import { canTransitionStatus } from "../readiness"
20
+ import { canTransitionStatus, isTerminalStatus } from "../readiness"
21
+ import { autoArchiveTask, type AutoArchiveResult } from "./auto-archive"
21
22
  import { documentRevision } from "../workbase/document-revision"
22
23
  import { archivedPhaseDirectory } from "../workbase/archive"
23
24
  import {
@@ -43,6 +44,7 @@ export interface PhaseRecord {
43
44
  readonly content: string
44
45
  readonly revision: string
45
46
  readonly data: PhaseData
47
+ readonly autoArchive?: AutoArchiveResult
46
48
  }
47
49
 
48
50
  export interface CreatePhaseInput {
@@ -573,11 +575,15 @@ export class PhaseService extends Effect.Service<PhaseService>()(
573
575
  : { ...record.data, status: validStatus }
574
576
  const content = formatMarkdownDocument(data, parsed.body)
575
577
  yield* fs.writeFile(record.path, content)
578
+ const autoArchive = isTerminalStatus(data.status)
579
+ ? yield* autoArchiveTask(taskId, startPath)
580
+ : undefined
576
581
  return {
577
582
  ...record,
578
583
  content,
579
584
  revision: documentRevision(content),
580
585
  data,
586
+ ...(autoArchive ? { autoArchive } : {}),
581
587
  } satisfies PhaseRecord
582
588
  }),
583
589
  }),
@@ -7,6 +7,7 @@ import { PhaseService } from "./PhaseService"
7
7
  import { RepositoryService } from "./RepositoryService"
8
8
  import { TaskService } from "./TaskService"
9
9
  import { WorkbaseService } from "./WorkbaseService"
10
+ import { autoArchiveTask } from "./auto-archive"
10
11
  import {
11
12
  WorktreeService,
12
13
  type WorktreeRemovalSnapshot,
@@ -424,6 +425,52 @@ export class ReviewService extends Effect.Service<ReviewService>()(
424
425
  }),
425
426
  )
426
427
  }),
428
+
429
+ finish: (
430
+ taskId: string,
431
+ startPath: string = process.cwd(),
432
+ ifRevision?: string,
433
+ ) =>
434
+ Effect.gen(function* () {
435
+ const workbase = yield* WorkbaseService
436
+ const tasks = yield* TaskService
437
+ const root = yield* workbase.discover(startPath)
438
+ const task = yield* tasks.show(taskId, root)
439
+ if (!("review" in task.data)) {
440
+ return yield* new ReviewError({
441
+ message: `Task '${taskId}' is not a review task`,
442
+ })
443
+ }
444
+ if (ifRevision && task.revision !== ifRevision) {
445
+ return yield* new RevisionConflictError({
446
+ path: task.path,
447
+ target: `task '${taskId}'`,
448
+ expectedRevision: ifRevision,
449
+ currentRevision: task.revision,
450
+ message: `Revision conflict for task '${taskId}'`,
451
+ })
452
+ }
453
+ if (task.data.status === "done" || task.data.status === "dropped") {
454
+ return yield* new ReviewError({
455
+ message: `Cannot finish review task '${taskId}' from ${task.data.status}; reopen it first`,
456
+ })
457
+ }
458
+ const parsed = yield* parseFrontmatter(task.content, task.path)
459
+ const data = { ...task.data, status: "done" as const }
460
+ const content = formatMarkdownDocument(data, parsed.body)
461
+ yield* runLifecycleTransaction({
462
+ root,
463
+ preconditions: [{ path: task.path, revision: task.revision }],
464
+ steps: [documentWriteStep(root, [{ path: task.path, content }])],
465
+ })
466
+ return {
467
+ id: taskId,
468
+ path: task.path,
469
+ revision: documentRevision(content),
470
+ data,
471
+ autoArchive: yield* autoArchiveTask(taskId, root),
472
+ }
473
+ }),
427
474
  }),
428
475
  },
429
476
  ) {}
@@ -31,6 +31,7 @@ import {
31
31
  type RepositorySetupResult,
32
32
  } from "./RepositoryService"
33
33
  import { VersionControlService } from "./VersionControlService"
34
+ import { autoArchiveTask, type AutoArchiveResult } from "./auto-archive"
34
35
 
35
36
  class SyncError extends Data.TaggedError("SyncError")<{
36
37
  readonly message: string
@@ -111,6 +112,7 @@ interface SyncResult {
111
112
  readonly unresolved: readonly SyncNotice[]
112
113
  readonly executions: readonly ExecutionSyncState[]
113
114
  readonly repositories: RepositorySetupResult
115
+ readonly autoArchive: readonly AutoArchiveResult[]
114
116
  }
115
117
 
116
118
  export interface SyncProgress {
@@ -370,6 +372,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
370
372
 
371
373
  const apply = options.apply === true
372
374
  const changes: SyncChange[] = []
375
+ const completedTasks = new Set<string>()
373
376
  const warnings: SyncNotice[] = []
374
377
  const unresolved: SyncNotice[] = []
375
378
  for (const issue of repositorySetup.unresolved) {
@@ -1285,6 +1288,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1285
1288
  })
1286
1289
  data = completed.data
1287
1290
  revision = completed.revision
1291
+ completedTasks.add(record.taskId)
1288
1292
  }
1289
1293
  changes.push({
1290
1294
  kind: "mark-done",
@@ -1372,6 +1376,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1372
1376
  reportExecution(`task:${task.id}`)
1373
1377
  }
1374
1378
 
1379
+ const autoArchive: AutoArchiveResult[] = []
1380
+ if (apply && config.autoArchive) {
1381
+ for (const taskId of completedTasks)
1382
+ autoArchive.push(yield* autoArchiveTask(taskId, root))
1383
+ }
1375
1384
  return {
1376
1385
  root,
1377
1386
  mode: apply ? "apply" : "dry-run",
@@ -1380,6 +1389,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1380
1389
  unresolved,
1381
1390
  executions,
1382
1391
  repositories: repositorySetup,
1392
+ autoArchive,
1383
1393
  } satisfies SyncResult
1384
1394
  }),
1385
1395
  }),
@@ -22,7 +22,8 @@ import {
22
22
  parseFrontmatter,
23
23
  parseFrontmatterSync,
24
24
  } from "../workbase/frontmatter"
25
- import { canTransitionStatus } from "../readiness"
25
+ import { canTransitionStatus, isTerminalStatus } from "../readiness"
26
+ import { autoArchiveTask, type AutoArchiveResult } from "./auto-archive"
26
27
  import { documentRevision } from "../workbase/document-revision"
27
28
  import { archivedTaskDirectory } from "../workbase/archive"
28
29
  import {
@@ -47,6 +48,7 @@ export interface TaskRecord {
47
48
  readonly content: string
48
49
  readonly revision: string
49
50
  readonly data: TaskData
51
+ readonly autoArchive?: AutoArchiveResult
50
52
  }
51
53
 
52
54
  export interface CreateTaskInput {
@@ -652,6 +654,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
652
654
  !canTransitionStatus(record.data.status, validStatus) &&
653
655
  !completionResult
654
656
  ) {
657
+ if (
658
+ validStatus === "done" &&
659
+ "review" in record.data &&
660
+ record.data.status !== "dropped"
661
+ ) {
662
+ return yield* new TaskError({
663
+ message: `Finish review task '${id}' with 'agency review finish ${id}'; reviews do not require a completion summary`,
664
+ })
665
+ }
655
666
  if (validStatus === "done") {
656
667
  return yield* new TaskError({
657
668
  message:
@@ -675,11 +686,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
675
686
  : { ...record.data, status: validStatus }
676
687
  const content = formatMarkdownDocument(data, parsed.body)
677
688
  yield* fs.writeFile(record.path, content)
689
+ const autoArchive = isTerminalStatus(data.status)
690
+ ? yield* autoArchiveTask(id, startPath)
691
+ : undefined
678
692
  return {
679
693
  ...record,
680
694
  content,
681
695
  revision: documentRevision(content),
682
696
  data,
697
+ ...(autoArchive ? { autoArchive } : {}),
683
698
  } satisfies TaskRecord
684
699
  }),
685
700
  }),
@@ -416,6 +416,19 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
416
416
  return { root, config: decoded.value }
417
417
  }),
418
418
 
419
+ setAutoArchive: (enabled: boolean, startPath: string = process.cwd()) =>
420
+ Effect.gen(function* () {
421
+ const service = yield* WorkbaseService
422
+ const fs = yield* FileSystemService
423
+ const { root, config } = yield* service.loadConfig(startPath)
424
+ const updated = { ...config, autoArchive: enabled }
425
+ yield* fs.writeFile(
426
+ join(root, "agency.json"),
427
+ `${JSON.stringify(updated, null, 2)}\n`,
428
+ )
429
+ return { root, autoArchive: enabled }
430
+ }),
431
+
419
432
  repositoryAliases: (startPath: string = process.cwd()) =>
420
433
  Effect.gen(function* () {
421
434
  const service = yield* WorkbaseService
@@ -0,0 +1,82 @@
1
+ import { Effect } from "effect"
2
+ import type {
3
+ GitVersionControlService,
4
+ VersionControlService,
5
+ } from "./VersionControlService"
6
+ import type { ArchiveService } from "./ArchiveService"
7
+ import { WorkbaseService } from "./WorkbaseService"
8
+ import type { FileSystemService } from "./FileSystemService"
9
+ import { TaskService } from "./TaskService"
10
+ import { PhaseService } from "./PhaseService"
11
+ import type { EpicService } from "./EpicService"
12
+ import type { WorktreeService } from "./WorktreeService"
13
+ import { aggregateProgress, isTerminalStatus } from "../readiness"
14
+
15
+ export interface AutoArchiveResult {
16
+ readonly taskId: string
17
+ readonly status: "disabled" | "non-terminal" | "archived" | "skipped"
18
+ readonly path?: string
19
+ readonly reason?: string
20
+ }
21
+
22
+ // A terminal transition is durable even when archive safety checks reject cleanup.
23
+ // Explicit typing also keeps the lazy service references free of inference cycles.
24
+ export const autoArchiveTask = (
25
+ taskId: string,
26
+ startPath: string,
27
+ ): Effect.Effect<
28
+ AutoArchiveResult,
29
+ never,
30
+ | WorkbaseService
31
+ | ArchiveService
32
+ | FileSystemService
33
+ | TaskService
34
+ | PhaseService
35
+ | EpicService
36
+ | WorktreeService
37
+ | GitVersionControlService
38
+ | VersionControlService
39
+ > =>
40
+ Effect.gen(function* () {
41
+ const workbase = yield* WorkbaseService
42
+ const { root, config } = yield* workbase.loadConfig(startPath)
43
+ if (!config.autoArchive) return { taskId, status: "disabled" as const }
44
+ const tasks = yield* TaskService
45
+ const task = yield* tasks.show(taskId, root)
46
+ let status
47
+ if ("phases" in task.data) {
48
+ const phases = yield* PhaseService
49
+ const records = yield* Effect.forEach(task.data.phases, (phase) =>
50
+ phases.show(taskId, phase.id, root),
51
+ )
52
+ status = aggregateProgress(
53
+ records.map((phase) => phase.data.status),
54
+ ).status
55
+ } else status = task.data.status
56
+ if (!isTerminalStatus(status))
57
+ return { taskId, status: "non-terminal" as const }
58
+ // ArchiveService uses TaskService for preflight; resolve it only after the
59
+ // terminal write to avoid an eager module initialization cycle.
60
+ const { ArchiveService } = yield* Effect.promise(
61
+ () => import("./ArchiveService"),
62
+ )
63
+ const archives = yield* ArchiveService
64
+ // archiveTask always performs the same preflight as --dry-run before applying.
65
+ const result = yield* archives.archiveTask(taskId, root)
66
+ return { taskId, status: "archived" as const, path: result.path }
67
+ }).pipe(
68
+ Effect.catchAll((error) =>
69
+ Effect.succeed({
70
+ taskId,
71
+ status: "skipped" as const,
72
+ reason: error.message,
73
+ }),
74
+ ),
75
+ )
76
+
77
+ export const autoArchiveMessage = (result: AutoArchiveResult | undefined) =>
78
+ result?.status === "archived"
79
+ ? `Auto-archived task '${result.taskId}' to ${result.path}`
80
+ : result?.status === "skipped"
81
+ ? `Auto-archive skipped for task '${result.taskId}': ${result.reason}`
82
+ : undefined
@@ -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,18 @@ 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.
189
+
190
+ The workbase's `autoArchive` setting defaults to off. Enabling it with
191
+ `agency config auto-archive on` is standing consent for automatic task archiving
192
+ on terminal transitions, including merged-PR sync and explicit non-PR completion.
193
+ Show or disable it with `agency config auto-archive [off]`; context/status JSON
194
+ reports its effective value. Multi-phase tasks wait until every phase is terminal.
195
+ Archive preflight safeguards still apply: inspect the triggering command's
196
+ `autoArchive` result for skips, resolve their reasons, and never force cleanup.
197
+ Successful auto-archive can remove the current task directory and clean checkouts;
198
+ continue lifecycle commands from the workbase root rather than a removed path.
187
199
 
188
200
  ## Investigation Handoffs
189
201
 
@@ -272,7 +284,9 @@ its pull request is open. It becomes `done` only after its authoritative pull
272
284
  request is merged and Agency reconciles that state. Do not mark committed or
273
285
  review-ready work `done` manually. A genuine investigation, operational action,
274
286
  or no-change result may complete without a pull request only with explicit user
275
- intent, `--no-pull-request`, and a durable outcome summary.
287
+ intent, `--no-pull-request`, and a durable outcome summary. A review task has no
288
+ delivery pull request; with explicit user intent, finish it with
289
+ `agency review finish` instead, which requires no summary.
276
290
 
277
291
  At each closeout trigger (creating or updating a PR, marking it ready, completing
278
292
  a refinement loop, or pausing or handing off completed implementation work):
@@ -103,6 +103,7 @@ const DeliveryProvider = Schema.Struct({
103
103
  export const WorkbaseConfig = Schema.Struct({
104
104
  version: Schema.Literal(2),
105
105
  vcs: Schema.optional(Schema.Literal("git")),
106
+ autoArchive: Schema.optional(Schema.Boolean),
106
107
  repositories: Schema.optional(
107
108
  Schema.Record({ key: RepositoryAlias, value: RepositoryDeclaration }),
108
109
  ),