@markjaquith/agency 2.3.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.
Files changed (37) hide show
  1. package/README.md +45 -16
  2. package/cli.ts +21 -0
  3. package/package.json +1 -1
  4. package/skills/agency/SKILL.md +31 -4
  5. package/src/cli.test.ts +1 -0
  6. package/src/commands/archive.test.ts +69 -0
  7. package/src/commands/archive.ts +70 -0
  8. package/src/commands/init.test.ts +3 -0
  9. package/src/commands/phase.ts +23 -1
  10. package/src/commands/pr.ts +1 -0
  11. package/src/commands/task-phase.test.ts +46 -1
  12. package/src/commands/task.test.ts +94 -0
  13. package/src/commands/task.ts +160 -17
  14. package/src/commands/work.test.ts +63 -34
  15. package/src/commands/work.ts +20 -3
  16. package/src/services/ArchiveService.test.ts +334 -0
  17. package/src/services/ArchiveService.ts +246 -0
  18. package/src/services/FileSystemService.ts +7 -2
  19. package/src/services/PhaseService.ts +28 -0
  20. package/src/services/PullRequestService.ts +3 -0
  21. package/src/services/TaskPhaseService.test.ts +79 -0
  22. package/src/services/TaskService.ts +31 -1
  23. package/src/services/WorkbaseService.test.ts +66 -0
  24. package/src/services/WorkbaseService.ts +32 -2
  25. package/src/services/WorktreeService.test.ts +210 -2
  26. package/src/services/WorktreeService.ts +143 -1
  27. package/src/test-utils.ts +2 -0
  28. package/src/utils/process.test.ts +38 -1
  29. package/src/utils/process.ts +30 -6
  30. package/src/utils/progress.test.ts +37 -0
  31. package/src/utils/progress.ts +36 -0
  32. package/src/workbase/AGENTS.md +3 -0
  33. package/src/workbase/opencode-file.ts +37 -0
  34. package/src/workbase/schemas.test.ts +50 -0
  35. package/src/workbase/schemas.ts +6 -2
  36. package/src/workbase/work-target.test.ts +9 -9
  37. package/src/workbase/work-target.ts +36 -6
@@ -212,6 +212,85 @@ describe("task and phase services", () => {
212
212
  branch: "task/single",
213
213
  base: "main",
214
214
  pr: "https://github.com/example/agency/pull/42",
215
+ status: "open",
215
216
  })
216
217
  })
218
+
219
+ test("updates status on execution units", async () => {
220
+ const createdTask = await runTestEffect(
221
+ TaskService.pipe(
222
+ Effect.flatMap((service) =>
223
+ service.create(
224
+ {
225
+ id: "single-status",
226
+ ticketUrl: "https://example.com/task",
227
+ repo: "agency",
228
+ branch: "task/single-status",
229
+ base: "main",
230
+ },
231
+ root,
232
+ ),
233
+ ),
234
+ ),
235
+ )
236
+ expect(createdTask.content).toContain("status: open")
237
+ const task = await runTestEffect(
238
+ TaskService.pipe(
239
+ Effect.flatMap((service) =>
240
+ service.setStatus("single-status", "done", root),
241
+ ),
242
+ ),
243
+ )
244
+ expect(task.data.status).toBe("done")
245
+ expect(task.content).toContain("status: done")
246
+ expect(task.content).toContain("Describe the task outcome.")
247
+
248
+ await runTestEffect(
249
+ TaskService.pipe(
250
+ Effect.flatMap((service) =>
251
+ service.create(
252
+ {
253
+ id: "multi-status",
254
+ ticketUrl: "https://example.com/task",
255
+ multiPhase: true,
256
+ },
257
+ root,
258
+ ),
259
+ ),
260
+ ),
261
+ )
262
+ await runTestEffect(
263
+ PhaseService.pipe(
264
+ Effect.flatMap((service) =>
265
+ service.create(
266
+ {
267
+ taskId: "multi-status",
268
+ id: "implementation",
269
+ repo: "agency",
270
+ branch: "task/multi-status",
271
+ base: "main",
272
+ },
273
+ root,
274
+ ),
275
+ ),
276
+ ),
277
+ )
278
+ const phase = await runTestEffect(
279
+ PhaseService.pipe(
280
+ Effect.flatMap((service) =>
281
+ service.setStatus("multi-status", "implementation", "dropped", root),
282
+ ),
283
+ ),
284
+ )
285
+ expect(phase.data.status).toBe("dropped")
286
+ await expect(
287
+ runTestEffect(
288
+ TaskService.pipe(
289
+ Effect.flatMap((service) =>
290
+ service.setStatus("multi-status", "done", root),
291
+ ),
292
+ ),
293
+ ),
294
+ ).rejects.toThrow("set status on a phase instead")
295
+ })
217
296
  })
@@ -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* ensureWorkbaseAgents(root)
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* ensureWorkbaseAgents(current)
254
+ yield* ensureWorkbaseAgentFiles(current)
225
255
  return current
226
256
  }
227
257
  }
@@ -1,8 +1,13 @@
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
- import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
5
+ import {
6
+ captureLogs,
7
+ cleanupTempDir,
8
+ createTempDir,
9
+ runTestEffect,
10
+ } from "../test-utils"
6
11
  import { TaskService } from "./TaskService"
7
12
  import { PhaseService } from "./PhaseService"
8
13
  import { WorktreeService } from "./WorktreeService"
@@ -127,6 +132,60 @@ describe("WorktreeService", () => {
127
132
  ).toBe("example\n")
128
133
  })
129
134
 
135
+ test("logs the expanded configured command in verbose mode", async () => {
136
+ await Bun.write(
137
+ join(root, "agency.json"),
138
+ JSON.stringify({
139
+ version: 2,
140
+ worktreeCreateCommand: [
141
+ "sh",
142
+ "-c",
143
+ 'git -C "$1" worktree add -b "$3" "$2" "$4" >/dev/null 2>&1',
144
+ "agency-worktree",
145
+ "{repo}",
146
+ "{worktree}",
147
+ "{branch}",
148
+ "{base}",
149
+ ],
150
+ }),
151
+ )
152
+ await runTestEffect(
153
+ TaskService.pipe(
154
+ Effect.flatMap((service) =>
155
+ service.create(
156
+ {
157
+ id: "verbose-command",
158
+ ticketUrl: "https://example.com/task",
159
+ repo: "agency",
160
+ branch: "task/verbose-command",
161
+ base: "main",
162
+ },
163
+ root,
164
+ ),
165
+ ),
166
+ ),
167
+ )
168
+
169
+ const logs = await captureLogs(() =>
170
+ runTestEffect(
171
+ WorktreeService.pipe(
172
+ Effect.flatMap((service) =>
173
+ service.materialize("verbose-command", undefined, root, {
174
+ verbose: true,
175
+ }),
176
+ ),
177
+ ),
178
+ ),
179
+ )
180
+
181
+ expect(logs).toHaveLength(1)
182
+ expect(logs[0]).toStartWith("Running worktree command: sh -c ")
183
+ expect(logs[0]).toContain(join(root, "repos", "agency"))
184
+ expect(logs[0]).toContain(
185
+ join(root, "tasks", "verbose-command", "code", "agency"),
186
+ )
187
+ })
188
+
130
189
  test("selects phases and rejects missing or unexpected phase IDs", async () => {
131
190
  await runTestEffect(
132
191
  TaskService.pipe(
@@ -541,6 +600,155 @@ pr: null
541
600
  ).rejects.toThrow("is attached to branch 'main'")
542
601
  })
543
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
+
544
752
  test("supports Worktrunk as the configured command", async () => {
545
753
  if (Bun.spawnSync(["which", "wt"], { stdout: "ignore" }).exitCode !== 0) {
546
754
  return