@markjaquith/agency 2.22.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 +22 -4
- package/cli.ts +27 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +9 -1
- package/src/cli-parser.test.ts +57 -0
- package/src/cli-parser.ts +85 -17
- package/src/cli.test.ts +36 -0
- package/src/commands/archive.test.ts +21 -3
- package/src/commands/archive.ts +86 -18
- package/src/commands/epic.test.ts +12 -3
- package/src/commands/epic.ts +11 -1
- package/src/commands/phase.ts +13 -1
- package/src/commands/restore.test.ts +98 -0
- package/src/commands/restore.ts +73 -0
- package/src/commands/task-phase.test.ts +16 -4
- package/src/commands/task.ts +13 -1
- package/src/protocol.test.ts +19 -0
- package/src/services/ArchiveService.test.ts +520 -1
- package/src/services/ArchiveService.ts +964 -84
- package/src/services/ClaimService.ts +27 -21
- package/src/services/ContextService.ts +4 -6
- package/src/services/EpicService.ts +22 -2
- package/src/services/GraphMutationService.test.ts +31 -0
- package/src/services/GraphMutationService.ts +126 -11
- package/src/services/GraphService.ts +2 -4
- package/src/services/PhaseService.ts +32 -4
- package/src/services/TaskService.ts +28 -3
- package/src/services/WorktreeService.ts +35 -5
- package/src/work-view.ts +2 -0
- package/src/workbase/archive.ts +18 -0
- package/src/workbase/document-revision.ts +16 -0
|
@@ -40,9 +40,12 @@ describe("epic command", () => {
|
|
|
40
40
|
),
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const created = JSON.parse(logs[0]!)
|
|
44
|
+
expect(created.revision).toMatch(/^[a-f0-9]{64}$/)
|
|
45
|
+
expect(created).toEqual({
|
|
44
46
|
id: "example",
|
|
45
47
|
path: join(root, "epics/example/EPIC.md"),
|
|
48
|
+
revision: created.revision,
|
|
46
49
|
data: {
|
|
47
50
|
ticketUrl: "https://example.com/epic",
|
|
48
51
|
repos: [{ repo: "agency", ref: "main" }],
|
|
@@ -68,10 +71,13 @@ describe("epic command", () => {
|
|
|
68
71
|
epic({ subcommand: "list", args: [], cwd: root, json: true }),
|
|
69
72
|
),
|
|
70
73
|
)
|
|
71
|
-
|
|
74
|
+
const listed = JSON.parse(listLogs[0]!)
|
|
75
|
+
expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
|
|
76
|
+
expect(listed).toEqual([
|
|
72
77
|
{
|
|
73
78
|
id: "example",
|
|
74
79
|
path: join(root, "epics/example/EPIC.md"),
|
|
80
|
+
revision: listed[0].revision,
|
|
75
81
|
data: {
|
|
76
82
|
ticketUrl: "https://example.com/epic",
|
|
77
83
|
repos: [{ repo: "agency", ref: "main" }],
|
|
@@ -90,9 +96,12 @@ describe("epic command", () => {
|
|
|
90
96
|
}),
|
|
91
97
|
),
|
|
92
98
|
)
|
|
93
|
-
|
|
99
|
+
const shown = JSON.parse(showLogs[0]!)
|
|
100
|
+
expect(shown.revision).toBe(listed[0].revision)
|
|
101
|
+
expect(shown).toEqual({
|
|
94
102
|
id: "example",
|
|
95
103
|
path: join(root, "epics/example/EPIC.md"),
|
|
104
|
+
revision: shown.revision,
|
|
96
105
|
data: {
|
|
97
106
|
ticketUrl: "https://example.com/epic",
|
|
98
107
|
repos: [{ repo: "agency", ref: "main" }],
|
package/src/commands/epic.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface EpicOptions extends BaseCommandOptions {
|
|
|
13
13
|
readonly ticketUrl?: string
|
|
14
14
|
readonly description?: string
|
|
15
15
|
readonly clearDescription?: boolean
|
|
16
|
+
readonly ifRevision?: string
|
|
16
17
|
readonly repos?: readonly string[]
|
|
17
18
|
readonly json?: boolean
|
|
18
19
|
readonly statuses?: readonly string[]
|
|
@@ -122,6 +123,7 @@ export const epic = (options: EpicOptions) =>
|
|
|
122
123
|
: parseRepositoryReferences(options.repos),
|
|
123
124
|
},
|
|
124
125
|
cwd,
|
|
126
|
+
options.ifRevision,
|
|
125
127
|
)
|
|
126
128
|
log(
|
|
127
129
|
options.json
|
|
@@ -138,7 +140,12 @@ export const epic = (options: EpicOptions) =>
|
|
|
138
140
|
new Error("Epic ID and new ID are required"),
|
|
139
141
|
)
|
|
140
142
|
}
|
|
141
|
-
const output = yield* mutations.renameEpic(
|
|
143
|
+
const output = yield* mutations.renameEpic(
|
|
144
|
+
id,
|
|
145
|
+
newId,
|
|
146
|
+
cwd,
|
|
147
|
+
options.ifRevision,
|
|
148
|
+
)
|
|
142
149
|
log(
|
|
143
150
|
options.json
|
|
144
151
|
? JSON.stringify(output, null, 2)
|
|
@@ -177,6 +184,9 @@ Update options:
|
|
|
177
184
|
--clear-description Remove the description
|
|
178
185
|
--repo <alias>:<ref> Replace repository references; repeatable
|
|
179
186
|
|
|
187
|
+
Mutation options:
|
|
188
|
+
--if-revision <hash> Require the target's current revision
|
|
189
|
+
|
|
180
190
|
Options:
|
|
181
191
|
--json Output results as JSON
|
|
182
192
|
--status <status> Filter list by status; repeatable
|
package/src/commands/phase.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface PhaseOptions extends BaseCommandOptions {
|
|
|
19
19
|
readonly clearReferences?: boolean
|
|
20
20
|
readonly prUrl?: string
|
|
21
21
|
readonly clearPr?: boolean
|
|
22
|
+
readonly ifRevision?: string
|
|
22
23
|
readonly dependsOn?: readonly string[]
|
|
23
24
|
readonly firstPhase?: string
|
|
24
25
|
readonly json?: boolean
|
|
@@ -181,6 +182,7 @@ export const phase = (options: PhaseOptions) =>
|
|
|
181
182
|
pr: options.clearPr ? null : options.prUrl,
|
|
182
183
|
},
|
|
183
184
|
cwd,
|
|
185
|
+
options.ifRevision,
|
|
184
186
|
)
|
|
185
187
|
log(
|
|
186
188
|
options.json
|
|
@@ -196,7 +198,13 @@ export const phase = (options: PhaseOptions) =>
|
|
|
196
198
|
new Error("Task ID, phase ID, and new ID are required"),
|
|
197
199
|
)
|
|
198
200
|
}
|
|
199
|
-
const output = yield* mutations.renamePhase(
|
|
201
|
+
const output = yield* mutations.renamePhase(
|
|
202
|
+
taskId,
|
|
203
|
+
phaseId,
|
|
204
|
+
newId,
|
|
205
|
+
cwd,
|
|
206
|
+
options.ifRevision,
|
|
207
|
+
)
|
|
200
208
|
log(
|
|
201
209
|
options.json
|
|
202
210
|
? JSON.stringify(output, null, 2)
|
|
@@ -225,6 +233,7 @@ export const phase = (options: PhaseOptions) =>
|
|
|
225
233
|
dependencyPhaseId,
|
|
226
234
|
dependencyId,
|
|
227
235
|
cwd,
|
|
236
|
+
options.ifRevision,
|
|
228
237
|
)
|
|
229
238
|
log(
|
|
230
239
|
options.json
|
|
@@ -257,6 +266,9 @@ Subcommands:
|
|
|
257
266
|
dependency <operation> <task> <phase> <dependency>
|
|
258
267
|
Add or remove a phase dependency
|
|
259
268
|
|
|
269
|
+
Mutation option:
|
|
270
|
+
--if-revision <hash> Require the target's current revision
|
|
271
|
+
|
|
260
272
|
Create options:
|
|
261
273
|
--description <text> Short description of the phase
|
|
262
274
|
--repo <alias> Writable repository
|
|
@@ -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
|
+
`
|
|
@@ -48,10 +48,13 @@ describe("task and phase command JSON output", () => {
|
|
|
48
48
|
task({ subcommand: "list", args: [], cwd: root, json: true }),
|
|
49
49
|
),
|
|
50
50
|
)
|
|
51
|
-
|
|
51
|
+
const listed = JSON.parse(listLogs[0]!)
|
|
52
|
+
expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
|
|
53
|
+
expect(listed).toEqual([
|
|
52
54
|
{
|
|
53
55
|
id: "multi",
|
|
54
56
|
path: join(root, "tasks/multi/TASK.md"),
|
|
57
|
+
revision: listed[0].revision,
|
|
55
58
|
data: {
|
|
56
59
|
ticketUrl: "https://example.com/task",
|
|
57
60
|
phases: [{ id: "first" }],
|
|
@@ -69,9 +72,12 @@ describe("task and phase command JSON output", () => {
|
|
|
69
72
|
}),
|
|
70
73
|
),
|
|
71
74
|
)
|
|
72
|
-
|
|
75
|
+
const shown = JSON.parse(showLogs[0]!)
|
|
76
|
+
expect(shown.revision).toBe(listed[0].revision)
|
|
77
|
+
expect(shown).toEqual({
|
|
73
78
|
id: "multi",
|
|
74
79
|
path: join(root, "tasks/multi/TASK.md"),
|
|
80
|
+
revision: shown.revision,
|
|
75
81
|
data: {
|
|
76
82
|
ticketUrl: "https://example.com/task",
|
|
77
83
|
phases: [{ id: "first" }],
|
|
@@ -85,11 +91,14 @@ describe("task and phase command JSON output", () => {
|
|
|
85
91
|
phase({ subcommand: "list", args: ["multi"], cwd: root, json: true }),
|
|
86
92
|
),
|
|
87
93
|
)
|
|
88
|
-
|
|
94
|
+
const listed = JSON.parse(listLogs[0]!)
|
|
95
|
+
expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
|
|
96
|
+
expect(listed).toEqual([
|
|
89
97
|
{
|
|
90
98
|
taskId: "multi",
|
|
91
99
|
id: "first",
|
|
92
100
|
path: join(root, "tasks/multi/phases/first/PHASE.md"),
|
|
101
|
+
revision: listed[0].revision,
|
|
93
102
|
data: {
|
|
94
103
|
repo: "agency",
|
|
95
104
|
branch: "task/first",
|
|
@@ -110,10 +119,13 @@ describe("task and phase command JSON output", () => {
|
|
|
110
119
|
}),
|
|
111
120
|
),
|
|
112
121
|
)
|
|
113
|
-
|
|
122
|
+
const shown = JSON.parse(showLogs[0]!)
|
|
123
|
+
expect(shown.revision).toBe(listed[0].revision)
|
|
124
|
+
expect(shown).toEqual({
|
|
114
125
|
taskId: "multi",
|
|
115
126
|
id: "first",
|
|
116
127
|
path: join(root, "tasks/multi/phases/first/PHASE.md"),
|
|
128
|
+
revision: shown.revision,
|
|
117
129
|
data: {
|
|
118
130
|
repo: "agency",
|
|
119
131
|
branch: "task/first",
|
package/src/commands/task.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
27
27
|
readonly clearReferences?: boolean
|
|
28
28
|
readonly prUrl?: string
|
|
29
29
|
readonly clearPr?: boolean
|
|
30
|
+
readonly ifRevision?: string
|
|
30
31
|
readonly noEpic?: boolean
|
|
31
32
|
readonly multiPhase?: boolean
|
|
32
33
|
readonly json?: boolean
|
|
@@ -318,6 +319,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
318
319
|
pr: options.clearPr ? null : options.prUrl,
|
|
319
320
|
},
|
|
320
321
|
cwd,
|
|
322
|
+
options.ifRevision,
|
|
321
323
|
)
|
|
322
324
|
log(
|
|
323
325
|
options.json
|
|
@@ -333,7 +335,12 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
333
335
|
new Error("Task ID and new ID are required"),
|
|
334
336
|
)
|
|
335
337
|
}
|
|
336
|
-
const output = yield* mutations.renameTask(
|
|
338
|
+
const output = yield* mutations.renameTask(
|
|
339
|
+
id,
|
|
340
|
+
newId,
|
|
341
|
+
cwd,
|
|
342
|
+
options.ifRevision,
|
|
343
|
+
)
|
|
337
344
|
log(
|
|
338
345
|
options.json
|
|
339
346
|
? JSON.stringify(output, null, 2)
|
|
@@ -348,6 +355,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
348
355
|
id,
|
|
349
356
|
options.noEpic ? null : (options.epic ?? null),
|
|
350
357
|
cwd,
|
|
358
|
+
options.ifRevision,
|
|
351
359
|
)
|
|
352
360
|
log(
|
|
353
361
|
options.json
|
|
@@ -376,6 +384,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
376
384
|
id,
|
|
377
385
|
dependencyId,
|
|
378
386
|
cwd,
|
|
387
|
+
options.ifRevision,
|
|
379
388
|
)
|
|
380
389
|
log(
|
|
381
390
|
options.json
|
|
@@ -408,6 +417,9 @@ Subcommands:
|
|
|
408
417
|
dependency <operation> <task> <dependency>
|
|
409
418
|
Add or remove a task dependency
|
|
410
419
|
|
|
420
|
+
Mutation option:
|
|
421
|
+
--if-revision <hash> Require the target's current revision
|
|
422
|
+
|
|
411
423
|
Create options:
|
|
412
424
|
--ticket-url <url> External ticket URL (optional)
|
|
413
425
|
--description <text> Short description of the task
|
package/src/protocol.test.ts
CHANGED
|
@@ -108,5 +108,24 @@ describe("machine protocol", () => {
|
|
|
108
108
|
},
|
|
109
109
|
},
|
|
110
110
|
})
|
|
111
|
+
expect(
|
|
112
|
+
errorEnvelope({
|
|
113
|
+
_tag: "RevisionConflictError",
|
|
114
|
+
message: "revision conflict",
|
|
115
|
+
path: "tasks/example/TASK.md",
|
|
116
|
+
expectedRevision: "a".repeat(64),
|
|
117
|
+
currentRevision: "b".repeat(64),
|
|
118
|
+
}),
|
|
119
|
+
).toMatchObject({
|
|
120
|
+
error: {
|
|
121
|
+
code: "REVISION_CONFLICT",
|
|
122
|
+
retryable: true,
|
|
123
|
+
fields: {
|
|
124
|
+
path: "tasks/example/TASK.md",
|
|
125
|
+
expectedRevision: "a".repeat(64),
|
|
126
|
+
currentRevision: "b".repeat(64),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
})
|
|
111
130
|
})
|
|
112
131
|
})
|