@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 +13 -4
- package/cli.ts +24 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +9 -1
- package/src/cli-parser.test.ts +32 -0
- package/src/cli-parser.ts +63 -10
- package/src/cli.test.ts +1 -0
- package/src/commands/archive.test.ts +21 -3
- package/src/commands/archive.ts +86 -18
- package/src/commands/restore.test.ts +98 -0
- package/src/commands/restore.ts +73 -0
- package/src/services/ArchiveService.test.ts +520 -1
- package/src/services/ArchiveService.ts +964 -84
- package/src/services/EpicService.ts +6 -0
- package/src/services/PhaseService.ts +6 -0
- package/src/services/TaskService.ts +6 -0
- package/src/services/WorktreeService.ts +35 -5
- package/src/workbase/archive.ts +18 -0
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
|
|
578
|
-
agency archive task <
|
|
579
|
-
agency archive phase <task-id> <phase-id>
|
|
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
package/skills/agency/SKILL.md
CHANGED
|
@@ -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
|
|
package/src/cli-parser.test.ts
CHANGED
|
@@ -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: {
|
|
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 (
|
|
842
|
+
if (["archive", "restore"].includes(commandName))
|
|
793
843
|
return subcommand === "epic"
|
|
794
844
|
? ["epic"]
|
|
795
845
|
: subcommand === "task"
|
|
796
846
|
? ["task"]
|
|
797
|
-
:
|
|
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]!)).
|
|
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
|
-
|
|
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
|
})
|
package/src/commands/archive.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
:
|
|
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 <
|
|
120
|
+
Usage: agency archive <list|show|epic|task|phase>
|
|
60
121
|
|
|
61
|
-
|
|
122
|
+
Browse or archive work items after preflighting worktrees and graph references.
|
|
62
123
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
phase <task-id> <phase-id>
|
|
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
|
-
--
|
|
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
|
+
`
|