@markjaquith/agency 2.28.1 → 2.30.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 (42) hide show
  1. package/README.md +63 -12
  2. package/cli.ts +15 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +25 -15
  6. package/skills/agency/references/commands.md +34 -17
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +50 -12
  9. package/src/cli-parser.test.ts +9 -0
  10. package/src/cli-parser.ts +20 -3
  11. package/src/cli.test.ts +205 -3
  12. package/src/commands/pr.test.ts +20 -1
  13. package/src/commands/repo.test.ts +66 -1
  14. package/src/commands/repo.ts +35 -8
  15. package/src/commands/status.test.ts +22 -0
  16. package/src/commands/status.ts +1 -0
  17. package/src/commands/sync.ts +5 -3
  18. package/src/commands/workbase.ts +2 -1
  19. package/src/graph-schema.test.ts +52 -4
  20. package/src/protocol.test.ts +41 -8
  21. package/src/readiness.test.ts +75 -17
  22. package/src/services/DoctorService.ts +22 -13
  23. package/src/services/EpicService.ts +1 -1
  24. package/src/services/GraphMutationService.ts +3 -3
  25. package/src/services/GraphService.ts +13 -4
  26. package/src/services/PhaseService.ts +1 -1
  27. package/src/services/ReadinessService.test.ts +47 -0
  28. package/src/services/RepositoryService.test.ts +299 -5
  29. package/src/services/RepositoryService.ts +725 -98
  30. package/src/services/SyncService.test.ts +36 -0
  31. package/src/services/SyncService.ts +20 -1
  32. package/src/services/TaskService.ts +1 -1
  33. package/src/services/WorkbaseService.test.ts +32 -0
  34. package/src/services/WorkbaseService.ts +22 -14
  35. package/src/services/WorktreeLock.test.ts +122 -0
  36. package/src/services/WorktreeService.test.ts +17 -2
  37. package/src/services/WorktreeService.ts +3 -3
  38. package/src/utils/process.test.ts +4 -3
  39. package/src/workbase/AGENTS.md +5 -0
  40. package/src/workbase/dependency-graph.test.ts +50 -0
  41. package/src/workbase/schemas.test.ts +52 -0
  42. package/src/workbase/schemas.ts +19 -0
@@ -16,6 +16,40 @@ const runGit = async (args: string[]) => {
16
16
  }
17
17
  }
18
18
 
19
+ const portableRemote = (name: string) =>
20
+ `https://example.com/agency-tests/${name}.git`
21
+
22
+ const setPortableOrigin = (path: string, name: string) =>
23
+ runGit(["-C", path, "remote", "add", "origin", portableRemote(name)])
24
+
25
+ const startGitDaemon = async (basePath: string) => {
26
+ const port = 20000 + Math.floor(Math.random() * 20000)
27
+ const process = Bun.spawn(
28
+ [
29
+ "git",
30
+ "daemon",
31
+ "--reuseaddr",
32
+ "--export-all",
33
+ `--base-path=${basePath}`,
34
+ "--listen=127.0.0.1",
35
+ `--port=${port}`,
36
+ basePath,
37
+ ],
38
+ { stdout: "pipe", stderr: "pipe" },
39
+ )
40
+ const remote = `git://127.0.0.1:${port}/source.git`
41
+ for (let attempt = 0; attempt < 40; attempt++) {
42
+ const probe = Bun.spawn(["git", "ls-remote", remote], {
43
+ stdout: "ignore",
44
+ stderr: "ignore",
45
+ })
46
+ if ((await probe.exited) === 0) return { process, remote }
47
+ await Bun.sleep(25)
48
+ }
49
+ process.kill()
50
+ throw new Error("Git daemon did not start")
51
+ }
52
+
19
53
  const write = async (root: string, path: string, content: string) => {
20
54
  const fullPath = join(root, path)
21
55
  await mkdir(dirname(fullPath), { recursive: true })
@@ -37,10 +71,11 @@ describe("RepositoryService", () => {
37
71
  test("adds a bare repository from a remote", async () => {
38
72
  const source = join(root, "source.git")
39
73
  await runGit(["init", "--bare", "--initial-branch=main", source])
74
+ await setPortableOrigin(source, "agency")
40
75
 
41
76
  const destination = await runTestEffect(
42
77
  RepositoryService.pipe(
43
- Effect.flatMap((service) => service.add("agency", source, root)),
78
+ Effect.flatMap((service) => service.add("agency", "source.git", root)),
44
79
  ),
45
80
  )
46
81
 
@@ -55,8 +90,10 @@ describe("RepositoryService", () => {
55
90
  alias: "agency",
56
91
  path: destination,
57
92
  kind: "bare",
58
- remote: source,
93
+ remote: portableRemote("agency"),
94
+ declaredRemote: portableRemote("agency"),
59
95
  target: null,
96
+ states: ["declared", "materialized"],
60
97
  },
61
98
  ])
62
99
  })
@@ -65,6 +102,7 @@ describe("RepositoryService", () => {
65
102
  const target = join(root, "linked-repository")
66
103
  await mkdir(target, { recursive: true })
67
104
  await runGit(["init", "--initial-branch=main", target])
105
+ await setPortableOrigin(target, "effect")
68
106
 
69
107
  const destination = await runTestEffect(
70
108
  RepositoryService.pipe(
@@ -79,14 +117,17 @@ describe("RepositoryService", () => {
79
117
  alias: "effect",
80
118
  path: destination,
81
119
  kind: "symlink",
82
- remote: null,
120
+ remote: portableRemote("effect"),
121
+ declaredRemote: portableRemote("effect"),
83
122
  target,
123
+ states: ["declared", "linked"],
84
124
  })
85
125
  })
86
126
 
87
127
  test("rejects invalid and duplicate aliases", async () => {
88
128
  const source = join(root, "source.git")
89
129
  await runGit(["init", "--bare", "--initial-branch=main", source])
130
+ await setPortableOrigin(source, "duplicate")
90
131
 
91
132
  await expect(
92
133
  runTestEffect(
@@ -110,10 +151,96 @@ describe("RepositoryService", () => {
110
151
  ).rejects.toThrow("already exists")
111
152
  })
112
153
 
154
+ test("leaves no declaration or materialization when cloning fails", async () => {
155
+ await expect(
156
+ runTestEffect(
157
+ RepositoryService.pipe(
158
+ Effect.flatMap((service) =>
159
+ service.add("failed", "git://127.0.0.1:1/missing.git", root),
160
+ ),
161
+ ),
162
+ ),
163
+ ).rejects.toThrow("Failed to clone repository")
164
+ expect(await Bun.file(join(root, "agency.json")).json()).toEqual({
165
+ version: 2,
166
+ })
167
+ expect(await Bun.file(join(root, "repos/failed")).exists()).toBe(false)
168
+ })
169
+
170
+ test("rolls back a repository move when config installation fails", async () => {
171
+ const source = join(root, "source.git")
172
+ await runGit(["init", "--bare", "--initial-branch=main", source])
173
+ await setPortableOrigin(source, "rollback")
174
+ await runTestEffect(
175
+ RepositoryService.pipe(
176
+ Effect.flatMap((service) => service.add("old", source, root)),
177
+ ),
178
+ )
179
+ const configBefore = await Bun.file(join(root, "agency.json")).text()
180
+ const originalWrite = Bun.write
181
+ ;(Bun as { write: typeof Bun.write }).write = ((
182
+ destination: unknown,
183
+ data: unknown,
184
+ ) =>
185
+ String(destination).includes(".agency-transaction-")
186
+ ? Promise.reject(new Error("injected config write failure"))
187
+ : originalWrite(
188
+ destination as never,
189
+ data as never,
190
+ )) as typeof Bun.write
191
+
192
+ try {
193
+ await expect(
194
+ runTestEffect(
195
+ RepositoryService.pipe(
196
+ Effect.flatMap((service) => service.rename("old", "new", root)),
197
+ ),
198
+ ),
199
+ ).rejects.toThrow("completed changes were rolled back")
200
+ } finally {
201
+ ;(Bun as { write: typeof Bun.write }).write = originalWrite
202
+ }
203
+
204
+ expect(await Bun.file(join(root, "repos/old/HEAD")).exists()).toBe(true)
205
+ expect(await Bun.file(join(root, "repos/new/HEAD")).exists()).toBe(false)
206
+ expect(await Bun.file(join(root, "agency.json")).text()).toBe(configBefore)
207
+ expect(
208
+ (await Array.fromAsync(new Bun.Glob(".agency-*").scan(root))).length,
209
+ ).toBe(0)
210
+ })
211
+
212
+ test("reports a file collision as invalid setup state", async () => {
213
+ await Bun.write(
214
+ join(root, "agency.json"),
215
+ JSON.stringify({
216
+ version: 2,
217
+ repositories: {
218
+ blocked: { remote: portableRemote("blocked") },
219
+ },
220
+ }),
221
+ )
222
+ await mkdir(join(root, "repos"), { recursive: true })
223
+ await Bun.write(join(root, "repos/blocked"), "not a repository")
224
+
225
+ const setup = await runTestEffect(
226
+ RepositoryService.pipe(
227
+ Effect.flatMap((service) => service.setup({ cwd: root })),
228
+ ),
229
+ )
230
+
231
+ expect(setup.actions).toEqual([])
232
+ expect(setup.repositories[0]?.states).toEqual(["declared", "invalid"])
233
+ expect(setup.unresolved[0]).toMatchObject({
234
+ alias: "blocked",
235
+ state: "invalid",
236
+ })
237
+ })
238
+
113
239
  test("shows, fetches, updates, and verifies a repository", async () => {
114
240
  const source = join(root, "source")
115
- const replacement = join(root, "replacement.git")
241
+ const replacement = portableRemote("replacement")
116
242
  await runGit(["init", "--initial-branch=main", source])
243
+ await setPortableOrigin(source, "source")
117
244
  await Bun.write(join(source, "README.md"), "# Source\n")
118
245
  await runGit(["-C", source, "add", "README.md"])
119
246
  await runGit([
@@ -127,12 +254,18 @@ describe("RepositoryService", () => {
127
254
  "-m",
128
255
  "Initial commit",
129
256
  ])
130
- await runGit(["init", "--bare", "--initial-branch=main", replacement])
131
257
  await runTestEffect(
132
258
  RepositoryService.pipe(
133
259
  Effect.flatMap((service) => service.add("agency", source, root)),
134
260
  ),
135
261
  )
262
+ await runGit([
263
+ "-C",
264
+ join(root, "repos/agency"),
265
+ "config",
266
+ `url.${source}.insteadOf`,
267
+ portableRemote("source"),
268
+ ])
136
269
 
137
270
  const result = await runTestEffect(
138
271
  RepositoryService.pipe(
@@ -149,6 +282,7 @@ describe("RepositoryService", () => {
149
282
  )
150
283
 
151
284
  expect(result.shown.remote).toBe(source)
285
+ expect(result.shown.declaredRemote).toBe(portableRemote("source"))
152
286
  expect(result.updated.remote).toBe(replacement)
153
287
  expect(result.verified.valid).toBe(true)
154
288
  expect(result.verified.issues).toEqual([])
@@ -157,6 +291,7 @@ describe("RepositoryService", () => {
157
291
  test("renames and removes an unused repository", async () => {
158
292
  const source = join(root, "source.git")
159
293
  await runGit(["init", "--bare", "--initial-branch=main", source])
294
+ await setPortableOrigin(source, "rename")
160
295
  await runTestEffect(
161
296
  RepositoryService.pipe(
162
297
  Effect.flatMap((service) => service.add("old", source, root)),
@@ -183,6 +318,7 @@ describe("RepositoryService", () => {
183
318
  const target = join(root, "linked-repository")
184
319
  await mkdir(target, { recursive: true })
185
320
  await runGit(["init", "--initial-branch=main", target])
321
+ await setPortableOrigin(target, "linked")
186
322
  await runTestEffect(
187
323
  RepositoryService.pipe(
188
324
  Effect.flatMap((service) => service.link("linked", target, root)),
@@ -199,11 +335,79 @@ describe("RepositoryService", () => {
199
335
  expect(await Bun.file(join(root, "repos/linked/.git/HEAD")).exists()).toBe(
200
336
  false,
201
337
  )
338
+ const declared = await runTestEffect(
339
+ RepositoryService.pipe(
340
+ Effect.flatMap((service) => service.show("linked", root)),
341
+ ),
342
+ )
343
+ expect(declared.states).toEqual(["declared", "missing"])
344
+ })
345
+
346
+ test("refuses to unlink an alias with active references", async () => {
347
+ const target = join(root, "referenced-link")
348
+ await mkdir(target, { recursive: true })
349
+ await runGit(["init", "--initial-branch=main", target])
350
+ await setPortableOrigin(target, "referenced-link")
351
+ await runTestEffect(
352
+ RepositoryService.pipe(
353
+ Effect.flatMap((service) => service.link("linked", target, root)),
354
+ ),
355
+ )
356
+ await write(
357
+ root,
358
+ "tasks/active/TASK.md",
359
+ `---
360
+ ticketUrl: null
361
+ repo: linked
362
+ branch: task/active-link
363
+ base: main
364
+ pr: null
365
+ ---
366
+ `,
367
+ )
368
+
369
+ await expect(
370
+ runTestEffect(
371
+ RepositoryService.pipe(
372
+ Effect.flatMap((service) => service.unlink("linked", root)),
373
+ ),
374
+ ),
375
+ ).rejects.toThrow("active reference execution-unit:task/active")
376
+ expect(await Bun.file(join(root, "repos/linked/.git/HEAD")).exists()).toBe(
377
+ true,
378
+ )
379
+ })
380
+
381
+ test("replaces an unused managed clone with a linked checkout", async () => {
382
+ const source = join(root, "source.git")
383
+ const target = join(root, "local-checkout")
384
+ await runGit(["init", "--bare", "--initial-branch=main", source])
385
+ await setPortableOrigin(source, "replace")
386
+ await mkdir(target, { recursive: true })
387
+ await runGit(["init", "--initial-branch=main", target])
388
+
389
+ const linked = await runTestEffect(
390
+ RepositoryService.pipe(
391
+ Effect.flatMap((service) =>
392
+ Effect.gen(function* () {
393
+ yield* service.add("agency", source, root)
394
+ yield* service.link("agency", target, root)
395
+ return yield* service.show("agency", root)
396
+ }),
397
+ ),
398
+ ),
399
+ )
400
+
401
+ expect(linked.kind).toBe("symlink")
402
+ expect(linked.target).toBe(target)
403
+ expect(linked.declaredRemote).toBe(portableRemote("replace"))
404
+ expect(linked.states).toEqual(["declared", "linked", "remote-drifted"])
202
405
  })
203
406
 
204
407
  test("reports active references and refuses unsafe removal", async () => {
205
408
  const source = join(root, "source.git")
206
409
  await runGit(["init", "--bare", "--initial-branch=main", source])
410
+ await setPortableOrigin(source, "referenced")
207
411
  await runTestEffect(
208
412
  RepositoryService.pipe(
209
413
  Effect.flatMap((service) => service.add("agency", source, root)),
@@ -231,4 +435,94 @@ pr: null
231
435
  ).rejects.toThrow("active reference execution-unit:task/active")
232
436
  expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
233
437
  })
438
+
439
+ test("plans and applies missing portable repository setup", async () => {
440
+ const source = join(root, "source.git")
441
+ await runGit(["init", "--bare", "--initial-branch=main", source])
442
+ const daemon = await startGitDaemon(root)
443
+ try {
444
+ await Bun.write(
445
+ join(root, "agency.json"),
446
+ JSON.stringify({
447
+ version: 2,
448
+ repositories: { agency: { remote: daemon.remote } },
449
+ }),
450
+ )
451
+
452
+ const result = await runTestEffect(
453
+ RepositoryService.pipe(
454
+ Effect.flatMap((service) =>
455
+ Effect.gen(function* () {
456
+ const planned = yield* service.setup({ cwd: root })
457
+ const absent = !(yield* Effect.promise(() =>
458
+ Bun.file(join(root, "repos/agency/HEAD")).exists(),
459
+ ))
460
+ const applied = yield* service.setup({ cwd: root, apply: true })
461
+ return { planned, absent, applied }
462
+ }),
463
+ ),
464
+ ),
465
+ )
466
+
467
+ expect(result.planned.mode).toBe("dry-run")
468
+ expect(result.planned.actions[0]).toMatchObject({
469
+ kind: "materialize",
470
+ status: "planned",
471
+ })
472
+ expect(result.absent).toBe(true)
473
+ expect(result.applied.actions[0]?.status).toBe("applied")
474
+ expect(result.applied.repositories[0]?.states).toEqual([
475
+ "declared",
476
+ "materialized",
477
+ ])
478
+ expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(
479
+ true,
480
+ )
481
+ } finally {
482
+ daemon.process.kill()
483
+ await daemon.process.exited
484
+ }
485
+ })
486
+
487
+ test("adopts legacy materializations and reports remote drift", async () => {
488
+ const legacy = join(root, "repos/legacy")
489
+ await mkdir(legacy, { recursive: true })
490
+ await runGit(["init", "--initial-branch=main", legacy])
491
+ await setPortableOrigin(legacy, "legacy")
492
+
493
+ const result = await runTestEffect(
494
+ RepositoryService.pipe(
495
+ Effect.flatMap((service) =>
496
+ Effect.gen(function* () {
497
+ const planned = yield* service.setup({ cwd: root })
498
+ const applied = yield* service.setup({ cwd: root, apply: true })
499
+ yield* Effect.promise(() =>
500
+ runGit([
501
+ "-C",
502
+ legacy,
503
+ "remote",
504
+ "set-url",
505
+ "origin",
506
+ "https://example.com/agency-tests/changed.git",
507
+ ]),
508
+ )
509
+ const drifted = yield* service.show("legacy", root)
510
+ const setup = yield* service.setup({ cwd: root })
511
+ return { planned, applied, drifted, setup }
512
+ }),
513
+ ),
514
+ ),
515
+ )
516
+
517
+ expect(result.planned.actions[0]?.kind).toBe("adopt")
518
+ expect(result.applied.repositories[0]?.states).toEqual([
519
+ "declared",
520
+ "materialized",
521
+ ])
522
+ expect(result.drifted.states).toContain("remote-drifted")
523
+ expect(result.setup.unresolved[0]).toMatchObject({
524
+ alias: "legacy",
525
+ state: "remote-drifted",
526
+ })
527
+ })
234
528
  })