@markjaquith/agency 2.23.0 → 2.24.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
@@ -574,15 +574,24 @@ runner environment documented above for a later release or finish operation.
574
574
  ### Archive
575
575
 
576
576
  ```text
577
- agency archive epic <epic-id> [--json]
578
- agency archive task <task-id> [--json]
579
- agency archive phase <task-id> <phase-id> [--json]
577
+ agency archive list [--kind <kind>] [--status <status>] [--repository <alias>]
578
+ agency archive show <epic|task> <id>
579
+ agency archive show phase <task-id> <phase-id>
580
+ agency archive epic <epic-id> [--dry-run] [--json]
581
+ agency archive task <task-id> [--dry-run] [--json]
582
+ agency archive phase <task-id> <phase-id> [--dry-run] [--json]
583
+ agency restore epic <epic-id> [--dry-run] [--json]
584
+ agency restore task <task-id> [--dry-run] [--json]
585
+ agency restore phase <task-id> <phase-id> [--dry-run] [--json]
580
586
  ```
581
587
 
582
588
  Archived work keeps its hierarchy under `archive/`. Epic archiving includes its
583
589
  listed tasks. Task and phase archiving update the active parent document and
584
590
  reject items that active siblings depend on. Agency removes registered worktrees
585
- before moving files, refuses dirty worktrees, and preserves branches.
591
+ before moving files, refuses dirty worktrees, and preserves branches. Archive and
592
+ restore preflight all destinations and graph references before changing files.
593
+ Versioned lifecycle provenance preserves parent declarations and dependency edges
594
+ for restoration. Archived IDs are reserved until restored.
586
595
 
587
596
  ### Work and Pull Requests
588
597
 
package/cli.ts CHANGED
@@ -17,6 +17,7 @@ import { repo, help as repoHelp } from "./src/commands/repo"
17
17
  import { epic, help as epicHelp } from "./src/commands/epic"
18
18
  import { phase, help as phaseHelp } from "./src/commands/phase"
19
19
  import { archive, help as archiveHelp } from "./src/commands/archive"
20
+ import { restore, help as restoreHelp } from "./src/commands/restore"
20
21
  import { workbase, help as workbaseHelp } from "./src/commands/workbase"
21
22
  import {
22
23
  integration,
@@ -317,6 +318,29 @@ const commands: Record<string, Command> = {
317
318
  type: args[0],
318
319
  args: args.slice(1),
319
320
  json: options.json,
321
+ dryRun: options["dry-run"],
322
+ kinds: options.kind,
323
+ statuses: options.status,
324
+ repositories: options.repository,
325
+ silent: options.silent,
326
+ verbose: options.verbose,
327
+ cwd: options.cwd,
328
+ }),
329
+ )
330
+ },
331
+ },
332
+ restore: {
333
+ run: async (args: string[], options: Record<string, any>) => {
334
+ if (options.help) {
335
+ console.log(restoreHelp)
336
+ return
337
+ }
338
+ await runCommand(
339
+ restore({
340
+ type: args[0],
341
+ args: args.slice(1),
342
+ json: options.json,
343
+ dryRun: options["dry-run"],
320
344
  silent: options.silent,
321
345
  verbose: options.verbose,
322
346
  cwd: options.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -248,14 +248,22 @@ agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outco
248
248
  ## Archive Completed Work
249
249
 
250
250
  ```bash
251
+ agency archive list
252
+ agency archive show <epic|task> <id>
251
253
  agency archive epic <epic-id>
252
254
  agency archive task <task-id>
253
255
  agency archive phase <task-id> <phase-id>
256
+ agency restore epic <epic-id>
257
+ agency restore task <task-id>
258
+ agency restore phase <task-id> <phase-id>
254
259
  ```
255
260
 
256
261
  Use these commands instead of moving work item folders manually. Agency mirrors
257
262
  their hierarchy under `archive/`, removes registered worktrees first, and keeps
258
- branches. It refuses dirty worktrees and active sibling dependencies.
263
+ branches. It refuses dirty worktrees and active sibling dependencies. Use
264
+ `--dry-run` to preflight archive or restore. Restore uses lifecycle provenance to
265
+ recreate parent declarations and rejects ID, backlink, dependency, and destination
266
+ conflicts. Archived IDs remain reserved until restored.
259
267
 
260
268
  ## Validate Every Structural Change
261
269
 
@@ -75,6 +75,27 @@ describe("strict CLI parsing", () => {
75
75
  reference: ["two:main", "three:main"],
76
76
  "depends-on": ["first", "second"],
77
77
  })
78
+ expect(
79
+ parseCli([
80
+ "archive",
81
+ "list",
82
+ "--kind",
83
+ "task",
84
+ "--kind",
85
+ "phase",
86
+ "--status",
87
+ "done",
88
+ "--repository",
89
+ "agency",
90
+ ]).values,
91
+ ).toMatchObject({
92
+ kind: ["task", "phase"],
93
+ status: ["done"],
94
+ repository: ["agency"],
95
+ })
96
+ expect(
97
+ parseCli(["restore", "task", "example", "--dry-run"]).values,
98
+ ).toMatchObject({ "dry-run": true })
78
99
  })
79
100
 
80
101
  test("parses composable view filters", () => {
@@ -110,6 +131,9 @@ describe("strict CLI parsing", () => {
110
131
  expect(() => parseCli(["status", "--pr", "--no-pr"])).toThrow(
111
132
  "cannot be combined",
112
133
  )
134
+ expect(() => parseCli(["archive", "list", "--status", "invalid"])).toThrow(
135
+ "Invalid '--status' value",
136
+ )
113
137
  })
114
138
 
115
139
  test("enforces exact maximum positional arity for every leaf command", () => {
@@ -170,6 +194,14 @@ describe("strict CLI parsing", () => {
170
194
  [["archive", "epic", "one", "two"], "agency archive epic"],
171
195
  [["archive", "task", "one", "two"], "agency archive task"],
172
196
  [["archive", "phase", "one", "two", "three"], "agency archive phase"],
197
+ [["archive", "list", "extra"], "agency archive list"],
198
+ [
199
+ ["archive", "show", "task", "one", "extra", "more"],
200
+ "agency archive show",
201
+ ],
202
+ [["restore", "epic", "one", "two"], "agency restore epic"],
203
+ [["restore", "task", "one", "two"], "agency restore task"],
204
+ [["restore", "phase", "one", "two", "three"], "agency restore phase"],
173
205
  [["work", "one", "two"], "agency work"],
174
206
  [["pr", "create", "one", "two", "three"], "agency pr create"],
175
207
  [["status", "extra"], "agency status"],
package/src/cli-parser.ts CHANGED
@@ -543,26 +543,76 @@ const commands = {
543
543
  },
544
544
  },
545
545
  archive: {
546
- usage: "agency archive <epic|task|phase>",
547
- options: { ...outputOptions, ...entitySelectorOptions },
546
+ usage: "agency archive <list|show|epic|task|phase>",
547
+ options: {
548
+ ...outputOptions,
549
+ ...entitySelectorOptions,
550
+ "dry-run": { type: "boolean" },
551
+ kind: { type: "string", multiple: true },
552
+ status: { type: "string", multiple: true },
553
+ repository: { type: "string", multiple: true },
554
+ },
548
555
  subcommands: {
556
+ list: {
557
+ usage:
558
+ "agency archive list [--kind <kind>] [--status <status>] [--repository <alias>] [--json]",
559
+ minArgs: 0,
560
+ maxArgs: 0,
561
+ options: ["kind", "status", "repository", "json"],
562
+ repeatable: ["kind", "status", "repository"],
563
+ },
564
+ show: {
565
+ usage:
566
+ "agency archive show <epic|task> <id> | phase <task-id> <phase-id> [--json]",
567
+ minArgs: 2,
568
+ maxArgs: 3,
569
+ options: ["json"],
570
+ },
549
571
  epic: {
550
- usage: "agency archive epic <epic-id> [--json]",
572
+ usage: "agency archive epic <epic-id> [--dry-run] [--json]",
551
573
  minArgs: 1,
552
574
  maxArgs: 1,
553
- options: ["json", "epic"],
575
+ options: ["dry-run", "json", "epic"],
554
576
  },
555
577
  task: {
556
- usage: "agency archive task <task-id> [--json]",
578
+ usage: "agency archive task <task-id> [--dry-run] [--json]",
557
579
  minArgs: 1,
558
580
  maxArgs: 1,
559
- options: ["json", "task"],
581
+ options: ["dry-run", "json", "task"],
560
582
  },
561
583
  phase: {
562
- usage: "agency archive phase <task-id> <phase-id> [--json]",
584
+ usage: "agency archive phase <task-id> <phase-id> [--dry-run] [--json]",
563
585
  minArgs: 2,
564
586
  maxArgs: 2,
565
- options: ["json", "task", "phase"],
587
+ options: ["dry-run", "json", "task", "phase"],
588
+ },
589
+ },
590
+ },
591
+ restore: {
592
+ usage: "agency restore <epic|task|phase>",
593
+ options: {
594
+ ...outputOptions,
595
+ ...entitySelectorOptions,
596
+ "dry-run": { type: "boolean" },
597
+ },
598
+ subcommands: {
599
+ epic: {
600
+ usage: "agency restore epic <epic-id> [--dry-run] [--json]",
601
+ minArgs: 1,
602
+ maxArgs: 1,
603
+ options: ["dry-run", "json", "epic"],
604
+ },
605
+ task: {
606
+ usage: "agency restore task <task-id> [--dry-run] [--json]",
607
+ minArgs: 1,
608
+ maxArgs: 1,
609
+ options: ["dry-run", "json", "task"],
610
+ },
611
+ phase: {
612
+ usage: "agency restore phase <task-id> <phase-id> [--dry-run] [--json]",
613
+ minArgs: 2,
614
+ maxArgs: 2,
615
+ options: ["dry-run", "json", "task", "phase"],
566
616
  },
567
617
  },
568
618
  },
@@ -789,12 +839,14 @@ const targetSlots = (
789
839
  return subcommand === "list" ? ["task"] : ["task", "phase"]
790
840
  if (["claim", "release", "finish"].includes(commandName))
791
841
  return ["task", "phase"]
792
- if (commandName === "archive")
842
+ if (["archive", "restore"].includes(commandName))
793
843
  return subcommand === "epic"
794
844
  ? ["epic"]
795
845
  : subcommand === "task"
796
846
  ? ["task"]
797
- : ["task", "phase"]
847
+ : subcommand === "phase"
848
+ ? ["task", "phase"]
849
+ : []
798
850
  if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
799
851
  return []
800
852
  }
@@ -1150,6 +1202,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1150
1202
  }
1151
1203
  if (
1152
1204
  commandName === "status" ||
1205
+ (commandName === "archive" && subcommand === "list") ||
1153
1206
  (["epic", "task", "phase"].includes(commandName) && subcommand === "list")
1154
1207
  ) {
1155
1208
  validateViewOptions(parsed.values, spec)
package/src/cli.test.ts CHANGED
@@ -345,6 +345,7 @@ describe("CLI", () => {
345
345
  ["task", "Usage: agency task"],
346
346
  ["phase", "Usage: agency phase"],
347
347
  ["archive", "Usage: agency archive"],
348
+ ["restore", "Usage: agency restore"],
348
349
  ["work", "Usage: agency work"],
349
350
  ["pr", "Usage: agency pr"],
350
351
  ["status", "Usage: agency status"],
@@ -52,18 +52,36 @@ describe("archive command", () => {
52
52
  ),
53
53
  )
54
54
 
55
- expect(JSON.parse(logs[0]!)).toEqual({
55
+ expect(JSON.parse(logs[0]!)).toMatchObject({
56
+ operation: "archive",
56
57
  kind: "task",
57
58
  id: "example",
58
59
  path: join(root, "archive/tasks/example"),
59
- archivedPaths: [join(root, "archive/tasks/example")],
60
+ affectedPaths: [join(root, "archive/tasks/example")],
60
61
  removedWorktrees: [],
62
+ dryRun: false,
61
63
  })
62
64
  })
63
65
 
64
66
  test("requires a supported work item type", async () => {
65
67
  await expect(
66
68
  runTestEffect(archive({ args: [], cwd: root, silent: true })),
67
- ).rejects.toThrow("Available: epic, task, phase")
69
+ ).rejects.toThrow("Available: list, show, epic, task, phase")
70
+ })
71
+
72
+ test("rejects an extra archive show identifier", async () => {
73
+ await runTestEffect(
74
+ archive({ type: "task", args: ["example"], cwd: root, silent: true }),
75
+ )
76
+ await expect(
77
+ runTestEffect(
78
+ archive({
79
+ type: "show",
80
+ args: ["task", "example", "extra"],
81
+ cwd: root,
82
+ silent: true,
83
+ }),
84
+ ),
85
+ ).rejects.toThrow("Usage: agency archive show")
68
86
  })
69
87
  })
@@ -7,6 +7,15 @@ interface ArchiveOptions extends BaseCommandOptions {
7
7
  readonly type?: string
8
8
  readonly args: readonly string[]
9
9
  readonly json?: boolean
10
+ readonly dryRun?: boolean
11
+ readonly kinds?: readonly string[]
12
+ readonly statuses?: readonly string[]
13
+ readonly repositories?: readonly string[]
14
+ }
15
+
16
+ const archiveKind = (value: string | undefined) => {
17
+ if (value === "epic" || value === "task" || value === "phase") return value
18
+ return undefined
10
19
  }
11
20
 
12
21
  export const archive = (options: ArchiveOptions) =>
@@ -16,55 +25,114 @@ export const archive = (options: ArchiveOptions) =>
16
25
  const cwd = options.cwd ?? process.cwd()
17
26
  const [id, phaseId] = options.args
18
27
 
28
+ if (options.type === "list") {
29
+ const records = yield* archives.list(
30
+ {
31
+ kinds: options.kinds,
32
+ statuses: options.statuses,
33
+ repositories: options.repositories,
34
+ },
35
+ cwd,
36
+ )
37
+ log(
38
+ options.json
39
+ ? JSON.stringify(records, null, 2)
40
+ : records
41
+ .map((record) =>
42
+ record.kind === "phase"
43
+ ? `phase\t${record.taskId}/${record.id}`
44
+ : `${record.kind}\t${record.id}`,
45
+ )
46
+ .join("\n"),
47
+ )
48
+ return
49
+ }
50
+
51
+ if (options.type === "show") {
52
+ const [kindValue, firstId, secondId] = options.args
53
+ const kind = archiveKind(kindValue)
54
+ if (
55
+ !kind ||
56
+ !firstId ||
57
+ (kind === "phase" ? !secondId : secondId !== undefined)
58
+ ) {
59
+ return yield* Effect.fail(
60
+ new Error(
61
+ "Usage: agency archive show <epic|task> <id> | phase <task-id> <phase-id>",
62
+ ),
63
+ )
64
+ }
65
+ const record = yield* archives.show(
66
+ kind,
67
+ kind === "phase" ? secondId! : firstId,
68
+ kind === "phase" ? firstId : undefined,
69
+ cwd,
70
+ )
71
+ log(options.json ? JSON.stringify(record, null, 2) : record.content)
72
+ return
73
+ }
74
+
19
75
  let result
20
76
  switch (options.type) {
21
77
  case "epic":
22
- if (!id) {
78
+ if (!id)
23
79
  return yield* Effect.fail(
24
80
  new Error("Usage: agency archive epic <epic-id>"),
25
81
  )
26
- }
27
- result = yield* archives.archiveEpic(id, cwd)
82
+ result = yield* archives.archiveEpic(id, cwd, {
83
+ dryRun: options.dryRun,
84
+ })
28
85
  break
29
86
  case "task":
30
- if (!id) {
87
+ if (!id)
31
88
  return yield* Effect.fail(
32
89
  new Error("Usage: agency archive task <task-id>"),
33
90
  )
34
- }
35
- result = yield* archives.archiveTask(id, cwd)
91
+ result = yield* archives.archiveTask(id, cwd, {
92
+ dryRun: options.dryRun,
93
+ })
36
94
  break
37
95
  case "phase":
38
- if (!id || !phaseId) {
96
+ if (!id || !phaseId)
39
97
  return yield* Effect.fail(
40
98
  new Error("Usage: agency archive phase <task-id> <phase-id>"),
41
99
  )
42
- }
43
- result = yield* archives.archivePhase(id, phaseId, cwd)
100
+ result = yield* archives.archivePhase(id, phaseId, cwd, {
101
+ dryRun: options.dryRun,
102
+ })
44
103
  break
45
104
  default:
46
105
  return yield* Effect.fail(
47
- new Error("Work item type is required. Available: epic, task, phase"),
106
+ new Error(
107
+ "Archive operation is required. Available: list, show, epic, task, phase",
108
+ ),
48
109
  )
49
110
  }
50
111
 
51
112
  log(
52
113
  options.json
53
114
  ? JSON.stringify(result, null, 2)
54
- : `Archived ${result.kind} '${result.id}' to ${result.path}`,
115
+ : `${result.dryRun ? "Would archive" : "Archived"} ${result.kind} '${result.id}' to ${result.path}`,
55
116
  )
56
117
  })
57
118
 
58
119
  export const help = `
59
- Usage: agency archive <type> <id>
120
+ Usage: agency archive <list|show|epic|task|phase>
60
121
 
61
- Archive a work item after removing its worktrees. Branches are preserved.
122
+ Browse or archive work items after preflighting worktrees and graph references.
62
123
 
63
- Types:
64
- epic <epic-id> Archive an epic and its tasks
65
- task <task-id> Archive a task
66
- phase <task-id> <phase-id> Archive a phase
124
+ Commands:
125
+ list [filters] List archived work
126
+ show <type> <id> Show an archived epic or task
127
+ show phase <task-id> <phase-id> Show an archived phase
128
+ epic <epic-id> Archive an epic and its tasks
129
+ task <task-id> Archive a task
130
+ phase <task-id> <phase-id> Archive a phase
67
131
 
68
132
  Options:
69
- --json Output results as JSON
133
+ --kind <kind> Filter list by kind (repeatable)
134
+ --status <status> Filter list by status (repeatable)
135
+ --repository <alias> Filter list by repository (repeatable)
136
+ --dry-run Preflight without changing files
137
+ --json Output results as JSON
70
138
  `
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { join } from "node:path"
3
+ import {
4
+ captureLogs,
5
+ cleanupTempDir,
6
+ createTempDir,
7
+ runTestEffect,
8
+ } from "../test-utils"
9
+ import { archive } from "./archive"
10
+ import { restore } from "./restore"
11
+ import { task } from "./task"
12
+
13
+ describe("restore command", () => {
14
+ let root: string
15
+
16
+ beforeEach(async () => {
17
+ root = await createTempDir()
18
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
19
+ const initialized = Bun.spawnSync([
20
+ "git",
21
+ "init",
22
+ "--bare",
23
+ join(root, "repos/agency"),
24
+ ])
25
+ if (initialized.exitCode !== 0)
26
+ throw new Error(new TextDecoder().decode(initialized.stderr))
27
+ await runTestEffect(
28
+ task({
29
+ subcommand: "create",
30
+ args: ["example"],
31
+ repo: "agency",
32
+ branch: "task/example",
33
+ base: "main",
34
+ cwd: root,
35
+ silent: true,
36
+ }),
37
+ )
38
+ await runTestEffect(
39
+ archive({ type: "task", args: ["example"], cwd: root, silent: true }),
40
+ )
41
+ })
42
+
43
+ afterEach(async () => cleanupTempDir(root))
44
+
45
+ test("dry-runs then restores an archived task", async () => {
46
+ const preview = await captureLogs(() =>
47
+ runTestEffect(
48
+ restore({
49
+ type: "task",
50
+ args: ["example"],
51
+ cwd: root,
52
+ dryRun: true,
53
+ }),
54
+ ),
55
+ )
56
+ expect(preview[0]).toContain("Would restore task 'example'")
57
+ expect(await Bun.file(join(root, "tasks/example/TASK.md")).exists()).toBe(
58
+ false,
59
+ )
60
+
61
+ const logs = await captureLogs(() =>
62
+ runTestEffect(
63
+ restore({ type: "task", args: ["example"], cwd: root, json: true }),
64
+ ),
65
+ )
66
+ expect(JSON.parse(logs[0]!)).toMatchObject({
67
+ operation: "restore",
68
+ kind: "task",
69
+ id: "example",
70
+ dryRun: false,
71
+ })
72
+ expect(await Bun.file(join(root, "tasks/example/TASK.md")).exists()).toBe(
73
+ true,
74
+ )
75
+ })
76
+
77
+ test("lists and shows archived work", async () => {
78
+ const listed = await captureLogs(() =>
79
+ runTestEffect(
80
+ archive({
81
+ type: "list",
82
+ args: [],
83
+ kinds: ["task"],
84
+ repositories: ["agency"],
85
+ cwd: root,
86
+ }),
87
+ ),
88
+ )
89
+ expect(listed).toEqual(["task\texample"])
90
+
91
+ const shown = await captureLogs(() =>
92
+ runTestEffect(
93
+ archive({ type: "show", args: ["task", "example"], cwd: root }),
94
+ ),
95
+ )
96
+ expect(shown[0]).toContain("# Example")
97
+ })
98
+ })
@@ -0,0 +1,73 @@
1
+ import { Effect } from "effect"
2
+ import { ArchiveService } from "../services/ArchiveService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface RestoreOptions extends BaseCommandOptions {
7
+ readonly type?: string
8
+ readonly args: readonly string[]
9
+ readonly json?: boolean
10
+ readonly dryRun?: boolean
11
+ }
12
+
13
+ export const restore = (options: RestoreOptions) =>
14
+ Effect.gen(function* () {
15
+ const archives = yield* ArchiveService
16
+ const { log } = createLoggers(options)
17
+ const cwd = options.cwd ?? process.cwd()
18
+ const [id, phaseId] = options.args
19
+ let result
20
+ switch (options.type) {
21
+ case "epic":
22
+ if (!id)
23
+ return yield* Effect.fail(
24
+ new Error("Usage: agency restore epic <epic-id>"),
25
+ )
26
+ result = yield* archives.restoreEpic(id, cwd, {
27
+ dryRun: options.dryRun,
28
+ })
29
+ break
30
+ case "task":
31
+ if (!id)
32
+ return yield* Effect.fail(
33
+ new Error("Usage: agency restore task <task-id>"),
34
+ )
35
+ result = yield* archives.restoreTask(id, cwd, {
36
+ dryRun: options.dryRun,
37
+ })
38
+ break
39
+ case "phase":
40
+ if (!id || !phaseId)
41
+ return yield* Effect.fail(
42
+ new Error("Usage: agency restore phase <task-id> <phase-id>"),
43
+ )
44
+ result = yield* archives.restorePhase(id, phaseId, cwd, {
45
+ dryRun: options.dryRun,
46
+ })
47
+ break
48
+ default:
49
+ return yield* Effect.fail(
50
+ new Error("Work item type is required. Available: epic, task, phase"),
51
+ )
52
+ }
53
+ log(
54
+ options.json
55
+ ? JSON.stringify(result, null, 2)
56
+ : `${result.dryRun ? "Would restore" : "Restored"} ${result.kind} '${result.id}' to ${result.path}`,
57
+ )
58
+ })
59
+
60
+ export const help = `
61
+ Usage: agency restore <epic|task|phase>
62
+
63
+ Restore archived work after preflighting IDs, backlinks, dependencies, and paths.
64
+
65
+ Commands:
66
+ epic <epic-id> Restore an epic and its tasks
67
+ task <task-id> Restore a task
68
+ phase <task-id> <phase-id> Restore a phase
69
+
70
+ Options:
71
+ --dry-run Preflight without changing files
72
+ --json Output results as JSON
73
+ `