@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
|
@@ -16,16 +16,19 @@ import {
|
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
20
|
+
import { archivedPhaseDirectory } from "../workbase/archive"
|
|
19
21
|
|
|
20
22
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
21
23
|
readonly message: string
|
|
22
24
|
}> {}
|
|
23
25
|
|
|
24
|
-
interface PhaseRecord {
|
|
26
|
+
export interface PhaseRecord {
|
|
25
27
|
readonly taskId: string
|
|
26
28
|
readonly id: string
|
|
27
29
|
readonly path: string
|
|
28
30
|
readonly content: string
|
|
31
|
+
readonly revision: string
|
|
29
32
|
readonly data: PhaseData
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -112,6 +115,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
112
115
|
message: `Phase '${id}' already exists on task '${taskId}'`,
|
|
113
116
|
})
|
|
114
117
|
}
|
|
118
|
+
if (yield* fs.exists(archivedPhaseDirectory(root, taskId, id))) {
|
|
119
|
+
return yield* new PhaseError({
|
|
120
|
+
message: `Phase '${id}' on task '${taskId}' is archived; restore it before reusing this ID`,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
115
123
|
const knownPhases = new Set(
|
|
116
124
|
isMultiPhase
|
|
117
125
|
? task.data.phases.map((phase) => phase.id)
|
|
@@ -261,7 +269,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
261
269
|
task.path,
|
|
262
270
|
formatMarkdownDocument(convertedTaskData, parsedTask.body),
|
|
263
271
|
)
|
|
264
|
-
return {
|
|
272
|
+
return {
|
|
273
|
+
taskId,
|
|
274
|
+
id,
|
|
275
|
+
path,
|
|
276
|
+
content,
|
|
277
|
+
revision: documentRevision(content),
|
|
278
|
+
data,
|
|
279
|
+
} satisfies PhaseRecord
|
|
265
280
|
}
|
|
266
281
|
|
|
267
282
|
yield* fs.createDirectory(directory)
|
|
@@ -283,7 +298,14 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
283
298
|
formatMarkdownDocument(updatedTaskData, parsedTask.body),
|
|
284
299
|
)
|
|
285
300
|
|
|
286
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
taskId,
|
|
303
|
+
id,
|
|
304
|
+
path,
|
|
305
|
+
content,
|
|
306
|
+
revision: documentRevision(content),
|
|
307
|
+
data,
|
|
308
|
+
} satisfies PhaseRecord
|
|
287
309
|
}),
|
|
288
310
|
|
|
289
311
|
list: (taskId: string, startPath: string = process.cwd()) =>
|
|
@@ -311,6 +333,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
311
333
|
id: entry.name,
|
|
312
334
|
path,
|
|
313
335
|
content,
|
|
336
|
+
revision: documentRevision(content),
|
|
314
337
|
data,
|
|
315
338
|
})
|
|
316
339
|
}
|
|
@@ -363,7 +386,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
363
386
|
const data = { ...record.data, status: validStatus }
|
|
364
387
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
365
388
|
yield* fs.writeFile(record.path, content)
|
|
366
|
-
return {
|
|
389
|
+
return {
|
|
390
|
+
...record,
|
|
391
|
+
content,
|
|
392
|
+
revision: documentRevision(content),
|
|
393
|
+
data,
|
|
394
|
+
} satisfies PhaseRecord
|
|
367
395
|
}),
|
|
368
396
|
}),
|
|
369
397
|
},
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
20
|
+
import { archivedTaskDirectory } from "../workbase/archive"
|
|
19
21
|
|
|
20
22
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
21
23
|
readonly message: string
|
|
@@ -25,6 +27,7 @@ interface TaskRecord {
|
|
|
25
27
|
readonly id: string
|
|
26
28
|
readonly path: string
|
|
27
29
|
readonly content: string
|
|
30
|
+
readonly revision: string
|
|
28
31
|
readonly data: TaskData
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -83,6 +86,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
83
86
|
message: `Task '${id}' already exists`,
|
|
84
87
|
})
|
|
85
88
|
}
|
|
89
|
+
if (yield* fs.exists(archivedTaskDirectory(root, id))) {
|
|
90
|
+
return yield* new TaskError({
|
|
91
|
+
message: `Task '${id}' is archived; restore it before reusing this ID`,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
86
94
|
|
|
87
95
|
let data: TaskData
|
|
88
96
|
if (input.multiPhase) {
|
|
@@ -158,7 +166,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
158
166
|
yield* fs.writeFile(parentEpic.path, updated)
|
|
159
167
|
}
|
|
160
168
|
|
|
161
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
id,
|
|
171
|
+
path,
|
|
172
|
+
content,
|
|
173
|
+
revision: documentRevision(content),
|
|
174
|
+
data,
|
|
175
|
+
} satisfies TaskRecord
|
|
162
176
|
}),
|
|
163
177
|
|
|
164
178
|
list: (startPath: string = process.cwd()) =>
|
|
@@ -178,7 +192,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
178
192
|
const content = yield* fs.readFile(path)
|
|
179
193
|
const parsed = yield* parseFrontmatter(content, path)
|
|
180
194
|
const data = yield* decodeTask(parsed.data)
|
|
181
|
-
records.push({
|
|
195
|
+
records.push({
|
|
196
|
+
id: entry.name,
|
|
197
|
+
path,
|
|
198
|
+
content,
|
|
199
|
+
revision: documentRevision(content),
|
|
200
|
+
data,
|
|
201
|
+
})
|
|
182
202
|
}
|
|
183
203
|
return records
|
|
184
204
|
}),
|
|
@@ -233,7 +253,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
233
253
|
const data = { ...record.data, status: validStatus }
|
|
234
254
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
235
255
|
yield* fs.writeFile(record.path, content)
|
|
236
|
-
return {
|
|
256
|
+
return {
|
|
257
|
+
...record,
|
|
258
|
+
content,
|
|
259
|
+
revision: documentRevision(content),
|
|
260
|
+
data,
|
|
261
|
+
} satisfies TaskRecord
|
|
237
262
|
}),
|
|
238
263
|
}),
|
|
239
264
|
}) {}
|
|
@@ -88,6 +88,8 @@ interface MaterializeOptions extends BaseCommandOptions {
|
|
|
88
88
|
readonly force?: boolean
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
interface RemoveOptions extends BaseCommandOptions {}
|
|
92
|
+
|
|
91
93
|
export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
92
94
|
"WorktreeService",
|
|
93
95
|
{
|
|
@@ -558,6 +560,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
558
560
|
taskId: string,
|
|
559
561
|
phaseId?: string,
|
|
560
562
|
startPath: string = process.cwd(),
|
|
563
|
+
options: RemoveOptions = {},
|
|
561
564
|
) =>
|
|
562
565
|
Effect.gen(function* () {
|
|
563
566
|
const fs = yield* FileSystemService
|
|
@@ -592,11 +595,22 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
592
595
|
}
|
|
593
596
|
|
|
594
597
|
const codeDirectoryExists = yield* fs.isDirectory(codePath)
|
|
595
|
-
const
|
|
596
|
-
for (const alias of [
|
|
598
|
+
const aliases = [
|
|
597
599
|
execution.repo,
|
|
598
600
|
...(execution.repos ?? []).map((reference) => reference.repo),
|
|
599
|
-
]
|
|
601
|
+
]
|
|
602
|
+
if (codeDirectoryExists) {
|
|
603
|
+
const unmanaged = (yield* fs.readDirectory(codePath)).filter(
|
|
604
|
+
(entry) => !aliases.includes(entry.name),
|
|
605
|
+
)
|
|
606
|
+
if (unmanaged.length > 0) {
|
|
607
|
+
return yield* new WorktreeError({
|
|
608
|
+
message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const removed: string[] = []
|
|
613
|
+
for (const alias of aliases) {
|
|
600
614
|
const repositoryPath = join(root, "repos", alias)
|
|
601
615
|
const checkoutPath = join(codePath, alias)
|
|
602
616
|
const listed = yield* fs.runCommand(
|
|
@@ -643,6 +657,19 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
643
657
|
}
|
|
644
658
|
continue
|
|
645
659
|
}
|
|
660
|
+
if (checkoutExists) {
|
|
661
|
+
const status = yield* fs.runCommand(
|
|
662
|
+
["git", "-C", checkoutPath, "status", "--porcelain"],
|
|
663
|
+
{ captureOutput: true },
|
|
664
|
+
)
|
|
665
|
+
if (status.exitCode !== 0 || status.stdout.trim()) {
|
|
666
|
+
return yield* new WorktreeError({
|
|
667
|
+
message: `Failed to remove worktree for '${alias}': worktree has uncommitted changes`,
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
removed.push(checkoutPath)
|
|
671
|
+
}
|
|
672
|
+
if (options.dryRun) continue
|
|
646
673
|
|
|
647
674
|
const result = yield* fs.runCommand(
|
|
648
675
|
[
|
|
@@ -661,10 +688,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
661
688
|
message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
|
|
662
689
|
})
|
|
663
690
|
}
|
|
664
|
-
if (checkoutExists) removed.push(checkoutPath)
|
|
665
691
|
}
|
|
666
692
|
|
|
667
|
-
if (
|
|
693
|
+
if (
|
|
694
|
+
!options.dryRun &&
|
|
695
|
+
codeDirectoryExists &&
|
|
696
|
+
(yield* fs.isDirectory(codePath))
|
|
697
|
+
) {
|
|
668
698
|
const remaining = yield* fs.readDirectory(codePath)
|
|
669
699
|
if (remaining.length > 0) {
|
|
670
700
|
return yield* new WorktreeError({
|
package/src/work-view.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface WorkViewRow {
|
|
|
23
23
|
readonly kind: "epic" | "task" | "phase"
|
|
24
24
|
readonly id: string
|
|
25
25
|
readonly key: string
|
|
26
|
+
readonly revision: string
|
|
26
27
|
readonly parent: string
|
|
27
28
|
readonly status: WorkStatus
|
|
28
29
|
readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
|
|
@@ -91,6 +92,7 @@ const rowFor = (
|
|
|
91
92
|
kind: node.kind,
|
|
92
93
|
id,
|
|
93
94
|
key: node.key,
|
|
95
|
+
revision: node.data.sha256,
|
|
94
96
|
parent,
|
|
95
97
|
status: node.status,
|
|
96
98
|
readiness: readinessLabel(node),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
|
|
3
|
+
const lifecycleManifestName = ".agency-lifecycle.json"
|
|
4
|
+
|
|
5
|
+
export const archivedEpicDirectory = (root: string, id: string) =>
|
|
6
|
+
join(root, "archive", "epics", id)
|
|
7
|
+
|
|
8
|
+
export const archivedTaskDirectory = (root: string, id: string) =>
|
|
9
|
+
join(root, "archive", "tasks", id)
|
|
10
|
+
|
|
11
|
+
export const archivedPhaseDirectory = (
|
|
12
|
+
root: string,
|
|
13
|
+
taskId: string,
|
|
14
|
+
id: string,
|
|
15
|
+
) => join(archivedTaskDirectory(root, taskId), "phases", id)
|
|
16
|
+
|
|
17
|
+
export const lifecycleManifestPath = (directory: string) =>
|
|
18
|
+
join(directory, lifecycleManifestName)
|
|
@@ -1,2 +1,18 @@
|
|
|
1
|
+
import { Data } from "effect"
|
|
2
|
+
|
|
1
3
|
export const documentRevision = (content: string) =>
|
|
2
4
|
new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
5
|
+
|
|
6
|
+
export const isDocumentRevision = (revision: string) =>
|
|
7
|
+
/^[a-f0-9]{64}$/.test(revision)
|
|
8
|
+
|
|
9
|
+
export class RevisionConflictError extends Data.TaggedError(
|
|
10
|
+
"RevisionConflictError",
|
|
11
|
+
)<{
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly path: string
|
|
14
|
+
readonly target?: string
|
|
15
|
+
readonly expectedRevision: string
|
|
16
|
+
readonly currentRevision: string
|
|
17
|
+
readonly claim?: unknown
|
|
18
|
+
}> {}
|