@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
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseFrontmatter,
|
|
14
14
|
} from "../workbase/frontmatter"
|
|
15
15
|
import { documentRevision } from "../workbase/document-revision"
|
|
16
|
+
import { archivedEpicDirectory } from "../workbase/archive"
|
|
16
17
|
|
|
17
18
|
class EpicError extends Data.TaggedError("EpicError")<{
|
|
18
19
|
readonly message: string
|
|
@@ -73,6 +74,11 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
|
|
|
73
74
|
message: `Epic '${validId}' already exists`,
|
|
74
75
|
})
|
|
75
76
|
}
|
|
77
|
+
if (yield* fs.exists(archivedEpicDirectory(root, validId))) {
|
|
78
|
+
return yield* new EpicError({
|
|
79
|
+
message: `Epic '${validId}' is archived; restore it before reusing this ID`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
76
82
|
|
|
77
83
|
for (const { repo: alias } of data.repos) {
|
|
78
84
|
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
19
|
import { documentRevision } from "../workbase/document-revision"
|
|
20
|
+
import { archivedPhaseDirectory } from "../workbase/archive"
|
|
20
21
|
|
|
21
22
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
22
23
|
readonly message: string
|
|
@@ -114,6 +115,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
114
115
|
message: `Phase '${id}' already exists on task '${taskId}'`,
|
|
115
116
|
})
|
|
116
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
|
+
}
|
|
117
123
|
const knownPhases = new Set(
|
|
118
124
|
isMultiPhase
|
|
119
125
|
? task.data.phases.map((phase) => phase.id)
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
19
|
import { documentRevision } from "../workbase/document-revision"
|
|
20
|
+
import { archivedTaskDirectory } from "../workbase/archive"
|
|
20
21
|
|
|
21
22
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
22
23
|
readonly message: string
|
|
@@ -85,6 +86,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
85
86
|
message: `Task '${id}' already exists`,
|
|
86
87
|
})
|
|
87
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
|
+
}
|
|
88
94
|
|
|
89
95
|
let data: TaskData
|
|
90
96
|
if (input.multiPhase) {
|
|
@@ -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({
|
|
@@ -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)
|