@markjaquith/agency 3.0.0 → 3.1.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 +6 -1
- package/cli-main.ts +10 -2
- package/package.json +1 -1
- package/src/cli-parser.test.ts +5 -0
- package/src/cli-parser.ts +12 -7
- package/src/cli.test.ts +3 -3
- package/src/commands/archive.test.ts +73 -1
- package/src/commands/archive.ts +18 -7
- package/src/services/ArchiveService.ts +59 -1
package/README.md
CHANGED
|
@@ -995,12 +995,17 @@ agency archive epic <epic-id> [--dry-run] [--json]
|
|
|
995
995
|
agency archive task <task-id> [--dry-run] [--json]
|
|
996
996
|
agency archive tasks [--dry-run] [--json]
|
|
997
997
|
agency archive phase <task-id> <phase-id> [--dry-run] [--json]
|
|
998
|
+
agency archive <path> [--dry-run] [--json]
|
|
998
999
|
agency restore epic <epic-id> [--dry-run] [--json]
|
|
999
1000
|
agency restore task <task-id> [--dry-run] [--json]
|
|
1000
1001
|
agency restore phase <task-id> <phase-id> [--dry-run] [--json]
|
|
1001
1002
|
```
|
|
1002
1003
|
|
|
1003
|
-
|
|
1004
|
+
An existing path within an active epic or task infers that work item, so
|
|
1005
|
+
`agency archive .` works from its directory. Collection roots are ambiguous,
|
|
1006
|
+
phase paths require the explicit `archive phase` form, and paths outside active
|
|
1007
|
+
epic or task trees are rejected. Archived work keeps its hierarchy under
|
|
1008
|
+
`archive/`. Epic archiving includes its
|
|
1004
1009
|
listed tasks. A task can be archived only when its effective status is terminal
|
|
1005
1010
|
(`done` or `dropped`). Multi-phase task status is derived from its phases, every
|
|
1006
1011
|
phase must be terminal, and a task with no phases is not eligible.
|
package/cli-main.ts
CHANGED
|
@@ -396,10 +396,18 @@ const commands: Record<string, Command> = {
|
|
|
396
396
|
console.log(archiveHelp)
|
|
397
397
|
return
|
|
398
398
|
}
|
|
399
|
+
const explicitType = [
|
|
400
|
+
"list",
|
|
401
|
+
"show",
|
|
402
|
+
"epic",
|
|
403
|
+
"task",
|
|
404
|
+
"tasks",
|
|
405
|
+
"phase",
|
|
406
|
+
].includes(args[0] ?? "")
|
|
399
407
|
await runCommand(
|
|
400
408
|
archive({
|
|
401
|
-
type: args[0],
|
|
402
|
-
args: args.slice(1),
|
|
409
|
+
type: explicitType ? args[0] : undefined,
|
|
410
|
+
args: explicitType ? args.slice(1) : args,
|
|
403
411
|
json: options.json,
|
|
404
412
|
dryRun: options["dry-run"],
|
|
405
413
|
kinds: options.kind,
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -478,6 +478,11 @@ describe("strict CLI parsing", () => {
|
|
|
478
478
|
})
|
|
479
479
|
|
|
480
480
|
test("accepts archive dry-run", () => {
|
|
481
|
+
expect(parseCli(["archive", ".", "--dry-run"])).toMatchObject({
|
|
482
|
+
commandName: "archive",
|
|
483
|
+
args: ["."],
|
|
484
|
+
values: { "dry-run": true },
|
|
485
|
+
})
|
|
481
486
|
expect(parseCli(["archive", "task", "example", "--dry-run"])).toMatchObject(
|
|
482
487
|
{
|
|
483
488
|
commandName: "archive",
|
package/src/cli-parser.ts
CHANGED
|
@@ -763,7 +763,7 @@ const commands = {
|
|
|
763
763
|
},
|
|
764
764
|
},
|
|
765
765
|
archive: {
|
|
766
|
-
usage: "agency archive <list|show|epic|task|tasks|phase>",
|
|
766
|
+
usage: "agency archive <path|list|show|epic|task|tasks|phase>",
|
|
767
767
|
options: {
|
|
768
768
|
...outputOptions,
|
|
769
769
|
...entitySelectorOptions,
|
|
@@ -772,6 +772,12 @@ const commands = {
|
|
|
772
772
|
status: { type: "string", multiple: true },
|
|
773
773
|
repository: { type: "string", multiple: true },
|
|
774
774
|
},
|
|
775
|
+
command: {
|
|
776
|
+
usage: "agency archive <path> [--dry-run] [--json]",
|
|
777
|
+
minArgs: 1,
|
|
778
|
+
maxArgs: 1,
|
|
779
|
+
options: ["dry-run", "json"],
|
|
780
|
+
},
|
|
775
781
|
subcommands: {
|
|
776
782
|
list: {
|
|
777
783
|
usage:
|
|
@@ -1474,9 +1480,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1474
1480
|
values: parsed.values,
|
|
1475
1481
|
}
|
|
1476
1482
|
}
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
: definition.command
|
|
1483
|
+
const selectedSubcommand = definition.subcommands?.[subcommand ?? ""]
|
|
1484
|
+
const spec = selectedSubcommand ?? definition.command
|
|
1480
1485
|
if (!spec) {
|
|
1481
1486
|
const message = subcommand
|
|
1482
1487
|
? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.`
|
|
@@ -1484,7 +1489,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1484
1489
|
throw usageError(message, definition.usage)
|
|
1485
1490
|
}
|
|
1486
1491
|
|
|
1487
|
-
let commandPositionals =
|
|
1492
|
+
let commandPositionals = selectedSubcommand
|
|
1488
1493
|
? parsed.positionals.slice(1)
|
|
1489
1494
|
: parsed.positionals
|
|
1490
1495
|
const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])])
|
|
@@ -1535,7 +1540,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1535
1540
|
|
|
1536
1541
|
commandPositionals = applyEntitySelectors(
|
|
1537
1542
|
commandName,
|
|
1538
|
-
subcommand,
|
|
1543
|
+
selectedSubcommand ? subcommand : undefined,
|
|
1539
1544
|
commandPositionals,
|
|
1540
1545
|
parsed.values,
|
|
1541
1546
|
spec,
|
|
@@ -1708,7 +1713,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1708
1713
|
|
|
1709
1714
|
return {
|
|
1710
1715
|
commandName: commandName as keyof typeof commands,
|
|
1711
|
-
args:
|
|
1716
|
+
args: selectedSubcommand
|
|
1712
1717
|
? [subcommand!, ...commandPositionals]
|
|
1713
1718
|
: commandPositionals,
|
|
1714
1719
|
values: parsed.values,
|
package/src/cli.test.ts
CHANGED
|
@@ -531,9 +531,9 @@ status: dropped
|
|
|
531
531
|
`,
|
|
532
532
|
)
|
|
533
533
|
|
|
534
|
-
expect(
|
|
535
|
-
|
|
536
|
-
)
|
|
534
|
+
expect(
|
|
535
|
+
(await runCli(["archive", "."], join(root, "tasks/example"))).exitCode,
|
|
536
|
+
).toBe(0)
|
|
537
537
|
const result = await runCli(["archive", "task", "example", "--json"], root)
|
|
538
538
|
|
|
539
539
|
expect(result.exitCode).toBe(1)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
2
3
|
import { join } from "node:path"
|
|
3
4
|
import {
|
|
4
5
|
captureLogs,
|
|
@@ -93,6 +94,75 @@ describe("archive command", () => {
|
|
|
93
94
|
)
|
|
94
95
|
})
|
|
95
96
|
|
|
97
|
+
test("infers a task from a filesystem path", async () => {
|
|
98
|
+
const logs = await captureLogs(() =>
|
|
99
|
+
runTestEffect(
|
|
100
|
+
archive({
|
|
101
|
+
args: ["."],
|
|
102
|
+
cwd: join(root, "tasks/example"),
|
|
103
|
+
dryRun: true,
|
|
104
|
+
json: true,
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
expect(JSON.parse(logs[0]!)).toMatchObject({
|
|
110
|
+
operation: "archive",
|
|
111
|
+
kind: "task",
|
|
112
|
+
id: "example",
|
|
113
|
+
dryRun: true,
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("infers an epic from a filesystem path", async () => {
|
|
118
|
+
const directory = join(root, "epics/delivery")
|
|
119
|
+
await mkdir(directory, { recursive: true })
|
|
120
|
+
await Bun.write(
|
|
121
|
+
join(directory, "EPIC.md"),
|
|
122
|
+
`---
|
|
123
|
+
ticketUrl: https://example.com/epic
|
|
124
|
+
repos:
|
|
125
|
+
- repo: agency
|
|
126
|
+
ref: main
|
|
127
|
+
tasks: []
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
# Delivery
|
|
131
|
+
`,
|
|
132
|
+
)
|
|
133
|
+
const logs = await captureLogs(() =>
|
|
134
|
+
runTestEffect(
|
|
135
|
+
archive({ args: ["."], cwd: directory, dryRun: true, json: true }),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(JSON.parse(logs[0]!)).toMatchObject({
|
|
140
|
+
operation: "archive",
|
|
141
|
+
kind: "epic",
|
|
142
|
+
id: "delivery",
|
|
143
|
+
dryRun: true,
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("rejects ambiguous and unsupported archive paths", async () => {
|
|
148
|
+
await expect(
|
|
149
|
+
runTestEffect(archive({ args: ["."], cwd: root, silent: true })),
|
|
150
|
+
).rejects.toThrow("Archive path is ambiguous")
|
|
151
|
+
await expect(
|
|
152
|
+
runTestEffect(
|
|
153
|
+
archive({ args: ["repos/agency"], cwd: root, silent: true }),
|
|
154
|
+
),
|
|
155
|
+
).rejects.toThrow("Archive path must be within an active epic or task")
|
|
156
|
+
|
|
157
|
+
const phaseDirectory = join(root, "tasks/example/phases/build")
|
|
158
|
+
await mkdir(phaseDirectory, { recursive: true })
|
|
159
|
+
await expect(
|
|
160
|
+
runTestEffect(
|
|
161
|
+
archive({ args: ["."], cwd: phaseDirectory, silent: true }),
|
|
162
|
+
),
|
|
163
|
+
).rejects.toThrow("Archive path identifies a phase")
|
|
164
|
+
})
|
|
165
|
+
|
|
96
166
|
test("reports an already archived task", async () => {
|
|
97
167
|
await runTestEffect(
|
|
98
168
|
archive({ type: "task", args: ["example"], cwd: root, silent: true }),
|
|
@@ -143,7 +213,9 @@ describe("archive command", () => {
|
|
|
143
213
|
test("requires a supported work item type", async () => {
|
|
144
214
|
await expect(
|
|
145
215
|
runTestEffect(archive({ args: [], cwd: root, silent: true })),
|
|
146
|
-
).rejects.toThrow(
|
|
216
|
+
).rejects.toThrow(
|
|
217
|
+
"Provide a path or use: list, show, epic, task, tasks, phase",
|
|
218
|
+
)
|
|
147
219
|
})
|
|
148
220
|
|
|
149
221
|
test("rejects an extra archive show identifier", async () => {
|
package/src/commands/archive.ts
CHANGED
|
@@ -24,6 +24,14 @@ export const archive = (options: ArchiveOptions) =>
|
|
|
24
24
|
const { log } = createLoggers(options)
|
|
25
25
|
const cwd = options.cwd ?? process.cwd()
|
|
26
26
|
const [id, phaseId] = options.args
|
|
27
|
+
let archiveType = options.type
|
|
28
|
+
let archiveId = id
|
|
29
|
+
|
|
30
|
+
if (!archiveType && id) {
|
|
31
|
+
const target = yield* archives.resolvePathTarget(id, cwd)
|
|
32
|
+
archiveType = target.kind
|
|
33
|
+
archiveId = target.id
|
|
34
|
+
}
|
|
27
35
|
|
|
28
36
|
if (options.type === "list") {
|
|
29
37
|
const records = yield* archives.list(
|
|
@@ -73,22 +81,22 @@ export const archive = (options: ArchiveOptions) =>
|
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
let result
|
|
76
|
-
switch (
|
|
84
|
+
switch (archiveType) {
|
|
77
85
|
case "epic":
|
|
78
|
-
if (!
|
|
86
|
+
if (!archiveId)
|
|
79
87
|
return yield* Effect.fail(
|
|
80
88
|
new Error("Usage: agency archive epic <epic-id>"),
|
|
81
89
|
)
|
|
82
|
-
result = yield* archives.archiveEpic(
|
|
90
|
+
result = yield* archives.archiveEpic(archiveId, cwd, {
|
|
83
91
|
dryRun: options.dryRun,
|
|
84
92
|
})
|
|
85
93
|
break
|
|
86
94
|
case "task":
|
|
87
|
-
if (!
|
|
95
|
+
if (!archiveId)
|
|
88
96
|
return yield* Effect.fail(
|
|
89
97
|
new Error("Usage: agency archive task <task-id>"),
|
|
90
98
|
)
|
|
91
|
-
result = yield* archives.archiveTask(
|
|
99
|
+
result = yield* archives.archiveTask(archiveId, cwd, {
|
|
92
100
|
dryRun: options.dryRun,
|
|
93
101
|
})
|
|
94
102
|
break
|
|
@@ -109,7 +117,7 @@ export const archive = (options: ArchiveOptions) =>
|
|
|
109
117
|
default:
|
|
110
118
|
return yield* Effect.fail(
|
|
111
119
|
new Error(
|
|
112
|
-
"Archive
|
|
120
|
+
"Archive target is required. Provide a path or use: list, show, epic, task, tasks, phase",
|
|
113
121
|
),
|
|
114
122
|
)
|
|
115
123
|
}
|
|
@@ -141,11 +149,14 @@ export const archive = (options: ArchiveOptions) =>
|
|
|
141
149
|
})
|
|
142
150
|
|
|
143
151
|
export const help = `
|
|
144
|
-
Usage: agency archive <list|show|epic|task|tasks|phase>
|
|
152
|
+
Usage: agency archive <path|list|show|epic|task|tasks|phase>
|
|
145
153
|
|
|
146
154
|
Browse or archive work items after preflighting worktrees and graph references.
|
|
147
155
|
|
|
156
|
+
An existing path within an active epic or task infers that work item.
|
|
157
|
+
|
|
148
158
|
Commands:
|
|
159
|
+
<path> Archive the containing epic or task
|
|
149
160
|
list [filters] List archived work
|
|
150
161
|
show <type> <id> Show an archived epic or task
|
|
151
162
|
show phase <task-id> <phase-id> Show an archived phase
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
2
|
import { Data, Effect, Either, Layer } from "effect"
|
|
3
3
|
import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
|
|
4
|
-
import { dirname, join, relative } from "node:path"
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
5
5
|
import { EpicService, type EpicRecord } from "./EpicService"
|
|
6
6
|
import { FileSystemService } from "./FileSystemService"
|
|
7
7
|
import { PhaseService, type PhaseRecord } from "./PhaseService"
|
|
@@ -52,6 +52,11 @@ class ArchiveError extends Data.TaggedError("ArchiveError")<{
|
|
|
52
52
|
|
|
53
53
|
export type ArchiveKind = "epic" | "task" | "phase"
|
|
54
54
|
|
|
55
|
+
interface ArchivePathTarget {
|
|
56
|
+
readonly kind: "epic" | "task"
|
|
57
|
+
readonly id: string
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
const LifecycleEventSchema = Schema.Struct({
|
|
56
61
|
operation: Schema.Literal("archive", "restore"),
|
|
57
62
|
at: Schema.String,
|
|
@@ -478,6 +483,59 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
478
483
|
"ArchiveService",
|
|
479
484
|
{
|
|
480
485
|
sync: () => ({
|
|
486
|
+
resolvePathTarget: (path: string, cwd: string = process.cwd()) =>
|
|
487
|
+
Effect.gen(function* () {
|
|
488
|
+
const fs = yield* FileSystemService
|
|
489
|
+
const workbase = yield* WorkbaseService
|
|
490
|
+
const candidate = resolve(cwd, path)
|
|
491
|
+
if (!(yield* fs.exists(candidate))) {
|
|
492
|
+
return yield* new ArchiveError({
|
|
493
|
+
message: `Archive path does not exist: ${candidate}`,
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const canonicalPath = yield* fs.realPath(candidate)
|
|
498
|
+
const root = yield* workbase.discover(canonicalPath)
|
|
499
|
+
const child = relative(root, canonicalPath)
|
|
500
|
+
const parts = child.split(sep)
|
|
501
|
+
if (child === "" || parts.length === 1) {
|
|
502
|
+
return yield* new ArchiveError({
|
|
503
|
+
message: `Archive path is ambiguous; it does not identify a single epic or task: ${canonicalPath}`,
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const [collection, id] = parts
|
|
508
|
+
const kind =
|
|
509
|
+
collection === "epics"
|
|
510
|
+
? "epic"
|
|
511
|
+
: collection === "tasks"
|
|
512
|
+
? "task"
|
|
513
|
+
: undefined
|
|
514
|
+
if (!kind || !id) {
|
|
515
|
+
return yield* new ArchiveError({
|
|
516
|
+
message: `Archive path must be within an active epic or task: ${canonicalPath}`,
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
if (kind === "task" && parts[2] === "phases") {
|
|
520
|
+
return yield* new ArchiveError({
|
|
521
|
+
message: `Archive path identifies a phase; use 'agency archive phase <task-id> <phase-id>': ${canonicalPath}`,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const document = join(
|
|
526
|
+
root,
|
|
527
|
+
kind === "epic" ? "epics" : "tasks",
|
|
528
|
+
id,
|
|
529
|
+
kind === "epic" ? "EPIC.md" : "TASK.md",
|
|
530
|
+
)
|
|
531
|
+
if (!(yield* fs.exists(document))) {
|
|
532
|
+
return yield* new ArchiveError({
|
|
533
|
+
message: `Archive path does not identify an active ${kind}: ${canonicalPath}`,
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
return { kind, id } satisfies ArchivePathTarget
|
|
537
|
+
}),
|
|
538
|
+
|
|
481
539
|
list: (filters: ArchiveFilters = {}, startPath: string = process.cwd()) =>
|
|
482
540
|
Effect.gen(function* () {
|
|
483
541
|
const fs = yield* FileSystemService
|