@markjaquith/agency 2.4.0 → 2.5.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 +45 -16
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +31 -4
- package/src/cli.test.ts +1 -0
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/work.test.ts +52 -33
- package/src/commands/work.ts +20 -8
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +66 -0
- package/src/services/WorkbaseService.ts +32 -2
- package/src/services/WorktreeService.test.ts +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +50 -0
- package/src/workbase/schemas.ts +6 -2
- package/src/workbase/work-target.test.ts +9 -9
- package/src/workbase/work-target.ts +36 -6
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TaskFrontmatter,
|
|
10
10
|
type RepositoryReference,
|
|
11
11
|
type TaskFrontmatter as TaskData,
|
|
12
|
+
WorkStatus,
|
|
12
13
|
} from "../workbase/schemas"
|
|
13
14
|
import {
|
|
14
15
|
formatMarkdownDocument,
|
|
@@ -28,7 +29,7 @@ interface TaskRecord {
|
|
|
28
29
|
|
|
29
30
|
export interface CreateTaskInput {
|
|
30
31
|
readonly id: string
|
|
31
|
-
readonly ticketUrl: string
|
|
32
|
+
readonly ticketUrl: string | null
|
|
32
33
|
readonly description?: string
|
|
33
34
|
readonly epic?: string
|
|
34
35
|
readonly multiPhase?: boolean
|
|
@@ -57,6 +58,13 @@ const decodeId = (id: string) => {
|
|
|
57
58
|
: Effect.succeed(result.right)
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
const decodeStatus = (status: string) => {
|
|
62
|
+
const result = Schema.decodeUnknownEither(WorkStatus)(status)
|
|
63
|
+
return Either.isLeft(result)
|
|
64
|
+
? Effect.fail(new TaskError({ message: `Invalid work status '${status}'` }))
|
|
65
|
+
: Effect.succeed(result.right)
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
61
69
|
sync: () => ({
|
|
62
70
|
create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
|
|
@@ -188,5 +196,27 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
188
196
|
}
|
|
189
197
|
return record
|
|
190
198
|
}),
|
|
199
|
+
|
|
200
|
+
setStatus: (
|
|
201
|
+
id: string,
|
|
202
|
+
status: string,
|
|
203
|
+
startPath: string = process.cwd(),
|
|
204
|
+
) =>
|
|
205
|
+
Effect.gen(function* () {
|
|
206
|
+
const fs = yield* FileSystemService
|
|
207
|
+
const service = yield* TaskService
|
|
208
|
+
const validStatus = yield* decodeStatus(status)
|
|
209
|
+
const record = yield* service.show(id, startPath)
|
|
210
|
+
if ("phases" in record.data) {
|
|
211
|
+
return yield* new TaskError({
|
|
212
|
+
message: `Task '${id}' has multiple phases; set status on a phase instead`,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
216
|
+
const data = { ...record.data, status: validStatus }
|
|
217
|
+
const content = formatMarkdownDocument(data, parsed.body)
|
|
218
|
+
yield* fs.writeFile(record.path, content)
|
|
219
|
+
return { ...record, content, data } satisfies TaskRecord
|
|
220
|
+
}),
|
|
191
221
|
}),
|
|
192
222
|
}) {}
|
|
@@ -5,6 +5,7 @@ import { mkdir } from "node:fs/promises"
|
|
|
5
5
|
import { dirname, join } from "node:path"
|
|
6
6
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
7
7
|
import { managedWorkbaseAgents } from "../workbase/agents-file"
|
|
8
|
+
import { managedWorkbaseOpencode } from "../workbase/opencode-file"
|
|
8
9
|
import { WorkbaseService } from "./WorkbaseService"
|
|
9
10
|
|
|
10
11
|
const write = async (root: string, path: string, content: string) => {
|
|
@@ -18,6 +19,11 @@ const managedAgents = (body: string) => {
|
|
|
18
19
|
return `<!-- agency-managed: sha256=${checksum} -->\n\n${body}`
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
const managedOpencode = (body: string) => {
|
|
23
|
+
const checksum = createHash("sha256").update(body).digest("hex")
|
|
24
|
+
return `// agency-managed: sha256=${checksum}\n\n${body}`
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
describe("WorkbaseService", () => {
|
|
22
28
|
let root: string
|
|
23
29
|
|
|
@@ -45,6 +51,66 @@ describe("WorkbaseService", () => {
|
|
|
45
51
|
expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
|
|
46
52
|
managedWorkbaseAgents,
|
|
47
53
|
)
|
|
54
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
55
|
+
managedWorkbaseOpencode,
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("preserves an unmanaged workbase OpenCode config", async () => {
|
|
60
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
61
|
+
await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
|
|
62
|
+
|
|
63
|
+
await runTestEffect(
|
|
64
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
68
|
+
'{"model":"test/model"}\n',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("does not override an existing JSON OpenCode config", async () => {
|
|
73
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
74
|
+
await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
|
|
75
|
+
|
|
76
|
+
await runTestEffect(
|
|
77
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
|
|
82
|
+
).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("updates an unmodified managed workbase OpenCode config", async () => {
|
|
86
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
87
|
+
await write(
|
|
88
|
+
root,
|
|
89
|
+
".opencode/opencode.jsonc",
|
|
90
|
+
managedOpencode('{"references":{}}\n'),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
await runTestEffect(
|
|
94
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
98
|
+
managedWorkbaseOpencode,
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("preserves a modified managed workbase OpenCode config", async () => {
|
|
103
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
104
|
+
const content = `${managedOpencode('{"references":{}}\n')}\n// User edit\n`
|
|
105
|
+
await write(root, ".opencode/opencode.jsonc", content)
|
|
106
|
+
|
|
107
|
+
await runTestEffect(
|
|
108
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
112
|
+
content,
|
|
113
|
+
)
|
|
48
114
|
})
|
|
49
115
|
|
|
50
116
|
test("preserves an unmanaged workbase AGENTS.md", async () => {
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
canUpdateManagedWorkbaseAgents,
|
|
20
20
|
managedWorkbaseAgents,
|
|
21
21
|
} from "../workbase/agents-file"
|
|
22
|
+
import {
|
|
23
|
+
canUpdateManagedWorkbaseOpencode,
|
|
24
|
+
managedWorkbaseOpencode,
|
|
25
|
+
} from "../workbase/opencode-file"
|
|
22
26
|
|
|
23
27
|
class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
|
|
24
28
|
readonly message: string
|
|
@@ -86,6 +90,32 @@ const ensureWorkbaseAgents = (root: string) =>
|
|
|
86
90
|
}
|
|
87
91
|
})
|
|
88
92
|
|
|
93
|
+
const ensureWorkbaseOpencode = (root: string) =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const fs = yield* FileSystemService
|
|
96
|
+
const directory = join(root, ".opencode")
|
|
97
|
+
const path = join(directory, "opencode.jsonc")
|
|
98
|
+
if (!(yield* fs.exists(path))) {
|
|
99
|
+
if (yield* fs.exists(join(directory, "opencode.json"))) return
|
|
100
|
+
yield* fs.createDirectory(directory)
|
|
101
|
+
yield* fs.writeFile(path, managedWorkbaseOpencode)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const content = yield* fs.readFile(path)
|
|
106
|
+
if (
|
|
107
|
+
content !== managedWorkbaseOpencode &&
|
|
108
|
+
canUpdateManagedWorkbaseOpencode(content)
|
|
109
|
+
) {
|
|
110
|
+
yield* fs.writeFile(path, managedWorkbaseOpencode)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const ensureWorkbaseAgentFiles = (root: string) =>
|
|
115
|
+
Effect.all([ensureWorkbaseAgents(root), ensureWorkbaseOpencode(root)], {
|
|
116
|
+
concurrency: "unbounded",
|
|
117
|
+
})
|
|
118
|
+
|
|
89
119
|
const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
|
|
90
120
|
const dependencies = new Map(
|
|
91
121
|
nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
|
|
@@ -164,7 +194,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
164
194
|
`${existing}${prefix}${missing.join("\n")}\n`,
|
|
165
195
|
)
|
|
166
196
|
}
|
|
167
|
-
yield*
|
|
197
|
+
yield* ensureWorkbaseAgentFiles(root)
|
|
168
198
|
|
|
169
199
|
return root
|
|
170
200
|
}),
|
|
@@ -221,7 +251,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
221
251
|
})
|
|
222
252
|
}
|
|
223
253
|
}
|
|
224
|
-
yield*
|
|
254
|
+
yield* ensureWorkbaseAgentFiles(current)
|
|
225
255
|
return current
|
|
226
256
|
}
|
|
227
257
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import {
|
|
6
6
|
captureLogs,
|
|
@@ -600,6 +600,155 @@ pr: null
|
|
|
600
600
|
).rejects.toThrow("is attached to branch 'main'")
|
|
601
601
|
})
|
|
602
602
|
|
|
603
|
+
test("removes worktrees without deleting branches", async () => {
|
|
604
|
+
await runTestEffect(
|
|
605
|
+
TaskService.pipe(
|
|
606
|
+
Effect.flatMap((service) =>
|
|
607
|
+
service.create(
|
|
608
|
+
{
|
|
609
|
+
id: "removable",
|
|
610
|
+
ticketUrl: "https://example.com/task",
|
|
611
|
+
repo: "agency",
|
|
612
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
613
|
+
branch: "task/removable",
|
|
614
|
+
base: "main",
|
|
615
|
+
},
|
|
616
|
+
root,
|
|
617
|
+
),
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
)
|
|
621
|
+
const workspace = await runTestEffect(
|
|
622
|
+
WorktreeService.pipe(
|
|
623
|
+
Effect.flatMap((service) =>
|
|
624
|
+
service.materialize("removable", undefined, root),
|
|
625
|
+
),
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
const removed = await runTestEffect(
|
|
630
|
+
WorktreeService.pipe(
|
|
631
|
+
Effect.flatMap((service) =>
|
|
632
|
+
service.remove("removable", undefined, root),
|
|
633
|
+
),
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
expect(removed.sort()).toEqual(
|
|
638
|
+
[
|
|
639
|
+
join(workspace.codePath, "agency"),
|
|
640
|
+
join(workspace.codePath, "effect"),
|
|
641
|
+
].sort(),
|
|
642
|
+
)
|
|
643
|
+
expect(await Bun.file(workspace.codePath).exists()).toBe(false)
|
|
644
|
+
const branch = Bun.spawnSync([
|
|
645
|
+
"git",
|
|
646
|
+
"-C",
|
|
647
|
+
join(root, "repos/agency"),
|
|
648
|
+
"show-ref",
|
|
649
|
+
"--verify",
|
|
650
|
+
"refs/heads/task/removable",
|
|
651
|
+
])
|
|
652
|
+
expect(branch.exitCode).toBe(0)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
test("refuses to remove a worktree with uncommitted changes", async () => {
|
|
656
|
+
await runTestEffect(
|
|
657
|
+
TaskService.pipe(
|
|
658
|
+
Effect.flatMap((service) =>
|
|
659
|
+
service.create(
|
|
660
|
+
{
|
|
661
|
+
id: "dirty",
|
|
662
|
+
ticketUrl: "https://example.com/task",
|
|
663
|
+
repo: "agency",
|
|
664
|
+
branch: "task/dirty",
|
|
665
|
+
base: "main",
|
|
666
|
+
},
|
|
667
|
+
root,
|
|
668
|
+
),
|
|
669
|
+
),
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
const workspace = await runTestEffect(
|
|
673
|
+
WorktreeService.pipe(
|
|
674
|
+
Effect.flatMap((service) =>
|
|
675
|
+
service.materialize("dirty", undefined, root),
|
|
676
|
+
),
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
await Bun.write(
|
|
680
|
+
join(workspace.writablePath, "uncommitted.txt"),
|
|
681
|
+
"keep me\n",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
await expect(
|
|
685
|
+
runTestEffect(
|
|
686
|
+
WorktreeService.pipe(
|
|
687
|
+
Effect.flatMap((service) => service.remove("dirty", undefined, root)),
|
|
688
|
+
),
|
|
689
|
+
),
|
|
690
|
+
).rejects.toThrow("Failed to remove worktree for 'agency'")
|
|
691
|
+
expect(
|
|
692
|
+
await Bun.file(join(workspace.writablePath, "uncommitted.txt")).text(),
|
|
693
|
+
).toBe("keep me\n")
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test("handles a missing checkout without deleting its branch", async () => {
|
|
697
|
+
await runTestEffect(
|
|
698
|
+
TaskService.pipe(
|
|
699
|
+
Effect.flatMap((service) =>
|
|
700
|
+
service.create(
|
|
701
|
+
{
|
|
702
|
+
id: "stale",
|
|
703
|
+
ticketUrl: "https://example.com/task",
|
|
704
|
+
repo: "agency",
|
|
705
|
+
branch: "task/stale",
|
|
706
|
+
base: "main",
|
|
707
|
+
},
|
|
708
|
+
root,
|
|
709
|
+
),
|
|
710
|
+
),
|
|
711
|
+
),
|
|
712
|
+
)
|
|
713
|
+
const workspace = await runTestEffect(
|
|
714
|
+
WorktreeService.pipe(
|
|
715
|
+
Effect.flatMap((service) =>
|
|
716
|
+
service.materialize("stale", undefined, root),
|
|
717
|
+
),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
await rm(workspace.codePath, { recursive: true })
|
|
721
|
+
|
|
722
|
+
const removed = await runTestEffect(
|
|
723
|
+
WorktreeService.pipe(
|
|
724
|
+
Effect.flatMap((service) => service.remove("stale", undefined, root)),
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
expect(removed).toEqual([])
|
|
729
|
+
const worktrees = Bun.spawnSync([
|
|
730
|
+
"git",
|
|
731
|
+
"-C",
|
|
732
|
+
join(root, "repos/agency"),
|
|
733
|
+
"worktree",
|
|
734
|
+
"list",
|
|
735
|
+
"--porcelain",
|
|
736
|
+
])
|
|
737
|
+
expect(new TextDecoder().decode(worktrees.stdout)).not.toContain(
|
|
738
|
+
workspace.writablePath,
|
|
739
|
+
)
|
|
740
|
+
expect(
|
|
741
|
+
Bun.spawnSync([
|
|
742
|
+
"git",
|
|
743
|
+
"-C",
|
|
744
|
+
join(root, "repos/agency"),
|
|
745
|
+
"show-ref",
|
|
746
|
+
"--verify",
|
|
747
|
+
"refs/heads/task/stale",
|
|
748
|
+
]).exitCode,
|
|
749
|
+
).toBe(0)
|
|
750
|
+
})
|
|
751
|
+
|
|
603
752
|
test("supports Worktrunk as the configured command", async () => {
|
|
604
753
|
if (Bun.spawnSync(["which", "wt"], { stdout: "ignore" }).exitCode !== 0) {
|
|
605
754
|
return
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Data, Effect } from "effect"
|
|
2
|
-
import { dirname, join, resolve } from "node:path"
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
3
3
|
import { FileSystemService } from "./FileSystemService"
|
|
4
4
|
import { WorkbaseService } from "./WorkbaseService"
|
|
5
5
|
import { TaskService } from "./TaskService"
|
|
@@ -372,6 +372,128 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
372
372
|
repos: execution.repos ?? [],
|
|
373
373
|
} satisfies ExecutionWorkspace
|
|
374
374
|
}),
|
|
375
|
+
|
|
376
|
+
remove: (
|
|
377
|
+
taskId: string,
|
|
378
|
+
phaseId?: string,
|
|
379
|
+
startPath: string = process.cwd(),
|
|
380
|
+
) =>
|
|
381
|
+
Effect.gen(function* () {
|
|
382
|
+
const fs = yield* FileSystemService
|
|
383
|
+
const workbase = yield* WorkbaseService
|
|
384
|
+
const tasks = yield* TaskService
|
|
385
|
+
const phases = yield* PhaseService
|
|
386
|
+
const root = yield* workbase.discover(startPath)
|
|
387
|
+
const task = yield* tasks.show(taskId, root)
|
|
388
|
+
|
|
389
|
+
let execution: {
|
|
390
|
+
repo: string
|
|
391
|
+
repos?: readonly RepositoryReference[]
|
|
392
|
+
}
|
|
393
|
+
let codePath: string
|
|
394
|
+
if ("phases" in task.data) {
|
|
395
|
+
if (!phaseId) {
|
|
396
|
+
return yield* new WorktreeError({
|
|
397
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
const phase = yield* phases.show(taskId, phaseId, root)
|
|
401
|
+
execution = phase.data
|
|
402
|
+
codePath = join(dirname(phase.path), "code")
|
|
403
|
+
} else {
|
|
404
|
+
if (phaseId) {
|
|
405
|
+
return yield* new WorktreeError({
|
|
406
|
+
message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
execution = task.data
|
|
410
|
+
codePath = join(dirname(task.path), "code")
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const codeDirectoryExists = yield* fs.isDirectory(codePath)
|
|
414
|
+
const removed: string[] = []
|
|
415
|
+
for (const alias of [
|
|
416
|
+
execution.repo,
|
|
417
|
+
...(execution.repos ?? []).map((reference) => reference.repo),
|
|
418
|
+
]) {
|
|
419
|
+
const repositoryPath = join(root, "repos", alias)
|
|
420
|
+
const checkoutPath = join(codePath, alias)
|
|
421
|
+
const listed = yield* fs.runCommand(
|
|
422
|
+
[
|
|
423
|
+
"git",
|
|
424
|
+
"-C",
|
|
425
|
+
repositoryPath,
|
|
426
|
+
"worktree",
|
|
427
|
+
"list",
|
|
428
|
+
"--porcelain",
|
|
429
|
+
"-z",
|
|
430
|
+
],
|
|
431
|
+
{ captureOutput: true },
|
|
432
|
+
)
|
|
433
|
+
if (listed.exitCode !== 0) {
|
|
434
|
+
return yield* new WorktreeError({
|
|
435
|
+
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const checkoutExists = yield* fs.isDirectory(checkoutPath)
|
|
440
|
+
const canonicalCheckoutPath = checkoutExists
|
|
441
|
+
? yield* fs.realPath(checkoutPath)
|
|
442
|
+
: join(
|
|
443
|
+
yield* fs.realPath(dirname(codePath)),
|
|
444
|
+
basename(codePath),
|
|
445
|
+
alias,
|
|
446
|
+
)
|
|
447
|
+
let registeredPath: string | undefined
|
|
448
|
+
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
449
|
+
const worktreePath = (yield* fs.exists(worktree.path))
|
|
450
|
+
? yield* fs.realPath(worktree.path)
|
|
451
|
+
: resolve(worktree.path)
|
|
452
|
+
if (worktreePath === canonicalCheckoutPath) {
|
|
453
|
+
registeredPath = worktreePath
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (!registeredPath) {
|
|
458
|
+
if (checkoutExists) {
|
|
459
|
+
return yield* new WorktreeError({
|
|
460
|
+
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const result = yield* fs.runCommand(
|
|
467
|
+
[
|
|
468
|
+
"git",
|
|
469
|
+
"-C",
|
|
470
|
+
repositoryPath,
|
|
471
|
+
"worktree",
|
|
472
|
+
"remove",
|
|
473
|
+
...(!checkoutExists ? ["--force"] : []),
|
|
474
|
+
checkoutExists ? checkoutPath : registeredPath,
|
|
475
|
+
],
|
|
476
|
+
{ captureOutput: true },
|
|
477
|
+
)
|
|
478
|
+
if (result.exitCode !== 0) {
|
|
479
|
+
return yield* new WorktreeError({
|
|
480
|
+
message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
if (checkoutExists) removed.push(checkoutPath)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
|
|
487
|
+
const remaining = yield* fs.readDirectory(codePath)
|
|
488
|
+
if (remaining.length > 0) {
|
|
489
|
+
return yield* new WorktreeError({
|
|
490
|
+
message: `Cannot remove ${codePath}; it contains unmanaged entries: ${remaining.map((entry) => entry.name).join(", ")}`,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
yield* fs.deleteDirectory(codePath)
|
|
494
|
+
}
|
|
495
|
+
return removed
|
|
496
|
+
}),
|
|
375
497
|
}),
|
|
376
498
|
},
|
|
377
499
|
) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { TaskService } from "./services/TaskService"
|
|
|
11
11
|
import { PhaseService } from "./services/PhaseService"
|
|
12
12
|
import { WorktreeService } from "./services/WorktreeService"
|
|
13
13
|
import { PullRequestService } from "./services/PullRequestService"
|
|
14
|
+
import { ArchiveService } from "./services/ArchiveService"
|
|
14
15
|
|
|
15
16
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
16
17
|
|
|
@@ -26,6 +27,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
26
27
|
PhaseService.Default,
|
|
27
28
|
WorktreeService.Default,
|
|
28
29
|
PullRequestService.Default,
|
|
30
|
+
ArchiveService.Default,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
export async function runTestEffect<A, E>(
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createProgress } from "./progress"
|
|
3
|
+
|
|
4
|
+
describe("progress", () => {
|
|
5
|
+
test("replaces an active TTY line with its completed state", () => {
|
|
6
|
+
const output: string[] = []
|
|
7
|
+
const progress = createProgress(
|
|
8
|
+
{},
|
|
9
|
+
{ isTTY: true, write: (text) => output.push(text) },
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
progress.start("Preparing workspace...")
|
|
13
|
+
progress.succeed("Workspace ready")
|
|
14
|
+
|
|
15
|
+
expect(output).toEqual([
|
|
16
|
+
"\r\x1b[2K\x1b[2m○\x1b[0m Preparing workspace...",
|
|
17
|
+
"\r\x1b[2K\x1b[32m✓\x1b[0m Workspace ready\n",
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("stays quiet for silent or non-TTY output", () => {
|
|
22
|
+
const output: string[] = []
|
|
23
|
+
for (const [silent, isTTY] of [
|
|
24
|
+
[true, true],
|
|
25
|
+
[false, false],
|
|
26
|
+
] as const) {
|
|
27
|
+
const progress = createProgress(
|
|
28
|
+
{ silent },
|
|
29
|
+
{ isTTY, write: (text) => output.push(text) },
|
|
30
|
+
)
|
|
31
|
+
progress.start("Preparing workspace...")
|
|
32
|
+
progress.fail("Workspace preparation failed")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
expect(output).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface ProgressOptions {
|
|
2
|
+
readonly silent?: boolean
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface ProgressOutput {
|
|
6
|
+
readonly isTTY: boolean
|
|
7
|
+
readonly write: (text: string) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Progress {
|
|
11
|
+
readonly start: (message: string) => void
|
|
12
|
+
readonly succeed: (message: string) => void
|
|
13
|
+
readonly fail: (message: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const terminalOutput: ProgressOutput = {
|
|
17
|
+
isTTY: Boolean(process.stderr.isTTY),
|
|
18
|
+
write: (text) => process.stderr.write(text),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createProgress = (
|
|
22
|
+
options: ProgressOptions,
|
|
23
|
+
output: ProgressOutput = terminalOutput,
|
|
24
|
+
): Progress => {
|
|
25
|
+
const enabled = !options.silent && output.isTTY
|
|
26
|
+
const write = (symbol: string, message: string, complete: boolean) => {
|
|
27
|
+
if (!enabled) return
|
|
28
|
+
output.write(`\r\x1b[2K${symbol} ${message}${complete ? "\n" : ""}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
start: (message) => write("\x1b[2m○\x1b[0m", message, false),
|
|
33
|
+
succeed: (message) => write("\x1b[32m✓\x1b[0m", message, true),
|
|
34
|
+
fail: (message) => write("\x1b[31m✗\x1b[0m", message, true),
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -24,7 +24,10 @@ field. Repositories listed in plural `repos` are read-only references.
|
|
|
24
24
|
|
|
25
25
|
- Keep task-level decisions in `TASK.md` and phase-specific delivery context in
|
|
26
26
|
`PHASE.md`.
|
|
27
|
+
- Keep execution-unit `status` current with `agency task status` or
|
|
28
|
+
`agency phase status`; `agency work` marks launched work as `working`.
|
|
27
29
|
- Do not manually create, move, or remove worktrees under `code/`.
|
|
30
|
+
- Use `agency archive`, rather than moving work item folders manually.
|
|
28
31
|
- Do not edit bare repositories or repository symlinks under `repos/`.
|
|
29
32
|
- Do not run `agency work` from an active agent session unless the user
|
|
30
33
|
explicitly asks to launch another agent.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
const managedHeaderPattern =
|
|
4
|
+
/^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
|
|
5
|
+
|
|
6
|
+
const checksum = (content: string) =>
|
|
7
|
+
createHash("sha256").update(content).digest("hex")
|
|
8
|
+
|
|
9
|
+
const body = `${JSON.stringify(
|
|
10
|
+
{
|
|
11
|
+
$schema: "https://opencode.ai/config.json",
|
|
12
|
+
references: {
|
|
13
|
+
tasks: {
|
|
14
|
+
path: "../tasks",
|
|
15
|
+
description: "Agency task definitions and execution context",
|
|
16
|
+
},
|
|
17
|
+
epics: {
|
|
18
|
+
path: "../epics",
|
|
19
|
+
description: "Agency epic definitions and orchestration context",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
null,
|
|
24
|
+
2,
|
|
25
|
+
)}\n`
|
|
26
|
+
|
|
27
|
+
const renderManagedWorkbaseOpencode = (content: string = body) =>
|
|
28
|
+
`// agency-managed: sha256=${checksum(content)}\n\n${content}`
|
|
29
|
+
|
|
30
|
+
export const managedWorkbaseOpencode = renderManagedWorkbaseOpencode()
|
|
31
|
+
|
|
32
|
+
export const canUpdateManagedWorkbaseOpencode = (content: string) => {
|
|
33
|
+
const match = content.match(managedHeaderPattern)
|
|
34
|
+
if (!match?.[1]) return false
|
|
35
|
+
|
|
36
|
+
return checksum(content.slice(match[0].length)) === match[1]
|
|
37
|
+
}
|