@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +326 -399
  24. package/src/commands/work.ts +153 -153
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -0,0 +1,446 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { dirname, join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { WorkbaseService } from "./WorkbaseService"
7
+
8
+ const write = async (root: string, path: string, content: string) => {
9
+ const fullPath = join(root, path)
10
+ await mkdir(dirname(fullPath), { recursive: true })
11
+ await Bun.write(fullPath, content)
12
+ }
13
+
14
+ describe("WorkbaseService", () => {
15
+ let root: string
16
+
17
+ beforeEach(async () => {
18
+ root = await createTempDir()
19
+ })
20
+
21
+ afterEach(async () => {
22
+ await cleanupTempDir(root)
23
+ })
24
+
25
+ test("discovers a workbase from a nested directory", async () => {
26
+ await write(root, "agency.json", '{"version":2}\n')
27
+ await mkdir(join(root, "nested/repository/src"), { recursive: true })
28
+
29
+ const discovered = await runTestEffect(
30
+ WorkbaseService.pipe(
31
+ Effect.flatMap((service) =>
32
+ service.discover(join(root, "nested/repository/src")),
33
+ ),
34
+ ),
35
+ )
36
+
37
+ expect(discovered).toBe(root)
38
+ })
39
+
40
+ test("rejects an invalid worktree command template", async () => {
41
+ await write(
42
+ root,
43
+ "agency.json",
44
+ JSON.stringify({
45
+ version: 2,
46
+ worktreeCreateCommand: ["tool", "{repo}"],
47
+ }),
48
+ )
49
+
50
+ await expect(
51
+ runTestEffect(
52
+ WorkbaseService.pipe(
53
+ Effect.flatMap((service) => service.discover(root)),
54
+ ),
55
+ ),
56
+ ).rejects.toThrow("{worktree}")
57
+ })
58
+
59
+ test("validates a workbase with an epic and multi-phase task", async () => {
60
+ await write(root, "agency.json", '{"version":2}\n')
61
+ await mkdir(join(root, "repos/agency"), { recursive: true })
62
+ await mkdir(join(root, "repos/effect"), { recursive: true })
63
+ await write(
64
+ root,
65
+ "epics/example/EPIC.md",
66
+ `---
67
+ ticketUrl: https://example.com/epics/example
68
+ repos:
69
+ - repo: agency
70
+ ref: main
71
+ tasks:
72
+ - id: example-task
73
+ ---
74
+
75
+ # Example
76
+ `,
77
+ )
78
+ await write(
79
+ root,
80
+ "tasks/example-task/TASK.md",
81
+ `---
82
+ ticketUrl: https://example.com/tasks/example
83
+ epic: example
84
+ phases:
85
+ - id: implementation
86
+ ---
87
+
88
+ # Example task
89
+ `,
90
+ )
91
+ await write(
92
+ root,
93
+ "tasks/example-task/phases/implementation/PHASE.md",
94
+ `---
95
+ repo: agency
96
+ repos:
97
+ - repo: effect
98
+ ref: main
99
+ branch: task/example
100
+ base: main
101
+ pr: null
102
+ ---
103
+
104
+ # Implementation
105
+ `,
106
+ )
107
+
108
+ const report = await runTestEffect(
109
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
110
+ )
111
+
112
+ expect(report.valid).toBe(true)
113
+ expect(report.issues).toEqual([])
114
+ expect(report.epicCount).toBe(1)
115
+ expect(report.taskCount).toBe(1)
116
+ expect(report.phaseCount).toBe(1)
117
+ })
118
+
119
+ test("reports schema, alias, backlink, and dependency errors", async () => {
120
+ await write(root, "agency.json", '{"version":2}\n')
121
+ await mkdir(join(root, "repos/agency"), { recursive: true })
122
+ await write(
123
+ root,
124
+ "epics/example/EPIC.md",
125
+ `---
126
+ ticketUrl: https://example.com/epics/example
127
+ repos:
128
+ - repo: missing
129
+ ref: main
130
+ tasks:
131
+ - id: example-task
132
+ dependsOn:
133
+ - absent-task
134
+ ---
135
+
136
+ # Example
137
+ `,
138
+ )
139
+ await write(
140
+ root,
141
+ "tasks/example-task/TASK.md",
142
+ `---
143
+ ticketUrl: https://example.com/tasks/example
144
+ repo: agency
145
+ repos:
146
+ - repo: agency
147
+ ref: main
148
+ branch: task/example
149
+ base: main
150
+ pr: null
151
+ ---
152
+
153
+ # Example task
154
+ `,
155
+ )
156
+ await write(
157
+ root,
158
+ "tasks/bad-schema/TASK.md",
159
+ `---
160
+ ticketUrl: https://example.com/tasks/bad-schema
161
+ repo: agency
162
+ branch: task/bad-schema
163
+ base: main
164
+ pr: not-a-url
165
+ ---
166
+
167
+ # Bad schema
168
+ `,
169
+ )
170
+
171
+ const report = await runTestEffect(
172
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
173
+ )
174
+
175
+ expect(report.valid).toBe(false)
176
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
177
+ "Unknown repository alias 'missing'",
178
+ )
179
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
180
+ "Unknown task dependency 'absent-task'",
181
+ )
182
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
183
+ "Task must reference parent epic 'example'",
184
+ )
185
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
186
+ "Repository 'agency' cannot also be a reference",
187
+ )
188
+ expect(
189
+ report.issues.some(
190
+ (issue) => issue.path.endsWith("bad-schema/TASK.md") && issue.message,
191
+ ),
192
+ ).toBe(true)
193
+ expect(report.issues.some((issue) => issue.path.endsWith("TASK.md"))).toBe(
194
+ true,
195
+ )
196
+ })
197
+
198
+ test("reports duplicate writable branch ownership", async () => {
199
+ await write(root, "agency.json", '{"version":2}\n')
200
+ await mkdir(join(root, "repos/agency"), { recursive: true })
201
+ for (const id of ["first", "second"]) {
202
+ await write(
203
+ root,
204
+ `tasks/${id}/TASK.md`,
205
+ `---
206
+ ticketUrl: https://example.com/tasks/${id}
207
+ repo: agency
208
+ branch: task/shared
209
+ base: main
210
+ pr: null
211
+ ---
212
+ `,
213
+ )
214
+ }
215
+
216
+ const report = await runTestEffect(
217
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
218
+ )
219
+
220
+ expect(report.valid).toBe(false)
221
+ expect(report.issues).toContainEqual({
222
+ path: "tasks/second/TASK.md",
223
+ message:
224
+ "Writable branch 'task/shared' for repository 'agency' is also owned by tasks/first/TASK.md",
225
+ })
226
+ })
227
+
228
+ test("reports high-risk structural validation failures", async () => {
229
+ const fixtures: readonly {
230
+ name: string
231
+ files: Readonly<Record<string, string>>
232
+ directories?: readonly string[]
233
+ expected: readonly string[]
234
+ }[] = [
235
+ {
236
+ name: "missing-documents",
237
+ files: {},
238
+ directories: [
239
+ "epics/missing-epic",
240
+ "tasks/missing-task",
241
+ "tasks/missing-task/phases/missing-phase",
242
+ ],
243
+ expected: [
244
+ "epics/missing-epic/EPIC.md: Required document is missing",
245
+ "tasks/missing-task/TASK.md: Required document is missing",
246
+ "tasks/missing-task/phases/missing-phase/PHASE.md: Required document is missing",
247
+ ],
248
+ },
249
+ {
250
+ name: "duplicates",
251
+ files: {
252
+ "epics/duplicate/EPIC.md": `---
253
+ ticketUrl: https://example.com/epics/duplicate
254
+ repos:
255
+ - repo: agency
256
+ ref: main
257
+ - repo: agency
258
+ ref: release
259
+ tasks:
260
+ - id: child
261
+ - id: child
262
+ ---
263
+ `,
264
+ "tasks/child/TASK.md": `---
265
+ ticketUrl: https://example.com/tasks/child
266
+ epic: duplicate
267
+ phases:
268
+ - id: implementation
269
+ - id: implementation
270
+ ---
271
+ `,
272
+ "tasks/child/phases/implementation/PHASE.md": `---
273
+ repo: agency
274
+ branch: task/duplicate
275
+ base: main
276
+ pr: null
277
+ ---
278
+ `,
279
+ },
280
+ expected: [
281
+ "Repository references must be unique",
282
+ "Epic task IDs must be unique",
283
+ "Task phase IDs must be unique",
284
+ ],
285
+ },
286
+ {
287
+ name: "backlinks",
288
+ files: {
289
+ "epics/parent/EPIC.md": `---
290
+ ticketUrl: https://example.com/epics/parent
291
+ repos:
292
+ - repo: agency
293
+ ref: main
294
+ tasks:
295
+ - id: missing-backlink
296
+ ---
297
+ `,
298
+ "tasks/missing-backlink/TASK.md": `---
299
+ ticketUrl: https://example.com/tasks/missing-backlink
300
+ repo: agency
301
+ branch: task/missing-backlink
302
+ base: main
303
+ pr: null
304
+ ---
305
+ `,
306
+ "tasks/unlisted/TASK.md": `---
307
+ ticketUrl: https://example.com/tasks/unlisted
308
+ epic: parent
309
+ repo: agency
310
+ branch: task/unlisted
311
+ base: main
312
+ pr: null
313
+ ---
314
+ `,
315
+ },
316
+ expected: [
317
+ "Task must reference parent epic 'parent'",
318
+ "Epic does not list child task 'unlisted'",
319
+ ],
320
+ },
321
+ {
322
+ name: "dependency-cycles",
323
+ files: {
324
+ "epics/cycles/EPIC.md": `---
325
+ ticketUrl: https://example.com/epics/cycles
326
+ repos:
327
+ - repo: agency
328
+ ref: main
329
+ tasks:
330
+ - id: first
331
+ dependsOn: [second]
332
+ - id: second
333
+ dependsOn: [first]
334
+ ---
335
+ `,
336
+ "tasks/first/TASK.md": `---
337
+ ticketUrl: https://example.com/tasks/first
338
+ epic: cycles
339
+ phases:
340
+ - id: contract
341
+ dependsOn: [delivery]
342
+ - id: delivery
343
+ dependsOn: [contract]
344
+ ---
345
+ `,
346
+ "tasks/first/phases/contract/PHASE.md": `---
347
+ repo: agency
348
+ branch: task/contract
349
+ base: main
350
+ pr: null
351
+ ---
352
+ `,
353
+ "tasks/first/phases/delivery/PHASE.md": `---
354
+ repo: agency
355
+ branch: task/delivery
356
+ base: main
357
+ pr: null
358
+ ---
359
+ `,
360
+ "tasks/second/TASK.md": `---
361
+ ticketUrl: https://example.com/tasks/second
362
+ epic: cycles
363
+ repo: agency
364
+ branch: task/second
365
+ base: main
366
+ pr: null
367
+ ---
368
+ `,
369
+ },
370
+ expected: [
371
+ "Task dependency cycle includes 'first'",
372
+ "Phase dependency cycle includes 'contract'",
373
+ ],
374
+ },
375
+ {
376
+ name: "phase-layout",
377
+ files: {
378
+ "tasks/multi/TASK.md": `---
379
+ ticketUrl: https://example.com/tasks/multi
380
+ phases:
381
+ - id: listed
382
+ ---
383
+ `,
384
+ "tasks/multi/phases/listed/PHASE.md": `---
385
+ repo: agency
386
+ branch: task/listed
387
+ base: main
388
+ pr: null
389
+ ---
390
+ `,
391
+ "tasks/multi/phases/unlisted/PHASE.md": `---
392
+ repo: agency
393
+ branch: task/unlisted
394
+ base: main
395
+ pr: null
396
+ ---
397
+ `,
398
+ "tasks/single/TASK.md": `---
399
+ ticketUrl: https://example.com/tasks/single
400
+ repo: agency
401
+ branch: task/single
402
+ base: main
403
+ pr: null
404
+ ---
405
+ `,
406
+ "tasks/single/phases/unexpected/PHASE.md": `---
407
+ repo: agency
408
+ branch: task/unexpected
409
+ base: main
410
+ pr: null
411
+ ---
412
+ `,
413
+ },
414
+ expected: [
415
+ "Unlisted phase 'unlisted'",
416
+ "Single-phase task cannot contain phase directories",
417
+ ],
418
+ },
419
+ ]
420
+
421
+ for (const fixture of fixtures) {
422
+ const fixtureRoot = join(root, fixture.name)
423
+ await write(fixtureRoot, "agency.json", '{"version":2}\n')
424
+ await mkdir(join(fixtureRoot, "repos/agency"), { recursive: true })
425
+ for (const directory of fixture.directories ?? []) {
426
+ await mkdir(join(fixtureRoot, directory), { recursive: true })
427
+ }
428
+ for (const [path, content] of Object.entries(fixture.files)) {
429
+ await write(fixtureRoot, path, content)
430
+ }
431
+
432
+ const report = await runTestEffect(
433
+ WorkbaseService.pipe(
434
+ Effect.flatMap((service) => service.validate(fixtureRoot)),
435
+ ),
436
+ )
437
+ const messages = report.issues
438
+ .map((issue) => `${issue.path}: ${issue.message}`)
439
+ .join("\n")
440
+ expect(report.valid, fixture.name).toBe(false)
441
+ for (const expected of fixture.expected) {
442
+ expect(messages, fixture.name).toContain(expected)
443
+ }
444
+ }
445
+ })
446
+ })