@markjaquith/agency 2.23.0 → 2.25.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,
@@ -659,6 +683,7 @@ try {
659
683
  }
660
684
  if (error instanceof Error) {
661
685
  let message = error.message
686
+ let details: any = error
662
687
 
663
688
  // Handle Effect FiberFailure errors that wrap tagged errors
664
689
  // When the message is generic "An error has occurred", try to extract the actual error
@@ -671,6 +696,7 @@ try {
671
696
  const cause = (error as any)[causeSymbol]
672
697
  if (cause && cause._tag === "Fail" && cause.failure) {
673
698
  const failure = cause.failure
699
+ details = failure
674
700
  // Try common error message patterns
675
701
  message =
676
702
  failure.message ||
@@ -681,6 +707,14 @@ try {
681
707
  }
682
708
  }
683
709
  }
710
+ for (const [field, label] of [
711
+ ["completed", "Completed"],
712
+ ["rolledBack", "Rolled back"],
713
+ ["manualRecovery", "Manual recovery"],
714
+ ] as const) {
715
+ if (Array.isArray(details[field]) && details[field].length > 0)
716
+ message += `\n${label}: ${details[field].join("; ")}`
717
+ }
684
718
 
685
719
  console.error(`ⓘ ${message}`)
686
720
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.23.0",
3
+ "version": "2.25.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"],
@@ -260,6 +292,15 @@ describe("strict CLI parsing", () => {
260
292
  )
261
293
  })
262
294
 
295
+ test("accepts archive dry-run", () => {
296
+ expect(parseCli(["archive", "task", "example", "--dry-run"])).toMatchObject(
297
+ {
298
+ commandName: "archive",
299
+ values: { "dry-run": true },
300
+ },
301
+ )
302
+ })
303
+
263
304
  test("validates revision-guarded claim operations", () => {
264
305
  const revision = "0".repeat(64)
265
306
  expect(
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,58 @@ 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
 
66
+ test("preflights an archive without moving the task", async () => {
67
+ const logs = await captureLogs(() =>
68
+ runTestEffect(
69
+ archive({
70
+ type: "task",
71
+ args: ["example"],
72
+ cwd: root,
73
+ dryRun: true,
74
+ json: true,
75
+ }),
76
+ ),
77
+ )
78
+
79
+ expect(JSON.parse(logs[0]!).dryRun).toBe(true)
80
+ expect(await Bun.file(join(root, "tasks/example/TASK.md")).exists()).toBe(
81
+ true,
82
+ )
83
+ expect(await Bun.file(join(root, "archive/tasks/example")).exists()).toBe(
84
+ false,
85
+ )
86
+ })
87
+
64
88
  test("requires a supported work item type", async () => {
65
89
  await expect(
66
90
  runTestEffect(archive({ args: [], cwd: root, silent: true })),
67
- ).rejects.toThrow("Available: epic, task, phase")
91
+ ).rejects.toThrow("Available: list, show, epic, task, phase")
92
+ })
93
+
94
+ test("rejects an extra archive show identifier", async () => {
95
+ await runTestEffect(
96
+ archive({ type: "task", args: ["example"], cwd: root, silent: true }),
97
+ )
98
+ await expect(
99
+ runTestEffect(
100
+ archive({
101
+ type: "show",
102
+ args: ["task", "example", "extra"],
103
+ cwd: root,
104
+ silent: true,
105
+ }),
106
+ ),
107
+ ).rejects.toThrow("Usage: agency archive show")
68
108
  })
69
109
  })
@@ -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,116 @@ 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
116
+ ? `Would archive ${result.kind} '${result.id}' to ${result.path}`
117
+ : `Archived ${result.kind} '${result.id}' to ${result.path}`,
55
118
  )
56
119
  })
57
120
 
58
121
  export const help = `
59
- Usage: agency archive <type> <id>
122
+ Usage: agency archive <list|show|epic|task|phase>
60
123
 
61
- Archive a work item after removing its worktrees. Branches are preserved.
124
+ Browse or archive work items after preflighting worktrees and graph references.
62
125
 
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
126
+ Commands:
127
+ list [filters] List archived work
128
+ show <type> <id> Show an archived epic or task
129
+ show phase <task-id> <phase-id> Show an archived phase
130
+ epic <epic-id> Archive an epic and its tasks
131
+ task <task-id> Archive a task
132
+ phase <task-id> <phase-id> Archive a phase
67
133
 
68
134
  Options:
69
- --json Output results as JSON
135
+ --kind <kind> Filter list by kind (repeatable)
136
+ --status <status> Filter list by status (repeatable)
137
+ --repository <alias> Filter list by repository (repeatable)
138
+ --dry-run Preflight without changing files
139
+ --json Output results as JSON
70
140
  `
@@ -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
+ })