@markjaquith/agency 3.2.14 → 3.2.15

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 (69) hide show
  1. package/package.json +7 -2
  2. package/src/workbase/schemas.ts +12 -0
  3. package/fixtures/protocol/orchestration-recipes.json +0 -53
  4. package/pi-extensions/agency.test.ts +0 -117
  5. package/src/check-commits.test.ts +0 -58
  6. package/src/cli-parser.test.ts +0 -945
  7. package/src/cli.test.ts +0 -1889
  8. package/src/commands/act.test.ts +0 -418
  9. package/src/commands/archive.test.ts +0 -236
  10. package/src/commands/context.test.ts +0 -711
  11. package/src/commands/description.test.ts +0 -103
  12. package/src/commands/doctor.test.ts +0 -185
  13. package/src/commands/epic.test.ts +0 -196
  14. package/src/commands/init.test.ts +0 -117
  15. package/src/commands/integration.test.ts +0 -165
  16. package/src/commands/pr.test.ts +0 -248
  17. package/src/commands/push.test.ts +0 -56
  18. package/src/commands/read-only.test.ts +0 -161
  19. package/src/commands/repo.test.ts +0 -199
  20. package/src/commands/restore.test.ts +0 -106
  21. package/src/commands/status.test.ts +0 -113
  22. package/src/commands/sync.test.ts +0 -200
  23. package/src/commands/task-phase.test.ts +0 -410
  24. package/src/commands/task.test.ts +0 -281
  25. package/src/commands/validate.test.ts +0 -186
  26. package/src/commands/work.test.ts +0 -1375
  27. package/src/commands/workbase.test.ts +0 -170
  28. package/src/graph-schema.test.ts +0 -124
  29. package/src/protocol.test.ts +0 -163
  30. package/src/readiness.test.ts +0 -105
  31. package/src/services/ArchiveBulkService.test.ts +0 -384
  32. package/src/services/ArchiveService.test.ts +0 -1092
  33. package/src/services/EpicService.test.ts +0 -74
  34. package/src/services/FileSystemService.test.ts +0 -81
  35. package/src/services/GraphMutationService.test.ts +0 -486
  36. package/src/services/GraphService.test.ts +0 -494
  37. package/src/services/IntegrationService.test.ts +0 -1091
  38. package/src/services/LifecycleTransaction.test.ts +0 -145
  39. package/src/services/PullRequestService.test.ts +0 -716
  40. package/src/services/PushService.test.ts +0 -408
  41. package/src/services/ReadinessService.test.ts +0 -290
  42. package/src/services/RepositoryService.test.ts +0 -789
  43. package/src/services/ReviewService.test.ts +0 -408
  44. package/src/services/SyncService.test.ts +0 -1377
  45. package/src/services/TaskPhaseService.test.ts +0 -852
  46. package/src/services/VersionControlService.test.ts +0 -62
  47. package/src/services/WorkbaseService.test.ts +0 -910
  48. package/src/services/WorktreeLock.test.ts +0 -205
  49. package/src/services/WorktreePerformance.test.ts +0 -71
  50. package/src/services/WorktreeService.test.ts +0 -2292
  51. package/src/test-utils.ts +0 -110
  52. package/src/usage-log.test.ts +0 -188
  53. package/src/utils/chooser.test.ts +0 -192
  54. package/src/utils/effect.test.ts +0 -30
  55. package/src/utils/interactive.pty.test.ts +0 -128
  56. package/src/utils/interactive.test.tsx +0 -860
  57. package/src/utils/process.test.ts +0 -132
  58. package/src/utils/progress.test.ts +0 -37
  59. package/src/work-view.test.ts +0 -151
  60. package/src/workbase/agent-command.test.ts +0 -128
  61. package/src/workbase/checkout-command.test.ts +0 -62
  62. package/src/workbase/delivery-command.test.ts +0 -180
  63. package/src/workbase/dependency-graph.test.ts +0 -50
  64. package/src/workbase/execution-contract.test.ts +0 -161
  65. package/src/workbase/frontmatter.test.ts +0 -67
  66. package/src/workbase/repository-reference.test.ts +0 -25
  67. package/src/workbase/schemas.test.ts +0 -543
  68. package/src/workbase/work-target.test.ts +0 -108
  69. package/src/workbase/worktree-command.test.ts +0 -61
package/src/cli.test.ts DELETED
@@ -1,1889 +0,0 @@
1
- import {
2
- afterAll,
3
- afterEach,
4
- describe,
5
- expect,
6
- setDefaultTimeout,
7
- test,
8
- } from "bun:test"
9
- import { access, chmod, mkdir, realpath, stat, symlink } from "node:fs/promises"
10
- import { join, sep } from "node:path"
11
- import errorFixture from "../fixtures/protocol/error.json"
12
- import successFixture from "../fixtures/protocol/success.json"
13
- import { cleanupTempDir, createTempDir } from "./test-utils"
14
-
15
- const projectRoot = join(import.meta.dir, "..")
16
- const cliPath = join(projectRoot, "cli.ts")
17
- const isolatedConfigHome = await createTempDir()
18
-
19
- setDefaultTimeout(15_000)
20
-
21
- afterAll(() => cleanupTempDir(isolatedConfigHome))
22
-
23
- interface CliResult {
24
- exitCode: number
25
- stdout: string
26
- stderr: string
27
- }
28
-
29
- async function runCli(
30
- args: string[],
31
- cwd = projectRoot,
32
- env?: Record<string, string>,
33
- ): Promise<CliResult> {
34
- const subprocess = Bun.spawn([process.execPath, cliPath, ...args], {
35
- cwd,
36
- env: {
37
- ...process.env,
38
- XDG_CONFIG_HOME: isolatedConfigHome,
39
- XDG_STATE_HOME: isolatedConfigHome,
40
- AGENCY_NO_USAGE_LOG: "1",
41
- ...env,
42
- },
43
- stdout: "pipe",
44
- stderr: "pipe",
45
- })
46
- const [exitCode, stdout, stderr] = await Promise.all([
47
- subprocess.exited,
48
- new Response(subprocess.stdout).text(),
49
- new Response(subprocess.stderr).text(),
50
- ])
51
- return { exitCode, stdout, stderr }
52
- }
53
-
54
- function parseJson(result: CliResult) {
55
- expect(result.exitCode, JSON.stringify(result)).toBe(0)
56
- expect(result.stderr).toBe("")
57
- const envelope = JSON.parse(result.stdout)
58
- expect(envelope).toMatchObject({ version: 1, ok: true })
59
- return envelope.result
60
- }
61
-
62
- async function runGit(args: string[]) {
63
- const subprocess = Bun.spawn(["git", ...args], {
64
- stdout: "pipe",
65
- stderr: "pipe",
66
- })
67
- const [exitCode, stdout, stderr] = await Promise.all([
68
- subprocess.exited,
69
- new Response(subprocess.stdout).text(),
70
- new Response(subprocess.stderr).text(),
71
- ])
72
- if (exitCode !== 0) throw new Error(stderr)
73
- return stdout
74
- }
75
-
76
- async function startGitDaemon(basePath: string) {
77
- const port = 20000 + Math.floor(Math.random() * 20000)
78
- const process = Bun.spawn(
79
- [
80
- "git",
81
- "daemon",
82
- "--reuseaddr",
83
- "--export-all",
84
- `--base-path=${basePath}`,
85
- "--listen=127.0.0.1",
86
- `--port=${port}`,
87
- basePath,
88
- ],
89
- { stdout: "pipe", stderr: "pipe" },
90
- )
91
- const remote = `git://127.0.0.1:${port}/source.git`
92
- for (let attempt = 0; attempt < 40; attempt++) {
93
- const probe = Bun.spawn(["git", "ls-remote", remote], {
94
- stdout: "ignore",
95
- stderr: "ignore",
96
- })
97
- if ((await probe.exited) === 0) return { process, remote }
98
- await Bun.sleep(25)
99
- }
100
- process.kill()
101
- throw new Error("Git daemon did not start")
102
- }
103
-
104
- describe("CLI", () => {
105
- const tempDirs: string[] = []
106
- const daemons: Bun.Subprocess[] = []
107
-
108
- afterEach(async () => {
109
- await Promise.all(
110
- daemons.splice(0).map(async (daemon) => {
111
- daemon.kill()
112
- await daemon.exited
113
- }),
114
- )
115
- await Promise.all(tempDirs.splice(0).map(cleanupTempDir))
116
- })
117
-
118
- test("reports version and routes top-level help with intentional statuses", async () => {
119
- const version = await runCli(["--version"])
120
- expect(version).toEqual({
121
- exitCode: 0,
122
- stdout: "v0.0.0-development\n",
123
- stderr: "",
124
- })
125
-
126
- const noArgs = await runCli([])
127
- expect(noArgs.exitCode).toBe(1)
128
- expect(noArgs.stdout).toContain("Usage: agency <command> [options]")
129
- expect(noArgs.stderr).toBe("")
130
-
131
- const help = await runCli(["--help"])
132
- expect(help.exitCode).toBe(0)
133
- expect(help.stdout).toContain("Usage: agency <command> [options]")
134
- expect(help.stderr).toBe("")
135
- })
136
-
137
- test("keeps the published protocol fixtures synchronized with CLI output", async () => {
138
- const parent = await createTempDir()
139
- tempDirs.push(parent)
140
- const root = join(parent, "workbase")
141
- const success = await runCli(["init", root, "--json"], parent)
142
- expect(success.exitCode).toBe(0)
143
- expect(success.stderr).toBe("")
144
- expect(success.stdout.endsWith("\n")).toBe(true)
145
- const successEnvelope = JSON.parse(success.stdout)
146
- expect(successEnvelope.result.root).toBe(root)
147
- expect({
148
- ...successEnvelope,
149
- result: { ...successEnvelope.result, root: "/work/agency" },
150
- }).toEqual(successFixture)
151
-
152
- const failure = await runCli(["unknown", "--json"])
153
- expect(failure.exitCode).toBe(1)
154
- expect(failure.stderr).toBe("")
155
- expect(failure.stdout.endsWith("\n")).toBe(true)
156
- expect(JSON.parse(failure.stdout)).toEqual(errorFixture)
157
- })
158
-
159
- test("reports unknown commands and preserves tagged error messages", async () => {
160
- const unknown = await runCli(["unknown"])
161
- expect(unknown.exitCode).toBe(1)
162
- expect(unknown.stdout).toBe("")
163
- expect(unknown.stderr).toContain("Unknown command 'unknown'")
164
- expect(unknown.stderr).toContain("Usage: agency <command> [options]")
165
-
166
- const cwd = await createTempDir()
167
- tempDirs.push(cwd)
168
- const taggedError = await runCli(["repo", "list"], cwd)
169
- expect(taggedError.exitCode).toBe(1)
170
- expect(taggedError.stdout).toBe("")
171
- expect(taggedError.stderr).toContain("ⓘ No Agency workbase found from")
172
- expect(taggedError.stderr).not.toContain("An error has occurred")
173
- })
174
-
175
- test("records successful and failed invocations without argument values", async () => {
176
- const state = await createTempDir()
177
- tempDirs.push(state)
178
- const env = {
179
- XDG_STATE_HOME: state,
180
- AGENCY_SESSION_ID: "cli-session",
181
- AGENCY_NO_USAGE_LOG: "0",
182
- AGENCY_INVOCATION_SOURCE: "automation",
183
- AGENCY_USAGE_TEST: "1",
184
- }
185
- expect((await runCli(["--version"], projectRoot, env)).exitCode).toBe(0)
186
- expect(
187
- (
188
- await runCli(
189
- [
190
- "task",
191
- "create",
192
- "private-customer-id",
193
- "--repo",
194
- "private-repository",
195
- "--description",
196
- "private free-form input",
197
- "--cwd",
198
- "/private/customer/path",
199
- ],
200
- projectRoot,
201
- env,
202
- )
203
- ).exitCode,
204
- ).toBe(1)
205
- expect(
206
- (await runCli(["unknown", "--private-flag=value"], projectRoot, env))
207
- .exitCode,
208
- ).toBe(1)
209
-
210
- const exported = await runCli(["usage", "export"], projectRoot, env)
211
- expect(exported).toMatchObject({ exitCode: 0, stderr: "" })
212
- const events = exported.stdout
213
- .trim()
214
- .split("\n")
215
- .map((line) => JSON.parse(line))
216
- expect(events).toEqual([
217
- expect.objectContaining({
218
- journeyId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
219
- journeySequence: 1,
220
- invocationSource: "automation",
221
- isTest: true,
222
- commandPath: "version",
223
- flagNames: ["version"],
224
- outcome: "success",
225
- outcomeCode: "SUCCESS",
226
- }),
227
- expect.objectContaining({
228
- journeySequence: 2,
229
- commandPath: "task/create",
230
- flagNames: ["cwd", "description", "repo"],
231
- outcome: "failure",
232
- outcomeCode: "WORKBASE_NOT_FOUND",
233
- }),
234
- expect.objectContaining({
235
- journeySequence: 3,
236
- commandPath: "invalid",
237
- flagNames: [],
238
- outcome: "failure",
239
- outcomeCode: "CLI_USAGE",
240
- }),
241
- ])
242
- for (const value of [
243
- "cli-session",
244
- "private-customer-id",
245
- "private-repository",
246
- "private free-form input",
247
- "/private/customer/path",
248
- "private-flag",
249
- "value",
250
- ]) {
251
- expect(exported.stdout).not.toContain(value)
252
- }
253
- })
254
-
255
- test("records status-based non-PR completion", async () => {
256
- const root = await createTempDir()
257
- tempDirs.push(root)
258
- await Bun.write(join(root, "agency.json"), '{"version":2}\n')
259
- await mkdir(join(root, "repos", "agency"), { recursive: true })
260
- parseJson(
261
- await runCli(
262
- ["task", "create", "non-pr", "--repo", "agency", "--json"],
263
- root,
264
- ),
265
- )
266
- parseJson(
267
- await runCli(
268
- [
269
- "task",
270
- "status",
271
- "non-pr",
272
- "done",
273
- "--no-pull-request",
274
- "--summary",
275
- "Investigation completed without changes.",
276
- "--evidence-url",
277
- "https://example.com/result",
278
- "--json",
279
- ],
280
- root,
281
- ),
282
- )
283
- expect(
284
- parseJson(await runCli(["task", "show", "non-pr", "--json"], root)).data,
285
- ).toMatchObject({
286
- status: "done",
287
- completion: {
288
- mode: "non-pr",
289
- summary: "Investigation completed without changes.",
290
- evidenceUrl: "https://example.com/result",
291
- },
292
- })
293
- const prUpdate = await runCli(
294
- [
295
- "task",
296
- "update",
297
- "non-pr",
298
- "--pr-url",
299
- "https://github.com/example/agency/pull/1",
300
- ],
301
- root,
302
- )
303
- expect(prUpdate.exitCode).toBe(1)
304
- expect(prUpdate.stderr).toContain("Reopen non-PR completed work")
305
- })
306
-
307
- test("routes graph mutations through structured output", async () => {
308
- const root = await createTempDir()
309
- tempDirs.push(root)
310
- await Bun.write(join(root, "agency.json"), '{"version":2}\n')
311
- await mkdir(join(root, "repos", "agency"), { recursive: true })
312
- parseJson(
313
- await runCli(
314
- [
315
- "epic",
316
- "create",
317
- "delivery",
318
- "--ticket-url",
319
- "https://example.com/delivery",
320
- "--repo",
321
- "agency:main",
322
- "--json",
323
- ],
324
- root,
325
- ),
326
- )
327
- for (const id of ["first", "second"]) {
328
- parseJson(
329
- await runCli(
330
- [
331
- "task",
332
- "create",
333
- id,
334
- "--repo",
335
- "agency",
336
- "--epic",
337
- "delivery",
338
- "--json",
339
- ],
340
- root,
341
- ),
342
- )
343
- }
344
- const observed = parseJson(
345
- await runCli(["task", "show", "second", "--json"], root),
346
- )
347
- const taskPath = join(root, "tasks/second/TASK.md")
348
- await Bun.write(
349
- taskPath,
350
- `${await Bun.file(taskPath).text()}\nConcurrent edit.\n`,
351
- )
352
- const conflict = await runCli(
353
- [
354
- "task",
355
- "update",
356
- "second",
357
- "--description",
358
- "Stale update",
359
- "--if-revision",
360
- observed.revision,
361
- "--json",
362
- ],
363
- root,
364
- )
365
- expect(conflict.exitCode).toBe(1)
366
- expect(JSON.parse(conflict.stdout)).toMatchObject({
367
- ok: false,
368
- error: {
369
- code: "REVISION_CONFLICT",
370
- retryable: true,
371
- fields: {
372
- path: "tasks/second/TASK.md",
373
- expectedRevision: observed.revision,
374
- currentRevision: expect.stringMatching(/^[a-f0-9]{64}$/),
375
- },
376
- },
377
- })
378
- expect(await Bun.file(taskPath).text()).toContain("Concurrent edit.")
379
-
380
- const updated = parseJson(
381
- await runCli(
382
- ["task", "update", "second", "--description", "Revised", "--json"],
383
- root,
384
- ),
385
- )
386
- expect(updated).toMatchObject({
387
- operation: "task.update",
388
- entity: { kind: "task", id: "second" },
389
- validation: { valid: true, scope: ["tasks/second/TASK.md"] },
390
- })
391
-
392
- const dependency = parseJson(
393
- await runCli(
394
- ["task", "dependency", "add", "second", "first", "--json"],
395
- root,
396
- ),
397
- )
398
- expect(dependency).toMatchObject({
399
- operation: "task.dependency.add",
400
- changedPaths: ["epics/delivery/EPIC.md"],
401
- })
402
- })
403
-
404
- test("emits one versioned error envelope for usage and command failures", async () => {
405
- const usage = await runCli(["unknown", "--json"])
406
- expect(usage.exitCode).toBe(1)
407
- expect(usage.stderr).toBe("")
408
- expect(usage.stdout.trim().split("\n")).toHaveLength(1)
409
- expect(JSON.parse(usage.stdout)).toEqual({
410
- version: 1,
411
- ok: false,
412
- error: {
413
- code: "CLI_USAGE",
414
- message:
415
- "Unknown command 'unknown'.\n\nUsage: agency <command> [options]",
416
- fields: {
417
- detail: "Unknown command 'unknown'.",
418
- usage: "agency <command> [options]",
419
- },
420
- retryable: false,
421
- remediation:
422
- "Correct the arguments using the usage value in error.fields.",
423
- },
424
- })
425
-
426
- const cwd = await createTempDir()
427
- tempDirs.push(cwd)
428
- const commandFailure = await runCli(
429
- ["repo", "list", "--json", "--silent"],
430
- cwd,
431
- )
432
- expect(commandFailure.exitCode).toBe(1)
433
- expect(commandFailure.stderr).toBe("")
434
- expect(JSON.parse(commandFailure.stdout)).toMatchObject({
435
- version: 1,
436
- ok: false,
437
- error: {
438
- code: "WORKBASE_NOT_FOUND",
439
- retryable: false,
440
- },
441
- })
442
- })
443
-
444
- test("preserves the JSON error contract when archiving an already archived task", async () => {
445
- const root = await createTempDir()
446
- tempDirs.push(root)
447
- await Bun.write(join(root, "agency.json"), '{"version":2}\n')
448
- expect(
449
- Bun.spawnSync(["git", "init", "--bare", join(root, "repos/agency")])
450
- .exitCode,
451
- ).toBe(0)
452
- await mkdir(join(root, "tasks/example"), { recursive: true })
453
- await Bun.write(
454
- join(root, "tasks/example/TASK.md"),
455
- `---
456
- ticketUrl: null
457
- repo: agency
458
- branch: task/example
459
- base: main
460
- pr: null
461
- status: dropped
462
- ---
463
-
464
- # Example
465
- `,
466
- )
467
-
468
- expect(
469
- (await runCli(["archive", "."], join(root, "tasks/example"))).exitCode,
470
- ).toBe(0)
471
- const result = await runCli(["archive", "task", "example", "--json"], root)
472
-
473
- expect(result.exitCode).toBe(1)
474
- expect(result.stderr).toBe("")
475
- expect(JSON.parse(result.stdout)).toEqual({
476
- version: 1,
477
- ok: false,
478
- error: {
479
- code: "TASK_ERROR",
480
- message: "Task 'example' is already archived",
481
- fields: {},
482
- retryable: false,
483
- },
484
- })
485
- })
486
-
487
- test("rejects malformed input before running a command", async () => {
488
- const parent = await createTempDir()
489
- tempDirs.push(parent)
490
- const root = join(parent, "workbase")
491
- const result = await runCli(["init", root, "extra"])
492
-
493
- expect(result.exitCode).toBe(1)
494
- expect(result.stderr).toContain("Usage: agency init [path] [--json]")
495
- await expect(access(root)).rejects.toThrow()
496
- })
497
-
498
- test("resolves relative init paths from explicit cwd", async () => {
499
- const parent = await createTempDir()
500
- tempDirs.push(parent)
501
- const result = parseJson(
502
- await runCli(["init", "child", "--cwd", parent, "--json"], projectRoot),
503
- )
504
- expect(result.root).toBe(join(parent, "child"))
505
- })
506
-
507
- test("refuses guided input without a TTY or with --no-input", async () => {
508
- for (const args of [
509
- ["task", "new"],
510
- ["task", "new", "example", "--no-input"],
511
- ]) {
512
- const result = await runCli(args)
513
- expect(result.exitCode).toBe(1)
514
- expect(result.stderr).toContain("task new requires interactive input")
515
- expect(result.stderr).toContain("agency task create")
516
- }
517
- const actResult = await runCli(["act", "--no-input"])
518
- expect(actResult.exitCode).toBe(1)
519
- expect(actResult.stderr).toContain("agency act requires interactive input")
520
- })
521
-
522
- test("routes command help and global options on either side of commands", async () => {
523
- const commands = [
524
- ["act", "Usage: agency act"],
525
- ["init", "Usage: agency init"],
526
- ["workbase", "Usage: agency workbase"],
527
- ["integration", "Usage: agency integration"],
528
- ["repo", "Usage: agency repo"],
529
- ["epic", "Usage: agency epic"],
530
- ["task", "Usage: agency task"],
531
- ["phase", "Usage: agency phase"],
532
- ["archive", "Usage: agency archive"],
533
- ["restore", "Usage: agency restore"],
534
- ["work", "Usage: agency work"],
535
- ["push", "Usage: agency push"],
536
- ["pr", "Work with GitHub pull requests."],
537
- ["status", "Usage: agency status"],
538
- ["validate", "Usage: agency validate"],
539
- ["context", "Usage: agency context"],
540
- ["graph", "Usage: agency graph"],
541
- ["next", "Usage: agency next"],
542
- ] as const
543
- for (let offset = 0; offset < commands.length; offset += 4) {
544
- const results = await Promise.all(
545
- commands.slice(offset, offset + 4).map(async ([command, usage]) => ({
546
- usage,
547
- result: await runCli([command, "--help"]),
548
- })),
549
- )
550
- for (const { result, usage } of results) {
551
- expect(result.exitCode).toBe(0)
552
- expect(result.stdout).toContain(usage)
553
- expect(result.stderr).toBe("")
554
- }
555
- }
556
-
557
- const helpBefore = await runCli(["--help", "task"])
558
- expect(helpBefore.exitCode).toBe(0)
559
- expect(helpBefore.stdout).toContain("Usage: agency task <subcommand>")
560
- expect(helpBefore.stderr).toBe("")
561
-
562
- const root = await createTempDir()
563
- tempDirs.push(root)
564
- const before = await runCli(["--silent", "init", root])
565
- expect(before).toEqual({ exitCode: 0, stdout: "", stderr: "" })
566
-
567
- const after = await runCli(["status", "--silent"], root)
568
- expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
569
- }, 30_000)
570
-
571
- test("passes pr arguments and exit status through to gh", async () => {
572
- const root = await createTempDir()
573
- tempDirs.push(root)
574
- const bin = join(root, "bin")
575
- const capture = join(root, "capture")
576
- await mkdir(bin)
577
- await Bun.write(
578
- join(bin, "gh"),
579
- `#!/bin/sh
580
- printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
581
- exit 23
582
- `,
583
- )
584
- await chmod(join(bin, "gh"), 0o755)
585
- const args = ["pr", "create", "--title", "two words", "--", "literal"]
586
-
587
- const result = await runCli(args, root, {
588
- PATH: `${bin}:${process.env.PATH ?? ""}`,
589
- GH_CAPTURE: capture,
590
- })
591
-
592
- expect(result).toEqual({ exitCode: 23, stdout: "", stderr: "" })
593
- expect((await Bun.file(capture).text()).trim().split("\n")).toEqual([
594
- await realpath(root),
595
- ...args,
596
- ])
597
- })
598
-
599
- test("lists ready work and exposes excluded blockers through one result", async () => {
600
- const root = await createTempDir()
601
- tempDirs.push(root)
602
- await Bun.write(join(root, "agency.json"), '{"version":2}\n')
603
- await mkdir(join(root, "repos/agency"), { recursive: true })
604
- for (const id of ["ready", "finished"]) {
605
- parseJson(
606
- await runCli(
607
- ["task", "create", id, "--repo", "agency", "--json"],
608
- root,
609
- ),
610
- )
611
- }
612
- parseJson(
613
- await runCli(["task", "status", "finished", "dropped", "--json"], root),
614
- )
615
-
616
- const human = await runCli(["next"], root)
617
- expect(human).toMatchObject({ exitCode: 0, stderr: "" })
618
- expect(human.stdout).toContain("1. task/ready")
619
- expect(human.stdout).not.toContain("task/finished")
620
-
621
- const result = parseJson(await runCli(["next", "--select", "--json"], root))
622
- expect(result.selected).toMatchObject({ key: "task/ready", rank: 1 })
623
- expect(result.ready.map((item: any) => item.key)).toEqual(["task/ready"])
624
- expect(result.excluded).toMatchObject([
625
- {
626
- key: "task/finished",
627
- status: "dropped",
628
- terminal: true,
629
- blockers: [{ kind: "status", reason: "Task status is dropped" }],
630
- },
631
- ])
632
-
633
- const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
634
- expect(blockedPr.exitCode).toBe(1)
635
- expect(blockedPr.stderr).toBe("")
636
- expect(JSON.parse(blockedPr.stdout)).toMatchObject({
637
- ok: false,
638
- error: {
639
- code: "EXECUTION_BLOCKED",
640
- fields: {
641
- status: "dropped",
642
- blockers: [{ kind: "status", reason: "Task status is dropped" }],
643
- },
644
- },
645
- })
646
- })
647
-
648
- test("reports and synchronizes managed integration files", async () => {
649
- const root = await createTempDir()
650
- tempDirs.push(root)
651
- expect((await runCli(["init", root])).exitCode).toBe(0)
652
-
653
- const before = parseJson(
654
- await runCli(["integration", "status", "--json"], root),
655
- )
656
- expect(before.files).toMatchObject([
657
- { name: "agents", state: "managed" },
658
- { name: "opencode", state: "managed" },
659
- { name: "opencode-plugin", state: "managed" },
660
- { name: "opencode-tui", state: "managed" },
661
- { name: "opencode-tui-plugin", state: "managed" },
662
- ])
663
-
664
- const synced = parseJson(
665
- await runCli(["integration", "sync", "--json"], root),
666
- )
667
- expect(synced.files).toMatchObject([
668
- { name: "agents", state: "managed", changed: false },
669
- { name: "opencode", state: "managed", changed: false },
670
- { name: "opencode-plugin", state: "managed", changed: false },
671
- { name: "opencode-tui", state: "managed", changed: false },
672
- { name: "opencode-tui-plugin", state: "managed", changed: false },
673
- ])
674
- })
675
-
676
- test("registers and lists workbases", async () => {
677
- const parent = await createTempDir()
678
- tempDirs.push(parent)
679
- const root = join(parent, "workbase")
680
- const env = { XDG_CONFIG_HOME: join(parent, "config") }
681
-
682
- expect(
683
- parseJson(await runCli(["init", root, "--json"], parent, env)),
684
- ).toEqual({
685
- root,
686
- })
687
- const registration = parseJson(
688
- await runCli(
689
- [
690
- "workbase",
691
- "add",
692
- "workbase",
693
- "--cwd",
694
- parent,
695
- "--name",
696
- "primary",
697
- "--json",
698
- ],
699
- projectRoot,
700
- env,
701
- ),
702
- )
703
- expect(registration).toMatchObject({
704
- name: "primary",
705
- path: await realpath(root),
706
- })
707
- expect(
708
- parseJson(await runCli(["workbase", "list", "--json"], parent, env)),
709
- ).toEqual({ workbases: [registration] })
710
-
711
- await mkdir(join(root, "repos", "agency"), { recursive: true })
712
- parseJson(
713
- await runCli(
714
- [
715
- "task",
716
- "create",
717
- "explicit",
718
- "--repo",
719
- "agency",
720
- "--workbase",
721
- "primary",
722
- "--no-input",
723
- "--json",
724
- ],
725
- parent,
726
- env,
727
- ),
728
- )
729
- const shown = parseJson(
730
- await runCli(
731
- [
732
- "task",
733
- "show",
734
- "--task",
735
- "explicit",
736
- "--workbase",
737
- registration.id,
738
- "--no-input",
739
- "--json",
740
- ],
741
- parent,
742
- env,
743
- ),
744
- )
745
- expect(shown.id).toBe("explicit")
746
- const inferred = parseJson(
747
- await runCli(
748
- ["context", "--cwd", join(root, "tasks", "explicit"), "--json"],
749
- projectRoot,
750
- env,
751
- ),
752
- )
753
- expect(inferred.target).toMatchObject({
754
- kind: "task",
755
- taskId: "explicit",
756
- })
757
- const nextByCwd = parseJson(
758
- await runCli(
759
- ["next", "--cwd", root, "--select", "--json"],
760
- projectRoot,
761
- env,
762
- ),
763
- )
764
- expect(nextByCwd.selected.key).toBe("task/explicit")
765
- const nextByRegistration = parseJson(
766
- await runCli(
767
- ["next", "--workbase", "primary", "--select", "--json"],
768
- projectRoot,
769
- env,
770
- ),
771
- )
772
- expect(nextByRegistration.selected.key).toBe("task/explicit")
773
-
774
- parseJson(
775
- await runCli(["workbase", "default", "primary", "--json"], parent, env),
776
- )
777
- expect(
778
- parseJson(await runCli(["task", "list", "--json"], parent, env)),
779
- ).toHaveLength(1)
780
- const explicitOutside = await runCli(
781
- ["task", "list", "--cwd", parent, "--json"],
782
- projectRoot,
783
- env,
784
- )
785
- expect(explicitOutside.exitCode).toBe(1)
786
- expect(explicitOutside.stderr).toBe("")
787
- expect(JSON.parse(explicitOutside.stdout).error.message).toContain(
788
- "No Agency workbase found from",
789
- )
790
- }, 20_000)
791
-
792
- test("exports equivalent JSON and JSONL graph contracts", async () => {
793
- const root = await createTempDir()
794
- tempDirs.push(root)
795
- expect((await runCli(["init", root])).exitCode).toBe(0)
796
- await mkdir(join(root, "repos/agency"), { recursive: true })
797
- await mkdir(join(root, "tasks/example"), { recursive: true })
798
- await Bun.write(
799
- join(root, "tasks/example/TASK.md"),
800
- `---
801
- ticketUrl: null
802
- repo: agency
803
- branch: feat/example
804
- base: main
805
- pr: null
806
- status: open
807
- ---
808
-
809
- # Example
810
- `,
811
- )
812
-
813
- const json = parseJson(
814
- await runCli(
815
- ["graph", "--json", "--include", "bodies", "--kind", "task"],
816
- root,
817
- ),
818
- )
819
- expect(json).toMatchObject({
820
- version: 1,
821
- includes: ["bodies"],
822
- nodes: [
823
- { id: "task:example", body: expect.stringContaining("# Example") },
824
- ],
825
- edges: [],
826
- })
827
-
828
- const streamed = await runCli(
829
- ["graph", "--jsonl", "--include", "bodies", "--kind", "task"],
830
- root,
831
- )
832
- expect(streamed.exitCode).toBe(0)
833
- expect(streamed.stderr).toBe("")
834
- const records = streamed.stdout
835
- .trim()
836
- .split("\n")
837
- .map((line) => JSON.parse(line))
838
- expect(records.map((record) => record.type)).toEqual([
839
- "meta",
840
- "node",
841
- "end",
842
- ])
843
- const reconstructed = {
844
- ...records[0].graph,
845
- nodes: records
846
- .filter((record) => record.type === "node")
847
- .map((record) => record.node),
848
- edges: records
849
- .filter((record) => record.type === "edge")
850
- .map((record) => record.edge),
851
- }
852
- expect(reconstructed).toEqual(json)
853
- })
854
-
855
- test("lets JSON override silent and disables interactive task input", async () => {
856
- const root = await createTempDir()
857
- tempDirs.push(root)
858
- const result = await runCli(["init", root, "--json", "--silent"])
859
- expect(parseJson(result)).toEqual({ root })
860
-
861
- const interactive = await runCli(["task", "new", "--json"])
862
- expect(interactive.exitCode).toBe(1)
863
- expect(interactive.stderr).toBe("")
864
- expect(JSON.parse(interactive.stdout)).toMatchObject({
865
- version: 1,
866
- ok: false,
867
- error: { code: "COMMAND_FAILED", retryable: false },
868
- })
869
- })
870
-
871
- test("materializes a linked repository through the CLI", async () => {
872
- const parent = await createTempDir()
873
- tempDirs.push(parent)
874
- const root = join(parent, "workbase")
875
- const source = join(parent, "source")
876
- await runGit(["init", "--initial-branch=main", source])
877
- await Bun.write(join(source, "README.md"), "materialize\n")
878
- for (const args of [
879
- ["config", "user.email", "test@example.com"],
880
- ["config", "user.name", "Test"],
881
- ["add", "README.md"],
882
- ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
883
- [
884
- "remote",
885
- "add",
886
- "origin",
887
- "https://example.com/agency-tests/source.git",
888
- ],
889
- ]) {
890
- await runGit(["-C", source, ...args])
891
- }
892
-
893
- parseJson(await runCli(["init", root, "--json"], parent))
894
- const configPath = join(root, "agency.json")
895
- await Bun.write(
896
- configPath,
897
- JSON.stringify({ ...(await Bun.file(configPath).json()), vcs: "git" }),
898
- )
899
- parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
900
- const materialized = parseJson(
901
- await runCli(["repo", "materialize", "agency", "--json"], root),
902
- )
903
-
904
- expect(materialized).toMatchObject({
905
- alias: "agency",
906
- kind: "bare",
907
- target: null,
908
- states: ["declared", "materialized"],
909
- })
910
- expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
911
- })
912
-
913
- test("prepares a workspace and reports a non-mutating dry-run", async () => {
914
- const parent = await createTempDir()
915
- tempDirs.push(parent)
916
- const root = join(parent, "workbase")
917
- const source = join(parent, "source")
918
- expect(
919
- Bun.spawnSync(["git", "init", "--initial-branch=main", source]).exitCode,
920
- ).toBe(0)
921
- await Bun.write(join(source, "README.md"), "example\n")
922
- for (const args of [
923
- ["config", "user.email", "test@example.com"],
924
- ["config", "user.name", "Test"],
925
- ["add", "README.md"],
926
- ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
927
- ["remote", "add", "origin", source],
928
- ]) {
929
- expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
930
- }
931
-
932
- parseJson(await runCli(["init", root, "--json"], parent))
933
- await Bun.write(
934
- join(root, "agency.json"),
935
- JSON.stringify({
936
- version: 2,
937
- repositories: {
938
- agency: {
939
- remote: "https://example.com/agency-tests/source.git",
940
- },
941
- },
942
- }),
943
- )
944
- parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
945
- parseJson(
946
- await runCli(
947
- [
948
- "task",
949
- "create",
950
- "example",
951
- "--repo",
952
- "agency",
953
- "--branch",
954
- "feat/example",
955
- "--base",
956
- "main",
957
- "--json",
958
- ],
959
- root,
960
- ),
961
- )
962
-
963
- const planned = parseJson(
964
- await runCli(["work", "prepare", "example", "--dry-run", "--json"], root),
965
- )
966
- const workbaseRoot = await realpath(root)
967
- expect(planned).toMatchObject({
968
- dryRun: true,
969
- taskPath: join(workbaseRoot, "tasks/example/TASK.md"),
970
- phasePath: null,
971
- checkouts: [
972
- {
973
- repo: "agency",
974
- kind: "writable",
975
- action: "created",
976
- resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
977
- },
978
- ],
979
- })
980
- await expect(access(join(root, "tasks/example/code"))).rejects.toThrow()
981
-
982
- const prepared = parseJson(
983
- await runCli(["work", "prepare", "example", "--json"], root),
984
- )
985
- expect(prepared).toMatchObject({
986
- dryRun: false,
987
- checkouts: [{ action: "created", kind: "writable" }],
988
- })
989
- const task = parseJson(
990
- await runCli(["task", "show", "example", "--json"], root),
991
- )
992
- expect(task.data.status).toBe("open")
993
-
994
- const listed = parseJson(await runCli(["worktree", "list", "--json"], root))
995
- expect(listed).toEqual([
996
- expect.objectContaining({
997
- owner: expect.objectContaining({ kind: "task", taskId: "example" }),
998
- checkouts: [
999
- expect.objectContaining({
1000
- repo: "agency",
1001
- kind: "writable",
1002
- registered: true,
1003
- actualBranch: "feat/example",
1004
- actualCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
1005
- dirty: false,
1006
- }),
1007
- ],
1008
- }),
1009
- ])
1010
- const preparedAgain = parseJson(
1011
- await runCli(
1012
- ["work", "prepare", join(root, "tasks/example/TASK.md"), "--json"],
1013
- parent,
1014
- ),
1015
- )
1016
- expect(preparedAgain.checkouts).toEqual([
1017
- expect.objectContaining({ action: "reused", kind: "writable" }),
1018
- ])
1019
- const rebuild = parseJson(
1020
- await runCli(
1021
- ["worktree", "rebuild", "example", "--dry-run", "--json"],
1022
- root,
1023
- ),
1024
- )
1025
- expect(rebuild).toMatchObject({
1026
- operation: "rebuild",
1027
- dryRun: true,
1028
- actions: [
1029
- `remove ${join(workbaseRoot, "tasks/example/code/agency")}`,
1030
- `create ${join(workbaseRoot, "tasks/example/code/agency")}`,
1031
- ],
1032
- })
1033
- expect(
1034
- await Bun.file(join(root, "tasks/example/code/agency/README.md")).text(),
1035
- ).toBe("example\n")
1036
- })
1037
-
1038
- test.skipIf(
1039
- Bun.which("opencode") === null || process.env.AGENCY_TEST_OPENCODE !== "1",
1040
- )(
1041
- "provides effective whole-workbase OpenCode access from every launch topology",
1042
- async () => {
1043
- const parent = await createTempDir()
1044
- tempDirs.push(parent)
1045
- const root = join(parent, "workbase")
1046
- const source = join(parent, "source")
1047
- expect(
1048
- Bun.spawnSync(["git", "init", "--initial-branch=main", source])
1049
- .exitCode,
1050
- ).toBe(0)
1051
- await Bun.write(join(source, "README.md"), "example\n")
1052
- await mkdir(join(source, ".claude/skills/repository-skill"), {
1053
- recursive: true,
1054
- })
1055
- await Bun.write(
1056
- join(source, ".claude/skills/repository-skill/SKILL.md"),
1057
- "---\nname: repository-skill\ndescription: Repository discovery test.\n---\n\nRepository skill content.\n",
1058
- )
1059
- for (const args of [
1060
- ["config", "user.email", "test@example.com"],
1061
- ["config", "user.name", "Test"],
1062
- ["add", "."],
1063
- ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1064
- ]) {
1065
- expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
1066
- }
1067
- await runGit(["clone", "--bare", source, join(parent, "source.git")])
1068
- const daemon = await startGitDaemon(parent)
1069
- daemons.push(daemon.process)
1070
- await runGit(["-C", source, "remote", "add", "origin", daemon.remote])
1071
-
1072
- parseJson(await runCli(["init", root, "--json"], parent))
1073
- expect(
1074
- Bun.spawnSync(["git", "init", "--initial-branch=main", root]).exitCode,
1075
- ).toBe(0)
1076
- const agencyConfigPath = join(root, "agency.json")
1077
- const agencyConfig = JSON.parse(await Bun.file(agencyConfigPath).text())
1078
- await Bun.write(
1079
- agencyConfigPath,
1080
- `${JSON.stringify(
1081
- {
1082
- ...agencyConfig,
1083
- agents: { noop: { command: ["true"] } },
1084
- },
1085
- null,
1086
- 2,
1087
- )}\n`,
1088
- )
1089
- parseJson(
1090
- await runCli(["repo", "link", "agency", source, "--json"], root),
1091
- )
1092
- parseJson(
1093
- await runCli(
1094
- [
1095
- "epic",
1096
- "create",
1097
- "delivery",
1098
- "--ticket-url",
1099
- "https://example.com/delivery",
1100
- "--repo",
1101
- "agency:main",
1102
- "--json",
1103
- ],
1104
- root,
1105
- ),
1106
- )
1107
- for (const [id, branch] of [
1108
- ["example", "feat/example"],
1109
- ["sibling", "feat/sibling"],
1110
- ] as const) {
1111
- parseJson(
1112
- await runCli(
1113
- [
1114
- "task",
1115
- "create",
1116
- id,
1117
- "--repo",
1118
- "agency",
1119
- "--branch",
1120
- branch,
1121
- "--base",
1122
- "main",
1123
- "--epic",
1124
- "delivery",
1125
- "--json",
1126
- ],
1127
- root,
1128
- ),
1129
- )
1130
- }
1131
- parseJson(
1132
- await runCli(
1133
- ["task", "create", "pipeline", "--multi-phase", "--json"],
1134
- root,
1135
- ),
1136
- )
1137
- parseJson(
1138
- await runCli(
1139
- [
1140
- "phase",
1141
- "create",
1142
- "pipeline",
1143
- "build",
1144
- "--repo",
1145
- "agency",
1146
- "--branch",
1147
- "feat/pipeline-build",
1148
- "--base",
1149
- "main",
1150
- "--json",
1151
- ],
1152
- root,
1153
- ),
1154
- )
1155
-
1156
- parseJson(await runCli(["work", "prepare", "example", "--json"], root))
1157
- parseJson(
1158
- await runCli(
1159
- [
1160
- "work",
1161
- "prepare",
1162
- "--task",
1163
- "pipeline",
1164
- "--phase",
1165
- "build",
1166
- "--json",
1167
- ],
1168
- root,
1169
- ),
1170
- )
1171
- const synced = parseJson(
1172
- await runCli(["integration", "sync", "--json"], root),
1173
- )
1174
- expect(
1175
- synced.files.every((file: { changed: boolean }) => !file.changed),
1176
- ).toBe(true)
1177
-
1178
- const workbaseRoot = await realpath(root)
1179
- const config = await Bun.file(
1180
- join(workbaseRoot, ".opencode/opencode.jsonc"),
1181
- ).text()
1182
- expect(config).not.toContain(workbaseRoot)
1183
- const documents = [
1184
- join(workbaseRoot, "tasks/example/TASK.md"),
1185
- join(workbaseRoot, "epics/delivery/EPIC.md"),
1186
- join(workbaseRoot, "tasks/sibling/TASK.md"),
1187
- ]
1188
- for (const document of documents) {
1189
- expect(await Bun.file(document).exists()).toBe(true)
1190
- }
1191
-
1192
- const launches = [
1193
- {
1194
- args: ["--task", "example"],
1195
- cwd: join(workbaseRoot, "tasks/example"),
1196
- checkoutPath: join(workbaseRoot, "tasks/example/code/agency"),
1197
- skillPath: join(
1198
- workbaseRoot,
1199
- "tasks/example/code/agency/.claude/skills",
1200
- ),
1201
- },
1202
- {
1203
- args: ["--task", "pipeline", "--phase", "build"],
1204
- cwd: join(workbaseRoot, "tasks/pipeline/phases/build"),
1205
- checkoutPath: join(
1206
- workbaseRoot,
1207
- "tasks/pipeline/phases/build/code/agency",
1208
- ),
1209
- skillPath: join(
1210
- workbaseRoot,
1211
- "tasks/pipeline/phases/build/code/agency/.claude/skills",
1212
- ),
1213
- },
1214
- {
1215
- args: ["--epic", "delivery"],
1216
- cwd: join(workbaseRoot, "epics/delivery"),
1217
- checkoutPath: undefined,
1218
- skillPath: undefined,
1219
- },
1220
- {
1221
- args: ["--task", "pipeline"],
1222
- cwd: join(workbaseRoot, "tasks/pipeline"),
1223
- checkoutPath: undefined,
1224
- skillPath: undefined,
1225
- },
1226
- ]
1227
- for (const launch of launches) {
1228
- const printed = await runCli(
1229
- ["work", ...launch.args, "--opencode", "--print-command", "--force"],
1230
- root,
1231
- )
1232
- expect(printed.exitCode).toBe(0)
1233
- expect(printed.stderr).toBe("")
1234
- const contract = JSON.parse(printed.stdout)
1235
- expect(contract.cwd).toBe(launch.cwd)
1236
- expect(contract.argv).toEqual(["opencode"])
1237
- expect(contract.environment.OPENCODE_CONFIG).toBeUndefined()
1238
- expect(contract.environment.AGENCY_WRITABLE_CHECKOUT).toBe(
1239
- launch.checkoutPath,
1240
- )
1241
- expect(contract.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
1242
- if (launch === launches[1]) {
1243
- const context = parseJson(
1244
- await runCli(["context", ".", "--json"], contract.cwd),
1245
- )
1246
- expect(context.target).toMatchObject({
1247
- kind: "phase",
1248
- taskId: "pipeline",
1249
- phaseId: "build",
1250
- })
1251
- }
1252
- const environment = {
1253
- ...process.env,
1254
- ...contract.environment,
1255
- XDG_CONFIG_HOME: isolatedConfigHome,
1256
- OPENCODE_DISABLE_AUTOUPDATE: "1",
1257
- OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1258
- OPENCODE_DISABLE_MODELS_FETCH: "1",
1259
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
1260
- model: "opencode/big-pickle",
1261
- }),
1262
- }
1263
- const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1264
- cwd: contract.cwd,
1265
- env: environment,
1266
- })
1267
- expect(probe.exitCode, probe.stderr.toString()).toBe(0)
1268
- const agent = JSON.parse(probe.stdout.toString())
1269
- expect(agent.permission).toEqual(
1270
- expect.arrayContaining([
1271
- expect.objectContaining({
1272
- permission: "external_directory",
1273
- pattern: join(workbaseRoot, "*"),
1274
- action: "allow",
1275
- }),
1276
- ]),
1277
- )
1278
- const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1279
- cwd: contract.cwd,
1280
- env: environment,
1281
- })
1282
- expect(configProbe.exitCode).toBe(0)
1283
- const effectiveConfig = JSON.parse(configProbe.stdout.toString())
1284
- expect(effectiveConfig.permission.external_directory).toMatchObject({
1285
- [join(workbaseRoot, "*")]: "allow",
1286
- })
1287
- expect(effectiveConfig.plugin_origins).toEqual(
1288
- expect.arrayContaining([
1289
- expect.objectContaining({
1290
- spec: expect.stringContaining("agency-repository-skills.ts"),
1291
- }),
1292
- ]),
1293
- )
1294
- if (launch.skillPath) {
1295
- expect(effectiveConfig.skills.paths).toContain(
1296
- `${launch.skillPath}${sep}.`,
1297
- )
1298
- const skillProbe = Bun.spawnSync(
1299
- [
1300
- "opencode",
1301
- "debug",
1302
- "agent",
1303
- "build",
1304
- "--tool",
1305
- "skill",
1306
- "--params",
1307
- JSON.stringify({ name: "repository-skill" }),
1308
- ],
1309
- { cwd: contract.cwd, env: environment },
1310
- )
1311
- expect(skillProbe.exitCode).toBe(0)
1312
- expect(
1313
- JSON.parse(skillProbe.stdout.toString()).result.output,
1314
- ).toContain("Repository skill content.")
1315
- } else {
1316
- expect(
1317
- (effectiveConfig.skills?.paths ?? []).filter((path: string) =>
1318
- path.endsWith(`${sep}.`),
1319
- ),
1320
- ).toEqual([])
1321
- }
1322
- if (launch === launches[0]) {
1323
- expect(effectiveConfig.instructions).toContain(".agency/AGENTS.md")
1324
- expect(effectiveConfig.agent.agency).toBeUndefined()
1325
- expect(effectiveConfig.references).toEqual({
1326
- workbase: {
1327
- path: "..",
1328
- description:
1329
- "Complete Agency workbase context; write authority still comes only from agency context",
1330
- },
1331
- })
1332
- for (const document of documents) {
1333
- const read = Bun.spawnSync(
1334
- [
1335
- "opencode",
1336
- "debug",
1337
- "agent",
1338
- "build",
1339
- "--tool",
1340
- "read",
1341
- "--params",
1342
- JSON.stringify({ filePath: document }),
1343
- ],
1344
- { cwd: contract.cwd, env: environment },
1345
- )
1346
- expect(read.exitCode).toBe(0)
1347
- const result = JSON.parse(read.stdout.toString())
1348
- expect(result.result.output).toContain(`<path>${document}</path>`)
1349
- }
1350
- }
1351
- }
1352
-
1353
- const agencyBin = join(parent, "bin")
1354
- await mkdir(agencyBin)
1355
- await symlink(cliPath, join(agencyBin, "agency"))
1356
- const directEnvironment: Record<string, string | undefined> = {
1357
- ...process.env,
1358
- PATH: `${agencyBin}:${process.env.PATH ?? ""}`,
1359
- XDG_CONFIG_HOME: isolatedConfigHome,
1360
- OPENCODE_DISABLE_AUTOUPDATE: "1",
1361
- OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1362
- OPENCODE_DISABLE_MODELS_FETCH: "1",
1363
- }
1364
- delete directEnvironment.OPENCODE_CONFIG
1365
- delete directEnvironment.OPENCODE_CONFIG_CONTENT
1366
- delete directEnvironment.AGENCY_WORKBASE
1367
- delete directEnvironment.AGENCY_TASK_ID
1368
- delete directEnvironment.AGENCY_PHASE_ID
1369
- delete directEnvironment.AGENCY_WRITABLE_CHECKOUT
1370
- const directDirectories = [
1371
- join(workbaseRoot, "tasks/example"),
1372
- join(workbaseRoot, "tasks/pipeline"),
1373
- join(workbaseRoot, "tasks/pipeline/phases/build"),
1374
- join(workbaseRoot, "epics/delivery"),
1375
- ]
1376
- for (const cwd of directDirectories) {
1377
- const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1378
- cwd,
1379
- env: directEnvironment,
1380
- })
1381
- expect(probe.exitCode).toBe(0)
1382
- const agent = JSON.parse(probe.stdout.toString())
1383
- expect(agent.permission).toEqual(
1384
- expect.arrayContaining([
1385
- expect.objectContaining({
1386
- permission: "external_directory",
1387
- pattern: join(workbaseRoot, "*"),
1388
- action: "allow",
1389
- }),
1390
- ]),
1391
- )
1392
- const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1393
- cwd,
1394
- env: directEnvironment,
1395
- })
1396
- expect(configProbe.exitCode).toBe(0)
1397
- const effectiveConfig = JSON.parse(configProbe.stdout.toString())
1398
- expect(effectiveConfig.permission.external_directory).toMatchObject({
1399
- [join(workbaseRoot, "*")]: "allow",
1400
- })
1401
- expect(effectiveConfig.plugin_origins).toEqual(
1402
- expect.arrayContaining([
1403
- expect.objectContaining({
1404
- spec: expect.stringContaining("agency-repository-skills.ts"),
1405
- }),
1406
- ]),
1407
- )
1408
- for (const document of documents) {
1409
- const read = Bun.spawnSync(
1410
- [
1411
- "opencode",
1412
- "debug",
1413
- "agent",
1414
- "build",
1415
- "--tool",
1416
- "read",
1417
- "--params",
1418
- JSON.stringify({ filePath: document }),
1419
- ],
1420
- { cwd, env: directEnvironment },
1421
- )
1422
- expect(read.exitCode).toBe(0)
1423
- expect(JSON.parse(read.stdout.toString()).result.output).toContain(
1424
- `<path>${document}</path>`,
1425
- )
1426
- }
1427
- }
1428
- const denied = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1429
- cwd: directDirectories[0],
1430
- env: {
1431
- ...directEnvironment,
1432
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
1433
- model: "opencode/big-pickle",
1434
- permission: { external_directory: { "*": "deny" } },
1435
- }),
1436
- },
1437
- })
1438
- expect(denied.exitCode).toBe(0)
1439
- const deniedRules = JSON.parse(denied.stdout.toString()).permission
1440
- const managedRule = deniedRules.findIndex(
1441
- (rule: any) =>
1442
- rule.permission === "external_directory" &&
1443
- rule.pattern === join(workbaseRoot, "*") &&
1444
- rule.action === "allow",
1445
- )
1446
- const userRule = deniedRules.findLastIndex(
1447
- (rule: any) =>
1448
- rule.permission === "external_directory" &&
1449
- rule.pattern === "*" &&
1450
- rule.action === "deny",
1451
- )
1452
- expect(managedRule).toBeGreaterThanOrEqual(0)
1453
- expect(userRule).toBeGreaterThan(managedRule)
1454
- for (const cwd of [
1455
- join(workbaseRoot, "tasks/example"),
1456
- join(workbaseRoot, "tasks/pipeline/phases/build"),
1457
- ]) {
1458
- const manualSkill = Bun.spawnSync(
1459
- [
1460
- "opencode",
1461
- "debug",
1462
- "agent",
1463
- "build",
1464
- "--tool",
1465
- "skill",
1466
- "--params",
1467
- JSON.stringify({ name: "repository-skill" }),
1468
- ],
1469
- { cwd, env: directEnvironment },
1470
- )
1471
- expect(manualSkill.exitCode, manualSkill.stderr.toString()).toBe(0)
1472
- expect(
1473
- JSON.parse(manualSkill.stdout.toString()).result.output,
1474
- ).toContain("Repository skill content.")
1475
- }
1476
-
1477
- parseJson(
1478
- await runCli(["task", "status", "example", "dropped", "--json"], root),
1479
- )
1480
- const blocked = await runCli(
1481
- ["work", "--task", "example", "--agent", "noop"],
1482
- root,
1483
- )
1484
- expect(blocked.exitCode).toBe(1)
1485
- expect(blocked.stderr).toContain("Task status is dropped")
1486
- expect(
1487
- parseJson(await runCli(["task", "show", "example", "--json"], root))
1488
- .data.status,
1489
- ).toBe("dropped")
1490
-
1491
- const resumedTask = await runCli(
1492
- ["work", "--task", "example", "--agent", "noop", "--force"],
1493
- root,
1494
- )
1495
- expect(resumedTask).toMatchObject({ exitCode: 0, stderr: "" })
1496
- expect(resumedTask.stdout).toContain(
1497
- "Reopened task/example from dropped as working",
1498
- )
1499
- expect(
1500
- parseJson(await runCli(["task", "show", "example", "--json"], root))
1501
- .data.status,
1502
- ).toBe("working")
1503
-
1504
- parseJson(
1505
- await runCli(
1506
- ["phase", "status", "pipeline", "build", "dropped", "--json"],
1507
- root,
1508
- ),
1509
- )
1510
- const resumedPhase = await runCli(
1511
- [
1512
- "work",
1513
- "--task",
1514
- "pipeline",
1515
- "--phase",
1516
- "build",
1517
- "--agent",
1518
- "noop",
1519
- "--force",
1520
- ],
1521
- root,
1522
- )
1523
- expect(resumedPhase).toMatchObject({ exitCode: 0, stderr: "" })
1524
- expect(resumedPhase.stdout).toContain(
1525
- "Reopened phase/pipeline/build from dropped as working",
1526
- )
1527
- const graph = parseJson(await runCli(["graph", "--json"], root))
1528
- expect(
1529
- graph.nodes.find((node: any) => node.id === "task:pipeline").status,
1530
- ).toBe("working")
1531
- },
1532
- 360_000,
1533
- )
1534
-
1535
- test("envelopes help and version output in machine mode", async () => {
1536
- const help = await runCli(["status", "--help", "--json"])
1537
- expect(parseJson(help)).toContain("Usage: agency status")
1538
-
1539
- const version = await runCli(["status", "--version", "--json"])
1540
- expect(parseJson(version)).toEqual({ version: "0.0.0-development" })
1541
-
1542
- const jsonlHelp = await runCli(["graph", "--help", "--jsonl"])
1543
- expect(parseJson(jsonlHelp)).toContain("Usage: agency graph")
1544
-
1545
- const invalidJsonl = await runCli([
1546
- "graph",
1547
- "--jsonl",
1548
- "--include",
1549
- "secrets",
1550
- ])
1551
- expect(invalidJsonl.exitCode).toBe(1)
1552
- expect(invalidJsonl.stderr).toBe("")
1553
- expect(invalidJsonl.stdout.trim().split("\n")).toHaveLength(1)
1554
- expect(JSON.parse(invalidJsonl.stdout)).toMatchObject({
1555
- version: 1,
1556
- ok: false,
1557
- error: { code: "CLI_USAGE" },
1558
- })
1559
- })
1560
-
1561
- test("runs a multi-phase domain workflow through subprocesses", async () => {
1562
- const parent = await createTempDir()
1563
- tempDirs.push(parent)
1564
- const root = join(parent, "workbase")
1565
- const source = join(parent, "source")
1566
-
1567
- const git = Bun.spawn(["git", "init", "--initial-branch=main", source], {
1568
- stdout: "ignore",
1569
- stderr: "pipe",
1570
- })
1571
- expect(await git.exited).toBe(0)
1572
- await runGit([
1573
- "-C",
1574
- source,
1575
- "remote",
1576
- "add",
1577
- "origin",
1578
- "https://example.com/agency-tests/source.git",
1579
- ])
1580
-
1581
- expect(parseJson(await runCli(["init", root, "--json"]))).toEqual({
1582
- root,
1583
- })
1584
- const workbaseRoot = await realpath(root)
1585
- expect(
1586
- parseJson(
1587
- await runCli(["repo", "link", "agency", source, "--json"], root),
1588
- ),
1589
- ).toEqual({
1590
- alias: "agency",
1591
- path: join(workbaseRoot, "repos/agency"),
1592
- })
1593
- for (const alias of ["effect", "tooling"]) {
1594
- parseJson(await runCli(["repo", "link", alias, source, "--json"], root))
1595
- }
1596
-
1597
- const epic = parseJson(
1598
- await runCli(
1599
- [
1600
- "epic",
1601
- "create",
1602
- "delivery",
1603
- "--ticket-url",
1604
- "https://example.com/epics/delivery",
1605
- "--repo",
1606
- "agency:main",
1607
- "--json",
1608
- ],
1609
- root,
1610
- ),
1611
- )
1612
- expect(epic).toMatchObject({
1613
- id: "delivery",
1614
- data: { repos: [{ repo: "agency", ref: "main" }], tasks: [] },
1615
- })
1616
-
1617
- const task = parseJson(
1618
- await runCli(
1619
- [
1620
- "task",
1621
- "create",
1622
- "ship",
1623
- "--ticket-url",
1624
- "https://example.com/tasks/ship",
1625
- "--epic",
1626
- "delivery",
1627
- "--multi-phase",
1628
- "--json",
1629
- ],
1630
- root,
1631
- ),
1632
- )
1633
- expect(task).toMatchObject({
1634
- id: "ship",
1635
- data: { epic: "delivery", phases: [] },
1636
- })
1637
-
1638
- for (const [id, branch] of [
1639
- ["prepare", "task/ship-prepare"],
1640
- ["implement", "task/ship-implement"],
1641
- ] as const) {
1642
- const phase = parseJson(
1643
- await runCli(
1644
- [
1645
- "phase",
1646
- "create",
1647
- "ship",
1648
- id,
1649
- "--repo",
1650
- "agency",
1651
- "--branch",
1652
- branch,
1653
- "--base",
1654
- "main",
1655
- "--json",
1656
- ],
1657
- root,
1658
- ),
1659
- )
1660
- expect(phase).toMatchObject({ taskId: "ship", id })
1661
- }
1662
-
1663
- const release = parseJson(
1664
- await runCli(
1665
- [
1666
- "phase",
1667
- "create",
1668
- "ship",
1669
- "release",
1670
- "--repo",
1671
- "agency",
1672
- "--branch",
1673
- "task/ship-release",
1674
- "--base",
1675
- "main",
1676
- "--depends-on",
1677
- "prepare",
1678
- "--depends-on",
1679
- "implement",
1680
- "--reference",
1681
- "effect:main",
1682
- "--reference",
1683
- "tooling:main",
1684
- "--json",
1685
- ],
1686
- root,
1687
- ),
1688
- )
1689
- expect(release.data).toMatchObject({
1690
- branch: "task/ship-release",
1691
- base: "main",
1692
- repos: [
1693
- { repo: "effect", ref: "main" },
1694
- { repo: "tooling", ref: "main" },
1695
- ],
1696
- })
1697
-
1698
- const epicList = parseJson(await runCli(["epic", "list", "--json"], root))
1699
- expect(epicList).toHaveLength(1)
1700
- expect(epicList[0].data.tasks).toEqual([{ id: "ship" }])
1701
-
1702
- const taskShow = parseJson(
1703
- await runCli(["task", "show", "ship", "--json"], root),
1704
- )
1705
- expect(taskShow.data.phases).toEqual([
1706
- { id: "prepare" },
1707
- { id: "implement" },
1708
- { id: "release", dependsOn: ["prepare", "implement"] },
1709
- ])
1710
- const plainTaskShow = await runCli(["task", "show", "ship"], root)
1711
- expect(plainTaskShow.exitCode).toBe(0)
1712
- expect(plainTaskShow.stdout).toContain(
1713
- "ticketUrl: https://example.com/tasks/ship",
1714
- )
1715
- expect(plainTaskShow.stderr).toBe("")
1716
-
1717
- const phaseList = parseJson(
1718
- await runCli(["phase", "list", "ship", "--json"], root),
1719
- )
1720
- expect(phaseList.map((phase: { id: string }) => phase.id)).toEqual([
1721
- "prepare",
1722
- "implement",
1723
- "release",
1724
- ])
1725
- const phaseTable = await runCli(
1726
- ["phase", "list", "ship", "--repository", "agency", "--no-pr"],
1727
- root,
1728
- )
1729
- expect(phaseTable.stdout).toMatch(
1730
- /PHASE\s+PARENT\s+STATUS\s+READINESS\s+REPOSITORIES\s+BRANCH/,
1731
- )
1732
- expect(phaseTable.stdout.indexOf("prepare")).toBeLessThan(
1733
- phaseTable.stdout.indexOf("implement"),
1734
- )
1735
-
1736
- const phaseShow = parseJson(
1737
- await runCli(["phase", "show", "ship", "release", "--json"], root),
1738
- )
1739
- expect(phaseShow).toMatchObject({ taskId: "ship", id: "release" })
1740
-
1741
- const status = parseJson(await runCli(["status", "--json"], root))
1742
- expect(status).toMatchObject({
1743
- root: workbaseRoot,
1744
- epicCount: 1,
1745
- taskCount: 1,
1746
- phaseCount: 3,
1747
- valid: true,
1748
- issues: [],
1749
- })
1750
- const doctor = parseJson(await runCli(["doctor", "--json"], root))
1751
- expect(doctor).toMatchObject({
1752
- version: 1,
1753
- root: workbaseRoot,
1754
- checks: expect.arrayContaining([
1755
- expect.objectContaining({ id: "tool.git", status: "pass" }),
1756
- expect.objectContaining({ id: "workbase.validation", status: "pass" }),
1757
- ]),
1758
- })
1759
-
1760
- const validation = parseJson(
1761
- await runCli(["validate", root, "--json"], parent),
1762
- )
1763
- expect(validation).toEqual({
1764
- root,
1765
- issues: [],
1766
- epicCount: 1,
1767
- taskCount: 1,
1768
- phaseCount: 3,
1769
- valid: true,
1770
- })
1771
- }, 30_000)
1772
-
1773
- test("restores portable repositories in a fresh workbase clone", async () => {
1774
- const parent = await createTempDir()
1775
- tempDirs.push(parent)
1776
- const sourceWorktree = join(parent, "source-worktree")
1777
- const source = join(parent, "source.git")
1778
- const root = join(parent, "workbase")
1779
- const restored = join(parent, "restored")
1780
-
1781
- await runGit(["init", "--initial-branch=main", sourceWorktree])
1782
- await Bun.write(join(sourceWorktree, "README.md"), "portable\n")
1783
- await runGit([
1784
- "-C",
1785
- sourceWorktree,
1786
- "config",
1787
- "user.email",
1788
- "test@example.com",
1789
- ])
1790
- await runGit(["-C", sourceWorktree, "config", "user.name", "Test"])
1791
- await runGit(["-C", sourceWorktree, "add", "README.md"])
1792
- await runGit([
1793
- "-C",
1794
- sourceWorktree,
1795
- "-c",
1796
- "commit.gpgsign=false",
1797
- "commit",
1798
- "-m",
1799
- "initial",
1800
- ])
1801
- await runGit(["clone", "--bare", sourceWorktree, source])
1802
- const daemon = await startGitDaemon(parent)
1803
-
1804
- try {
1805
- parseJson(await runCli(["init", root, "--json"], parent))
1806
- parseJson(
1807
- await runCli(["repo", "add", "agency", daemon.remote, "--json"], root),
1808
- )
1809
- parseJson(
1810
- await runCli(
1811
- [
1812
- "task",
1813
- "create",
1814
- "portable",
1815
- "--repo",
1816
- "agency",
1817
- "--branch",
1818
- "feat/portable",
1819
- "--base",
1820
- "main",
1821
- "--json",
1822
- ],
1823
- root,
1824
- ),
1825
- )
1826
-
1827
- await runGit(["init", "--initial-branch=main", root])
1828
- await runGit(["-C", root, "config", "user.email", "test@example.com"])
1829
- await runGit(["-C", root, "config", "user.name", "Test"])
1830
- await runGit(["-C", root, "add", "."])
1831
- await runGit([
1832
- "-C",
1833
- root,
1834
- "-c",
1835
- "commit.gpgsign=false",
1836
- "commit",
1837
- "-m",
1838
- "portable workbase",
1839
- ])
1840
- const tracked = await runGit(["-C", root, "ls-files"])
1841
- expect(tracked).toContain("agency.json")
1842
- expect(tracked).not.toContain("repos/agency")
1843
-
1844
- await runGit(["clone", root, restored])
1845
- const planned = parseJson(
1846
- await runCli(["repo", "setup", "--dry-run", "--json"], restored),
1847
- )
1848
- expect(planned.actions).toEqual([
1849
- expect.objectContaining({
1850
- alias: "agency",
1851
- kind: "materialize",
1852
- status: "planned",
1853
- }),
1854
- ])
1855
- expect(await Bun.file(join(restored, "repos/agency/HEAD")).exists()).toBe(
1856
- false,
1857
- )
1858
-
1859
- const applied = parseJson(
1860
- await runCli(["repo", "setup", "--apply", "--json"], restored),
1861
- )
1862
- expect(applied.actions[0]).toMatchObject({
1863
- alias: "agency",
1864
- status: "applied",
1865
- })
1866
- expect((await stat(join(restored, "repos/agency/HEAD"))).isFile()).toBe(
1867
- true,
1868
- )
1869
-
1870
- const prepared = parseJson(
1871
- await runCli(["work", "prepare", "portable", "--json"], restored),
1872
- )
1873
- expect(prepared.checkouts).toEqual([
1874
- expect.objectContaining({
1875
- repo: "agency",
1876
- action: "created",
1877
- }),
1878
- ])
1879
- expect(
1880
- await Bun.file(
1881
- join(restored, "tasks/portable/code/agency/README.md"),
1882
- ).text(),
1883
- ).toBe("portable\n")
1884
- } finally {
1885
- daemon.process.kill()
1886
- await daemon.process.exited
1887
- }
1888
- }, 30_000)
1889
- })