@markjaquith/agency 3.2.2 → 3.2.4

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 (48) hide show
  1. package/README.md +30 -62
  2. package/cli-main.ts +20 -83
  3. package/fixtures/protocol/orchestration-recipes.json +9 -34
  4. package/package.json +1 -4
  5. package/schemas/agency-graph-v1.schema.json +2 -31
  6. package/src/cli-parser.test.ts +34 -132
  7. package/src/cli-parser.ts +45 -102
  8. package/src/cli.test.ts +60 -117
  9. package/src/commands/act.ts +2 -6
  10. package/src/commands/sync.ts +3 -3
  11. package/src/commands/validate.ts +3 -1
  12. package/src/commands/work.test.ts +70 -8
  13. package/src/commands/work.ts +15 -8
  14. package/src/graph-schema.ts +0 -2
  15. package/src/protocol.test.ts +0 -25
  16. package/src/protocol.ts +0 -11
  17. package/src/readiness.test.ts +2 -2
  18. package/src/services/ArchiveBulkService.test.ts +0 -88
  19. package/src/services/ArchiveService.ts +0 -32
  20. package/src/services/GraphMutationService.ts +0 -26
  21. package/src/services/GraphService.test.ts +1 -1
  22. package/src/services/IntegrationService.test.ts +7 -4
  23. package/src/services/LifecycleTransaction.ts +5 -1
  24. package/src/services/PhaseService.ts +1 -8
  25. package/src/services/ReadinessService.test.ts +0 -34
  26. package/src/services/ReadinessService.ts +1 -20
  27. package/src/services/ReviewService.test.ts +0 -64
  28. package/src/services/ReviewService.ts +0 -5
  29. package/src/services/SyncService.test.ts +75 -88
  30. package/src/services/SyncService.ts +52 -123
  31. package/src/services/TaskPhaseService.test.ts +13 -1
  32. package/src/services/TaskService.ts +1 -7
  33. package/src/services/WorkbaseService.ts +0 -3
  34. package/src/services/WorktreeService.test.ts +192 -1
  35. package/src/services/WorktreeService.ts +169 -34
  36. package/src/test-utils.ts +0 -2
  37. package/src/usage-log.test.ts +89 -5
  38. package/src/usage-log.ts +139 -31
  39. package/src/workbase/AGENTS.md +9 -15
  40. package/src/workbase/agent-command.test.ts +1 -3
  41. package/src/workbase/agent-command.ts +1 -6
  42. package/src/workbase/document-revision.ts +0 -4
  43. package/src/workbase/opencode-plugin-file.ts +1 -0
  44. package/src/workbase/schemas.test.ts +12 -25
  45. package/src/workbase/schemas.ts +0 -16
  46. package/src/commands/claim.ts +0 -122
  47. package/src/services/ClaimService.test.ts +0 -415
  48. package/src/services/ClaimService.ts +0 -608
@@ -1,415 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
- import { Effect } from "effect"
3
- import { mkdir, readFile, writeFile } from "node:fs/promises"
4
- import { join } from "node:path"
5
- import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
- import { ClaimService } from "./ClaimService"
7
- import { PhaseService } from "./PhaseService"
8
- import { TaskService } from "./TaskService"
9
-
10
- const at = (value: string) => new Date(value)
11
-
12
- describe("claim service", () => {
13
- let root: string
14
-
15
- beforeEach(async () => {
16
- root = await createTempDir()
17
- await Bun.write(join(root, "agency.json"), '{"version":2}\n')
18
- await mkdir(join(root, "repos/agency"), { recursive: true })
19
- await runTestEffect(
20
- TaskService.pipe(
21
- Effect.flatMap((service) =>
22
- service.create(
23
- {
24
- id: "single",
25
- ticketUrl: null,
26
- repo: "agency",
27
- branch: "task/single",
28
- base: "main",
29
- },
30
- root,
31
- ),
32
- ),
33
- ),
34
- )
35
- })
36
-
37
- afterEach(async () => cleanupTempDir(root))
38
-
39
- const inspect = (taskId = "single", phaseId?: string) =>
40
- runTestEffect(
41
- ClaimService.pipe(
42
- Effect.flatMap((service) => service.inspect(taskId, phaseId, root)),
43
- ),
44
- )
45
-
46
- const claim = async (
47
- revision: string,
48
- sessionId = "session-1",
49
- now = at("2026-07-17T12:00:00.000Z"),
50
- expiresAt?: string,
51
- ) =>
52
- runTestEffect(
53
- ClaimService.pipe(
54
- Effect.flatMap((service) =>
55
- service.claim(
56
- {
57
- taskId: "single",
58
- claimant: "orchestrator-1",
59
- agent: "agent-1",
60
- sessionId,
61
- revision,
62
- now,
63
- ...(expiresAt ? { expiresAt } : {}),
64
- },
65
- root,
66
- ),
67
- ),
68
- ),
69
- )
70
-
71
- test("records ownership and guarded release and finish transitions", async () => {
72
- const initial = await inspect()
73
- const acquired = await claim(
74
- initial.revision,
75
- "session-1",
76
- at("2026-07-17T12:00:00.000Z"),
77
- "2026-07-17T13:00:00.000Z",
78
- )
79
-
80
- expect(acquired.claim).toEqual({
81
- claimant: "orchestrator-1",
82
- agent: "agent-1",
83
- sessionId: "session-1",
84
- startedAt: "2026-07-17T12:00:00.000Z",
85
- targetRevision: initial.revision,
86
- expiresAt: "2026-07-17T13:00:00.000Z",
87
- state: "active",
88
- })
89
- expect((await inspect()).data.status).toBe("working")
90
- await expect(
91
- runTestEffect(
92
- TaskService.pipe(
93
- Effect.flatMap((service) =>
94
- service.setStatus("single", "done", root),
95
- ),
96
- ),
97
- ),
98
- ).rejects.toThrow("has an active claim")
99
-
100
- const released = await runTestEffect(
101
- ClaimService.pipe(
102
- Effect.flatMap((service) =>
103
- service.release(
104
- {
105
- taskId: "single",
106
- sessionId: "session-1",
107
- revision: acquired.revision,
108
- now: at("2026-07-17T12:15:00.000Z"),
109
- },
110
- root,
111
- ),
112
- ),
113
- ),
114
- )
115
- expect(released.data.status).toBe("open")
116
- expect(released.claim).toMatchObject({
117
- state: "released",
118
- releasedAt: "2026-07-17T12:15:00.000Z",
119
- })
120
-
121
- const reacquired = await claim(
122
- released.revision,
123
- "session-2",
124
- at("2026-07-17T12:20:00.000Z"),
125
- )
126
- const finished = await runTestEffect(
127
- ClaimService.pipe(
128
- Effect.flatMap((service) =>
129
- service.finish(
130
- {
131
- taskId: "single",
132
- sessionId: "session-2",
133
- revision: reacquired.revision,
134
- outcome: "done",
135
- now: at("2026-07-17T12:45:00.000Z"),
136
- },
137
- root,
138
- ),
139
- ),
140
- ),
141
- )
142
- expect(finished.data.status).toBe("working")
143
- expect(finished.claim).toMatchObject({
144
- state: "finished",
145
- finishedAt: "2026-07-17T12:45:00.000Z",
146
- outcome: "done",
147
- })
148
- })
149
-
150
- test("finishes claimed non-PR work with durable completion evidence", async () => {
151
- const initial = await inspect()
152
- const acquired = await claim(initial.revision)
153
- const finished = await runTestEffect(
154
- ClaimService.pipe(
155
- Effect.flatMap((service) =>
156
- service.finish(
157
- {
158
- taskId: "single",
159
- sessionId: "session-1",
160
- revision: acquired.revision,
161
- outcome: "done",
162
- nonPrCompletion: {
163
- summary: "Review completed; no code change was needed.",
164
- evidenceUrl: "https://example.com/review",
165
- },
166
- now: at("2026-07-17T12:45:00.000Z"),
167
- },
168
- root,
169
- ),
170
- ),
171
- ),
172
- )
173
-
174
- expect(finished.data).toMatchObject({
175
- status: "done",
176
- claim: { state: "finished", outcome: "done" },
177
- completion: {
178
- mode: "non-pr",
179
- completedAt: "2026-07-17T12:45:00.000Z",
180
- summary: "Review completed; no code change was needed.",
181
- evidenceUrl: "https://example.com/review",
182
- },
183
- })
184
- })
185
-
186
- test("rejects invalid claimed non-PR completion", async () => {
187
- const initial = await inspect()
188
- const acquired = await claim(initial.revision)
189
- await expect(
190
- runTestEffect(
191
- ClaimService.pipe(
192
- Effect.flatMap((service) =>
193
- service.finish(
194
- {
195
- taskId: "single",
196
- sessionId: "session-1",
197
- revision: acquired.revision,
198
- outcome: "done",
199
- nonPrCompletion: { summary: " " },
200
- },
201
- root,
202
- ),
203
- ),
204
- ),
205
- ),
206
- ).rejects.toThrow("summary must not be empty")
207
- })
208
-
209
- test("returns structured ownership and revision conflicts", async () => {
210
- const initial = await inspect()
211
- const acquired = await claim(initial.revision)
212
-
213
- await expect(claim(acquired.revision, "session-2")).rejects.toThrow(
214
- "is claimed by 'agent-1'",
215
- )
216
- await expect(
217
- runTestEffect(
218
- ClaimService.pipe(
219
- Effect.flatMap((service) =>
220
- service.release(
221
- {
222
- taskId: "single",
223
- sessionId: "session-2",
224
- revision: acquired.revision,
225
- },
226
- root,
227
- ),
228
- ),
229
- ),
230
- ),
231
- ).rejects.toThrow("does not own")
232
- await expect(claim(initial.revision, "session-3")).rejects.toThrow(
233
- "Revision conflict",
234
- )
235
- })
236
-
237
- test("inspects a task without parsing unrelated task documents", async () => {
238
- await mkdir(join(root, "tasks/broken"), { recursive: true })
239
- await Bun.write(join(root, "tasks/broken/TASK.md"), "not frontmatter\n")
240
-
241
- const inspected = await inspect()
242
- expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
243
- expect(inspected.data).toMatchObject({ branch: "task/single" })
244
- })
245
-
246
- test("inspects a phase without parsing unrelated phase documents", async () => {
247
- await mkdir(join(root, "tasks/phased/phases/target"), { recursive: true })
248
- await mkdir(join(root, "tasks/phased/phases/broken"), { recursive: true })
249
- await Bun.write(
250
- join(root, "tasks/phased/TASK.md"),
251
- "---\nticketUrl: null\nphases:\n - id: target\n - id: broken\nstatus: working\n---\n",
252
- )
253
- await Bun.write(
254
- join(root, "tasks/phased/phases/target/PHASE.md"),
255
- "---\nrepo: agency\nbranch: phase/target\nbase: main\npr: null\nstatus: open\n---\n",
256
- )
257
- await Bun.write(
258
- join(root, "tasks/phased/phases/broken/PHASE.md"),
259
- "not frontmatter\n",
260
- )
261
-
262
- const inspected = await inspect("phased", "target")
263
- expect(inspected.target.path).toBe(
264
- join(root, "tasks/phased/phases/target/PHASE.md"),
265
- )
266
- expect(inspected.data).toMatchObject({ branch: "phase/target" })
267
- })
268
- test("serializes concurrent claims and allows expired ownership replacement", async () => {
269
- const initial = await inspect()
270
- const attempts = await Promise.allSettled([
271
- claim(initial.revision, "session-a"),
272
- claim(initial.revision, "session-b"),
273
- ])
274
- expect(
275
- attempts.filter((result) => result.status === "fulfilled"),
276
- ).toHaveLength(1)
277
- expect(
278
- attempts.filter((result) => result.status === "rejected"),
279
- ).toHaveLength(1)
280
- const current = await inspect()
281
- expect(current.data.claim?.state).toBe("active")
282
-
283
- await runTestEffect(
284
- ClaimService.pipe(
285
- Effect.flatMap((service) =>
286
- service.release(
287
- {
288
- taskId: "single",
289
- sessionId: current.data.claim!.sessionId,
290
- revision: current.revision,
291
- },
292
- root,
293
- ),
294
- ),
295
- ),
296
- )
297
- const released = await inspect()
298
- const expiring = await claim(
299
- released.revision,
300
- "expiring",
301
- at("2026-07-17T12:00:00.000Z"),
302
- "2026-07-17T12:01:00.000Z",
303
- )
304
- const replacement = await claim(
305
- expiring.revision,
306
- "replacement",
307
- at("2026-07-17T12:02:00.000Z"),
308
- )
309
- expect(replacement.claim.sessionId).toBe("replacement")
310
- })
311
-
312
- test("claims phases and rejects multi-phase task containers", async () => {
313
- await runTestEffect(
314
- TaskService.pipe(
315
- Effect.flatMap((service) =>
316
- service.create(
317
- { id: "multi", ticketUrl: null, multiPhase: true },
318
- root,
319
- ),
320
- ),
321
- ),
322
- )
323
- await runTestEffect(
324
- PhaseService.pipe(
325
- Effect.flatMap((service) =>
326
- service.create(
327
- {
328
- taskId: "multi",
329
- id: "implementation",
330
- repo: "agency",
331
- branch: "task/multi",
332
- base: "main",
333
- },
334
- root,
335
- ),
336
- ),
337
- ),
338
- )
339
- await expect(inspect("multi")).rejects.toThrow("claim a phase instead")
340
- const phase = await inspect("multi", "implementation")
341
- const acquired = await runTestEffect(
342
- ClaimService.pipe(
343
- Effect.flatMap((service) =>
344
- service.claim(
345
- {
346
- taskId: "multi",
347
- phaseId: "implementation",
348
- claimant: "orchestrator",
349
- agent: "agent",
350
- sessionId: "phase-session",
351
- revision: phase.revision,
352
- },
353
- root,
354
- ),
355
- ),
356
- ),
357
- )
358
- expect(acquired.target).toBe("phase 'multi/implementation'")
359
- })
360
-
361
- test("inspects only the requested task on large workbases", async () => {
362
- for (let index = 0; index < 100; index += 1) {
363
- const taskRoot = join(root, "tasks", `unrelated-${index}`)
364
- await mkdir(taskRoot, { recursive: true })
365
- await writeFile(join(taskRoot, "TASK.md"), "not valid frontmatter\n")
366
- }
367
-
368
- const inspected = await inspect()
369
- expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
370
- expect(inspected.data.status).toBe("open")
371
- })
372
-
373
- test("inspects only the requested phase", async () => {
374
- await runTestEffect(
375
- TaskService.pipe(
376
- Effect.flatMap((service) =>
377
- service.create(
378
- { id: "multi", ticketUrl: null, multiPhase: true },
379
- root,
380
- ),
381
- ),
382
- ),
383
- )
384
- await runTestEffect(
385
- PhaseService.pipe(
386
- Effect.flatMap((service) =>
387
- service.create(
388
- {
389
- taskId: "multi",
390
- id: "requested",
391
- repo: "agency",
392
- branch: "task/multi",
393
- base: "main",
394
- },
395
- root,
396
- ),
397
- ),
398
- ),
399
- )
400
- const taskPath = join(root, "tasks/multi/TASK.md")
401
- await writeFile(
402
- taskPath,
403
- (await readFile(taskPath, "utf8")).replace("multi", "["),
404
- )
405
- const unrelatedPhase = join(root, "tasks/multi/phases/unrelated")
406
- await mkdir(unrelatedPhase, { recursive: true })
407
- await writeFile(join(unrelatedPhase, "PHASE.md"), "not valid frontmatter\n")
408
-
409
- const inspected = await inspect("multi", "requested")
410
- expect(inspected.target.path).toBe(
411
- join(root, "tasks/multi/phases/requested/PHASE.md"),
412
- )
413
- expect(inspected.data.status).toBe("open")
414
- })
415
- })