@markjaquith/agency 3.2.14 → 3.3.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 (75) hide show
  1. package/README.md +20 -3
  2. package/cli-main.ts +1 -0
  3. package/package.json +7 -2
  4. package/src/cli-parser.ts +13 -0
  5. package/src/commands/work.ts +70 -2
  6. package/src/services/ReadinessService.ts +20 -0
  7. package/src/workbase/execution-contract.ts +10 -1
  8. package/src/workbase/schemas.ts +12 -0
  9. package/fixtures/protocol/orchestration-recipes.json +0 -53
  10. package/pi-extensions/agency.test.ts +0 -117
  11. package/src/check-commits.test.ts +0 -58
  12. package/src/cli-parser.test.ts +0 -945
  13. package/src/cli.test.ts +0 -1889
  14. package/src/commands/act.test.ts +0 -418
  15. package/src/commands/archive.test.ts +0 -236
  16. package/src/commands/context.test.ts +0 -711
  17. package/src/commands/description.test.ts +0 -103
  18. package/src/commands/doctor.test.ts +0 -185
  19. package/src/commands/epic.test.ts +0 -196
  20. package/src/commands/init.test.ts +0 -117
  21. package/src/commands/integration.test.ts +0 -165
  22. package/src/commands/pr.test.ts +0 -248
  23. package/src/commands/push.test.ts +0 -56
  24. package/src/commands/read-only.test.ts +0 -161
  25. package/src/commands/repo.test.ts +0 -199
  26. package/src/commands/restore.test.ts +0 -106
  27. package/src/commands/status.test.ts +0 -113
  28. package/src/commands/sync.test.ts +0 -200
  29. package/src/commands/task-phase.test.ts +0 -410
  30. package/src/commands/task.test.ts +0 -281
  31. package/src/commands/validate.test.ts +0 -186
  32. package/src/commands/work.test.ts +0 -1375
  33. package/src/commands/workbase.test.ts +0 -170
  34. package/src/graph-schema.test.ts +0 -124
  35. package/src/protocol.test.ts +0 -163
  36. package/src/readiness.test.ts +0 -105
  37. package/src/services/ArchiveBulkService.test.ts +0 -384
  38. package/src/services/ArchiveService.test.ts +0 -1092
  39. package/src/services/EpicService.test.ts +0 -74
  40. package/src/services/FileSystemService.test.ts +0 -81
  41. package/src/services/GraphMutationService.test.ts +0 -486
  42. package/src/services/GraphService.test.ts +0 -494
  43. package/src/services/IntegrationService.test.ts +0 -1091
  44. package/src/services/LifecycleTransaction.test.ts +0 -145
  45. package/src/services/PullRequestService.test.ts +0 -716
  46. package/src/services/PushService.test.ts +0 -408
  47. package/src/services/ReadinessService.test.ts +0 -290
  48. package/src/services/RepositoryService.test.ts +0 -789
  49. package/src/services/ReviewService.test.ts +0 -408
  50. package/src/services/SyncService.test.ts +0 -1377
  51. package/src/services/TaskPhaseService.test.ts +0 -852
  52. package/src/services/VersionControlService.test.ts +0 -62
  53. package/src/services/WorkbaseService.test.ts +0 -910
  54. package/src/services/WorktreeLock.test.ts +0 -205
  55. package/src/services/WorktreePerformance.test.ts +0 -71
  56. package/src/services/WorktreeService.test.ts +0 -2292
  57. package/src/test-utils.ts +0 -110
  58. package/src/usage-log.test.ts +0 -188
  59. package/src/utils/chooser.test.ts +0 -192
  60. package/src/utils/effect.test.ts +0 -30
  61. package/src/utils/interactive.pty.test.ts +0 -128
  62. package/src/utils/interactive.test.tsx +0 -860
  63. package/src/utils/process.test.ts +0 -132
  64. package/src/utils/progress.test.ts +0 -37
  65. package/src/work-view.test.ts +0 -151
  66. package/src/workbase/agent-command.test.ts +0 -128
  67. package/src/workbase/checkout-command.test.ts +0 -62
  68. package/src/workbase/delivery-command.test.ts +0 -180
  69. package/src/workbase/dependency-graph.test.ts +0 -50
  70. package/src/workbase/execution-contract.test.ts +0 -161
  71. package/src/workbase/frontmatter.test.ts +0 -67
  72. package/src/workbase/repository-reference.test.ts +0 -25
  73. package/src/workbase/schemas.test.ts +0 -543
  74. package/src/workbase/work-target.test.ts +0 -108
  75. package/src/workbase/worktree-command.test.ts +0 -61
@@ -1,945 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import orchestrationRecipes from "../fixtures/protocol/orchestration-recipes.json"
3
- import { parseCli } from "./cli-parser"
4
-
5
- const expectUsageError = (args: string[], usage: string) => {
6
- expect(() => parseCli(args)).toThrow(`Usage: ${usage}`)
7
- }
8
-
9
- describe("strict CLI parsing", () => {
10
- test("returns canonical paths without positional values", () => {
11
- expect(parseCli(["task", "show", "private-task-id"]).commandPath).toBe(
12
- "task/show",
13
- )
14
- expect(parseCli(["validate", "/private/customer/path"]).commandPath).toBe(
15
- "validate",
16
- )
17
- expect(parseCli(["work", "prepare", "private-task-id"]).commandPath).toBe(
18
- "work/prepare",
19
- )
20
- expect(parseCli(["pr", "view", "private-task-id"]).commandPath).toBe("pr")
21
- try {
22
- parseCli(["task", "create", "private-task-id"])
23
- expect.unreachable()
24
- } catch (error) {
25
- expect(error).toMatchObject({ commandPath: "task/create" })
26
- }
27
- })
28
-
29
- test("parses act selectors, dry-run, and JSON options", () => {
30
- expect(
31
- parseCli([
32
- "act",
33
- "--task",
34
- "example",
35
- "--phase",
36
- "build",
37
- "--dry-run",
38
- "--auto",
39
- "--draft",
40
- ]),
41
- ).toMatchObject({
42
- commandName: "act",
43
- args: [],
44
- values: {
45
- task: "example",
46
- phase: "build",
47
- "dry-run": true,
48
- auto: true,
49
- draft: true,
50
- },
51
- })
52
- expect(parseCli(["act", "--json"]).values.json).toBe(true)
53
- expect(parseCli(["act", "task-id"]).args).toEqual(["task-id"])
54
- expect(parseCli(["act", "."]).args).toEqual(["."])
55
- expectUsageError(["act", "one", "two"], "agency act")
56
- expectUsageError(["act", ".", "--task", "example"], "agency act")
57
- expectUsageError(["act", "--phase", "build"], "agency act")
58
- expectUsageError(
59
- ["act", "--epic", "roadmap", "--task", "example"],
60
- "agency act",
61
- )
62
- expectUsageError(["act", "--dry-run", "--json"], "agency act")
63
- })
64
-
65
- test("accepts review creation and refresh while enforcing one source", () => {
66
- expect(
67
- parseCli([
68
- "task",
69
- "create",
70
- "review-it",
71
- "--review",
72
- "agency",
73
- "--pull-request",
74
- "42",
75
- ]).values,
76
- ).toMatchObject({ review: "agency", "pull-request": "42" })
77
- expect(() =>
78
- parseCli([
79
- "task",
80
- "create",
81
- "review-it",
82
- "--review",
83
- "agency",
84
- "--ref",
85
- "feature/review",
86
- ]),
87
- ).not.toThrow()
88
- expect(() =>
89
- parseCli([
90
- "review",
91
- "refresh",
92
- "review-it",
93
- "--if-revision",
94
- "a".repeat(64),
95
- ]),
96
- ).not.toThrow()
97
- expect(() =>
98
- parseCli([
99
- "task",
100
- "create",
101
- "review-it",
102
- "--review",
103
- "agency",
104
- "--pull-request",
105
- "42",
106
- "--ref",
107
- "feature/review",
108
- ]),
109
- ).toThrow("exactly one")
110
- for (const extra of [
111
- ["--ref", "feature/review", "--repo", "agency"],
112
- ["--ref", "feature/review", "--multi-phase"],
113
- ]) {
114
- expect(() =>
115
- parseCli([
116
- "task",
117
- "create",
118
- "review-it",
119
- "--review",
120
- "agency",
121
- ...extra,
122
- ]),
123
- ).toThrow("cannot be combined")
124
- }
125
- })
126
-
127
- test("accepts every documented orchestration recipe command", () => {
128
- for (const args of orchestrationRecipes) {
129
- expect(() => parseCli(args)).not.toThrow()
130
- }
131
- })
132
-
133
- test("rejects misspelled and command-inapplicable options", () => {
134
- expectUsageError(["task", "list", "--josn"], "agency task")
135
- expectUsageError(
136
- ["task", "list", "--repo", "agency"],
137
- "agency task list [filters] [--json]",
138
- )
139
- expectUsageError(["status", "--draft"], "agency status [filters] [--json]")
140
- })
141
-
142
- test("rejects duplicate scalar, boolean, and single-value multiple options", () => {
143
- for (const args of [
144
- ["status", "--json", "--json"],
145
- ["task", "create", "example", "--repo", "one", "--repo", "two"],
146
- [
147
- "task",
148
- "create",
149
- "example",
150
- "--repo",
151
- "one",
152
- "--base",
153
- "a",
154
- "--base",
155
- "b",
156
- ],
157
- ["status", "-s", "--silent"],
158
- ]) {
159
- expect(() => parseCli(args)).toThrow("may only be specified once")
160
- }
161
- })
162
-
163
- test("preserves explicitly repeatable options", () => {
164
- expect(
165
- parseCli([
166
- "epic",
167
- "create",
168
- "delivery",
169
- "--ticket-url",
170
- "https://example.com",
171
- "--repo",
172
- "one:main",
173
- "--repo",
174
- "two:main",
175
- ]).values.repo,
176
- ).toEqual(["one:main", "two:main"])
177
- expect(
178
- parseCli([
179
- "phase",
180
- "create",
181
- "task",
182
- "phase",
183
- "--repo",
184
- "one",
185
- "--branch",
186
- "feature",
187
- "--base",
188
- "main",
189
- "--reference",
190
- "two:main",
191
- "--reference",
192
- "three:main",
193
- "--depends-on",
194
- "first",
195
- "--depends-on",
196
- "second",
197
- ]).values,
198
- ).toMatchObject({
199
- reference: ["two:main", "three:main"],
200
- "depends-on": ["first", "second"],
201
- })
202
- expect(
203
- parseCli([
204
- "archive",
205
- "list",
206
- "--kind",
207
- "task",
208
- "--kind",
209
- "phase",
210
- "--status",
211
- "done",
212
- "--repository",
213
- "agency",
214
- ]).values,
215
- ).toMatchObject({
216
- kind: ["task", "phase"],
217
- status: ["done"],
218
- repository: ["agency"],
219
- })
220
- expect(
221
- parseCli(["restore", "task", "example", "--dry-run"]).values,
222
- ).toMatchObject({ "dry-run": true })
223
- })
224
-
225
- test("parses composable view filters", () => {
226
- expect(
227
- parseCli([
228
- "task",
229
- "list",
230
- "--status",
231
- "open",
232
- "--status",
233
- "working",
234
- "--repository",
235
- "agency",
236
- "--ready",
237
- "--pr",
238
- ]).values,
239
- ).toMatchObject({
240
- status: ["open", "working"],
241
- repository: ["agency"],
242
- ready: true,
243
- pr: true,
244
- })
245
- expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
246
- })
247
-
248
- test("parses addressable resource maintenance commands", () => {
249
- for (const args of [
250
- ["doctor", "--json"],
251
- ["repo", "materialize", "--help"],
252
- ["repo", "show", "agency", "--json"],
253
- ["repo", "materialize", "agency", "--json"],
254
- ["repo", "fetch", "agency"],
255
- ["repo", "remove", "agency"],
256
- ["repo", "unlink", "agency"],
257
- ["repo", "rename", "agency", "renamed"],
258
- ["repo", "remote", "agency", "https://example.com/repo.git"],
259
- ["repo", "verify", "agency"],
260
- ["workbase", "show", "primary", "--json"],
261
- ["workbase", "init", "new-workbase", "--json"],
262
- ["workbase", "name", "primary", "renamed"],
263
- ["workbase", "name", "primary", "--clear"],
264
- ]) {
265
- expect(() => parseCli(args)).not.toThrow()
266
- }
267
- })
268
-
269
- test("validates view filter values and conflicts", () => {
270
- expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
271
- "Invalid '--status' value",
272
- )
273
- expect(() =>
274
- parseCli(["phase", "list", "task", "--ready", "--blocked"]),
275
- ).toThrow("cannot be combined")
276
- expect(() => parseCli(["status", "--pr", "--no-pr"])).toThrow(
277
- "cannot be combined",
278
- )
279
- expect(() => parseCli(["archive", "list", "--status", "invalid"])).toThrow(
280
- "Invalid '--status' value",
281
- )
282
- })
283
-
284
- test("enforces exact maximum positional arity for every leaf command", () => {
285
- for (const [args, usage] of [
286
- [["init", "one", "two"], "agency init"],
287
- [["workbase", "init", "one", "two"], "agency workbase init"],
288
- [["workbase", "add", "one", "two"], "agency workbase add"],
289
- [["workbase", "list", "extra"], "agency workbase list"],
290
- [["integration", "status", "extra"], "agency integration status"],
291
- [["integration", "sync", "extra"], "agency integration sync"],
292
- [["repo", "add", "a", "b", "extra"], "agency repo add"],
293
- [["repo", "link", "a", "b", "extra"], "agency repo link"],
294
- [["repo", "materialize", "a", "extra"], "agency repo materialize"],
295
- [["repo", "list", "extra"], "agency repo list"],
296
- [
297
- [
298
- "epic",
299
- "create",
300
- "one",
301
- "two",
302
- "--ticket-url",
303
- "url",
304
- "--repo",
305
- "repo:main",
306
- ],
307
- "agency epic create",
308
- ],
309
- [["epic", "list", "extra"], "agency epic list"],
310
- [["epic", "show", "one", "two"], "agency epic show"],
311
- [["task", "new", "one", "two"], "agency task new"],
312
- [
313
- ["task", "create", "one", "two", "--repo", "repo"],
314
- "agency task create",
315
- ],
316
- [["task", "list", "extra"], "agency task list"],
317
- [["task", "show", "one", "two"], "agency task show"],
318
- [["task", "status", "one", "open", "extra"], "agency task status"],
319
- [
320
- [
321
- "phase",
322
- "create",
323
- "task",
324
- "phase",
325
- "extra",
326
- "--repo",
327
- "repo",
328
- "--branch",
329
- "branch",
330
- "--base",
331
- "main",
332
- ],
333
- "agency phase create",
334
- ],
335
- [["phase", "list", "one", "two"], "agency phase list"],
336
- [["phase", "show", "one", "two", "three"], "agency phase show"],
337
- [
338
- ["phase", "status", "one", "two", "open", "extra"],
339
- "agency phase status",
340
- ],
341
- [["archive", "epic", "one", "two"], "agency archive epic"],
342
- [["archive", "task", "one", "two"], "agency archive task"],
343
- [["archive", "tasks", "one"], "agency archive tasks"],
344
- [["archive", "phase", "one", "two", "three"], "agency archive phase"],
345
- [["archive", "list", "extra"], "agency archive list"],
346
- [
347
- ["archive", "show", "task", "one", "extra", "more"],
348
- "agency archive show",
349
- ],
350
- [["restore", "epic", "one", "two"], "agency restore epic"],
351
- [["restore", "task", "one", "two"], "agency restore task"],
352
- [["restore", "phase", "one", "two", "three"], "agency restore phase"],
353
- [["work", "one", "two"], "agency work"],
354
- [["status", "extra"], "agency status"],
355
- [["validate", "one", "two"], "agency validate"],
356
- [["context", "one", "two"], "agency context"],
357
- [["graph", "extra"], "agency graph"],
358
- [["next", "extra"], "agency next"],
359
- [["sync", "one", "two", "three"], "agency sync"],
360
- ] as const) {
361
- expectUsageError([...args], usage)
362
- }
363
- })
364
-
365
- test("parses readiness selection and explicit guard overrides", () => {
366
- expect(parseCli(["next", "--select", "--json"])).toMatchObject({
367
- commandName: "next",
368
- values: { select: true, json: true },
369
- })
370
- expect(parseCli(["work", "example", "--force"])).toMatchObject({
371
- commandName: "work",
372
- values: { force: true },
373
- })
374
- expect(parseCli(["work", "prepare", "example", "--force"])).toMatchObject({
375
- commandName: "work",
376
- args: ["prepare", "example"],
377
- values: { force: true },
378
- })
379
- })
380
-
381
- test("preserves every argument after pr without parsing it", () => {
382
- const args = ["create", "--title", "two words", "--", "--literal"]
383
- expect(parseCli(["--cwd", "/workbase", "pr", ...args])).toEqual({
384
- commandName: "pr",
385
- commandPath: "pr",
386
- args,
387
- passthrough: true,
388
- values: { cwd: "/workbase" },
389
- })
390
- })
391
-
392
- test("parses task-aware pr create without routing it to passthrough", () => {
393
- expect(
394
- parseCli([
395
- "pr",
396
- "create",
397
- "ship",
398
- "release",
399
- "--draft",
400
- "--force",
401
- "--json",
402
- ]),
403
- ).toEqual({
404
- commandName: "pr",
405
- commandPath: "pr/create",
406
- args: ["create", "ship", "release"],
407
- values: { draft: true, force: true, json: true },
408
- })
409
- const selected = parseCli(["pr", "create", "--task", "ship"])
410
- expect(selected).toMatchObject({
411
- args: ["create", "ship"],
412
- })
413
- expect(selected.passthrough).toBeUndefined()
414
- expect(
415
- parseCli([
416
- "pr",
417
- "create",
418
- "ship",
419
- "--title",
420
- "Ship it",
421
- "--head",
422
- "task/ship",
423
- "--base",
424
- "main",
425
- "--label",
426
- "ai-assisted",
427
- "--label",
428
- "product-area:platform",
429
- ]),
430
- ).toMatchObject({
431
- args: ["create", "ship"],
432
- values: {
433
- title: "Ship it",
434
- head: "task/ship",
435
- base: "main",
436
- label: ["ai-assisted", "product-area:platform"],
437
- },
438
- })
439
- })
440
-
441
- test("parses explicit dry-run reconciliation", () => {
442
- expect(parseCli(["sync"])).toMatchObject({
443
- commandName: "sync",
444
- values: {},
445
- })
446
- expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
447
- commandName: "sync",
448
- values: { "dry-run": true, json: true },
449
- })
450
- expect(parseCli(["sync", "ship", "release", "--json"])).toMatchObject({
451
- commandName: "sync",
452
- args: ["ship", "release"],
453
- values: { json: true },
454
- })
455
- expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
456
- })
457
-
458
- test("accepts archive dry-run", () => {
459
- expect(parseCli(["archive", ".", "--dry-run"])).toMatchObject({
460
- commandName: "archive",
461
- args: ["."],
462
- values: { "dry-run": true },
463
- })
464
- expect(parseCli(["archive", "task", "example", "--dry-run"])).toMatchObject(
465
- {
466
- commandName: "archive",
467
- values: { "dry-run": true },
468
- },
469
- )
470
- expect(parseCli(["archive", "tasks", "--dry-run", "--json"])).toMatchObject(
471
- {
472
- commandName: "archive",
473
- args: ["tasks"],
474
- values: { "dry-run": true, json: true },
475
- },
476
- )
477
- })
478
-
479
- test("rejects removed claim protocol commands", () => {
480
- for (const command of ["claim", "release", "finish"]) {
481
- expect(() => parseCli([command])).toThrow(`Unknown command '${command}'`)
482
- }
483
- expect(() =>
484
- parseCli([
485
- "task",
486
- "status",
487
- "example",
488
- "working",
489
- "--no-pull-request",
490
- "--summary",
491
- "Not done",
492
- ]),
493
- ).toThrow("valid only with a done status")
494
- expect(() =>
495
- parseCli([
496
- "phase",
497
- "status",
498
- "task",
499
- "phase",
500
- "done",
501
- "--summary",
502
- "Missing explicit flag",
503
- ]),
504
- ).toThrow("require '--no-pull-request'")
505
- })
506
-
507
- test("accepts valid mutation revisions and rejects malformed hashes", () => {
508
- const revision = "a".repeat(64)
509
- expect(
510
- parseCli([
511
- "task",
512
- "move",
513
- "example",
514
- "--no-epic",
515
- "--if-revision",
516
- revision,
517
- ]),
518
- ).toMatchObject({ values: { "if-revision": revision } })
519
- expect(() =>
520
- parseCli([
521
- "phase",
522
- "rename",
523
- "task",
524
- "phase",
525
- "renamed",
526
- "--if-revision",
527
- "stale",
528
- ]),
529
- ).toThrow("64-character SHA-256 hash")
530
- })
531
-
532
- test("accepts context projections and keeps compact command-local", () => {
533
- expect(parseCli(["context", ".", "--json", "--compact"])).toMatchObject({
534
- commandName: "context",
535
- args: ["."],
536
- values: { json: true, compact: true },
537
- })
538
- expectUsageError(["status", "--compact"], "agency status")
539
- expect(parseCli(["context", ".", "--json", "--full"])).toMatchObject({
540
- commandName: "context",
541
- args: ["."],
542
- values: { json: true, full: true },
543
- })
544
- expectUsageError(["context", ".", "--compact", "--full"], "agency context")
545
- expectUsageError(["status", "--full"], "agency status")
546
- })
547
-
548
- test("accepts work preparation options only for the prepare subcommand", () => {
549
- expect(
550
- parseCli(["work", "prepare", "tasks/example", "--dry-run", "--json"]),
551
- ).toMatchObject({
552
- commandName: "work",
553
- args: ["prepare", "tasks/example"],
554
- values: { "dry-run": true, json: true },
555
- })
556
- expect(() => parseCli(["work", "example", "--dry-run"])).toThrow(
557
- "only valid with 'agency work prepare'",
558
- )
559
- expect(() => parseCli(["work", "prepare", "--opencode"])).toThrow(
560
- "cannot be combined",
561
- )
562
- expect(() => parseCli(["work", "prepare", "first", "second"])).toThrow(
563
- "Expected 0-2 positional arguments",
564
- )
565
- expect(() =>
566
- parseCli(["work", "prepare", "example", "--task", "other"]),
567
- ).toThrow("Entity selector options cannot be combined")
568
- expect(() => parseCli(["work", "prepare", "--phase", "build"])).toThrow(
569
- "requires '--task'",
570
- )
571
- })
572
-
573
- test("parses worktree lifecycle commands and selectors", () => {
574
- expect(parseCli(["worktree", "list", "--json"])).toMatchObject({
575
- commandName: "worktree",
576
- args: ["list"],
577
- values: { json: true },
578
- })
579
- expect(
580
- parseCli([
581
- "worktree",
582
- "rebuild",
583
- "--task",
584
- "example",
585
- "--phase",
586
- "verify",
587
- "--dry-run",
588
- "--force",
589
- ]),
590
- ).toMatchObject({
591
- commandName: "worktree",
592
- args: ["rebuild", "example", "verify"],
593
- values: {
594
- task: "example",
595
- phase: "verify",
596
- "dry-run": true,
597
- force: true,
598
- },
599
- })
600
- expectUsageError(
601
- ["worktree", "inspect", "example", "--dry-run"],
602
- "agency worktree inspect",
603
- )
604
- expect(() => parseCli(["worktree", "repair", "--phase", "verify"])).toThrow(
605
- "requires '--task'",
606
- )
607
- })
608
-
609
- test("parses push without accepting an alternate target", () => {
610
- expect(parseCli(["push", "--json"])).toMatchObject({
611
- commandName: "push",
612
- args: [],
613
- values: { json: true },
614
- })
615
- expectUsageError(["push", "example"], "agency push")
616
- })
617
-
618
- test("accepts agent selection and command inspection for work", () => {
619
- expect(
620
- parseCli([
621
- "work",
622
- "example",
623
- "--agent",
624
- "custom",
625
- "--auto",
626
- "--print-command",
627
- ]),
628
- ).toMatchObject({
629
- commandName: "work",
630
- args: ["example"],
631
- values: { agent: "custom", auto: true, "print-command": true },
632
- })
633
- expect(() =>
634
- parseCli(["work", "example", "--agent", "custom", "--claude"]),
635
- ).toThrow("cannot be combined")
636
- expect(() => parseCli(["work", "prepare", "--print-command"])).toThrow(
637
- "cannot be combined",
638
- )
639
- expect(() => parseCli(["work", "prepare", "--auto"])).toThrow(
640
- "cannot be combined",
641
- )
642
- })
643
-
644
- test("accepts work launch options on new entities", () => {
645
- expect(
646
- parseCli(["task", "new", "example", "--work", "--auto"]),
647
- ).toMatchObject({
648
- commandName: "task",
649
- args: ["new", "example"],
650
- values: { work: true, auto: true },
651
- })
652
- expect(
653
- parseCli([
654
- "epic",
655
- "new",
656
- "delivery",
657
- "--ticket-url",
658
- "https://example.com/delivery",
659
- "--repo",
660
- "agency:main",
661
- "--work",
662
- ]),
663
- ).toMatchObject({ commandName: "epic", args: ["new", "delivery"] })
664
- expect(
665
- parseCli([
666
- "phase",
667
- "new",
668
- "delivery",
669
- "implementation",
670
- "--repo",
671
- "agency",
672
- "--branch",
673
- "task/implementation",
674
- "--base",
675
- "main",
676
- "--work",
677
- ]),
678
- ).toMatchObject({
679
- commandName: "phase",
680
- args: ["new", "delivery", "implementation"],
681
- })
682
- expect(() => parseCli(["task", "new", "example", "--auto"])).toThrow(
683
- "Option '--auto' requires '--work'",
684
- )
685
- expect(() =>
686
- parseCli(["task", "new", "example", "--work", "--json"]),
687
- ).toThrow("cannot be combined")
688
- expectUsageError(
689
- ["task", "create", "example", "--repo", "agency", "--work"],
690
- "agency task create",
691
- )
692
- })
693
-
694
- test("accepts repeatable graph filters and rejects output conflicts", () => {
695
- expect(
696
- parseCli([
697
- "graph",
698
- "--json",
699
- "--status",
700
- "open",
701
- "--status",
702
- "working",
703
- "--repository",
704
- "agency",
705
- "--kind",
706
- "task",
707
- "--kind",
708
- "phase",
709
- "--include",
710
- "bodies",
711
- "--include",
712
- "git",
713
- ]).values,
714
- ).toMatchObject({
715
- json: true,
716
- status: ["open", "working"],
717
- repository: ["agency"],
718
- kind: ["task", "phase"],
719
- include: ["bodies", "git"],
720
- })
721
- expect(() => parseCli(["graph", "--json", "--jsonl"])).toThrow(
722
- "cannot be combined",
723
- )
724
- expect(() => parseCli(["graph", "--ready", "--blocked"])).toThrow(
725
- "cannot be combined",
726
- )
727
- for (const [option, value] of [
728
- ["status", "started"],
729
- ["kind", "worktree"],
730
- ["include", "secrets"],
731
- ] as const) {
732
- expect(() => parseCli(["graph", `--${option}`, value])).toThrow(
733
- `Invalid '--${option}' value`,
734
- )
735
- }
736
- })
737
-
738
- test("reports unknown subcommands with parent usage", () => {
739
- expect(() => parseCli(["task", "crate"])).toThrow(
740
- "Unknown subcommand 'crate'",
741
- )
742
- expectUsageError(
743
- ["task", "crate"],
744
- "agency task <new|create|handoff|list|show|status|update|rename|move|dependency>",
745
- )
746
- })
747
-
748
- test("parses investigation creation and explicit handoff commands", () => {
749
- expect(
750
- parseCli([
751
- "task",
752
- "create",
753
- "investigate",
754
- "--purpose",
755
- "investigation",
756
- "--repo",
757
- "agency",
758
- ]),
759
- ).toMatchObject({
760
- args: ["create", "investigate"],
761
- values: { purpose: "investigation", repo: ["agency"] },
762
- })
763
- expect(
764
- parseCli([
765
- "task",
766
- "handoff",
767
- "investigate",
768
- "implement",
769
- "--source-phase",
770
- "evidence",
771
- "--repo",
772
- "agency",
773
- "--json",
774
- ]),
775
- ).toMatchObject({
776
- args: ["handoff", "investigate", "implement"],
777
- values: {
778
- "source-phase": "evidence",
779
- repo: ["agency"],
780
- json: true,
781
- },
782
- })
783
- expect(() =>
784
- parseCli([
785
- "task",
786
- "create",
787
- "investigate",
788
- "--purpose",
789
- "unknown",
790
- "--repo",
791
- "agency",
792
- ]),
793
- ).toThrow("Invalid '--purpose' value")
794
- })
795
-
796
- test("parses graph mutation commands and rejects unsafe ambiguity", () => {
797
- expect(
798
- parseCli([
799
- "task",
800
- "update",
801
- "example",
802
- "--description",
803
- "Revised",
804
- "--reference",
805
- "docs:main",
806
- "--reference",
807
- "api:main",
808
- "--clear-pr",
809
- ]),
810
- ).toMatchObject({
811
- args: ["update", "example"],
812
- values: {
813
- description: "Revised",
814
- reference: ["docs:main", "api:main"],
815
- "clear-pr": true,
816
- },
817
- })
818
- expect(
819
- parseCli(["task", "move", "example", "--no-epic"]).values,
820
- ).toMatchObject({
821
- "no-epic": true,
822
- })
823
- expect(parseCli(["phase", "rename", "task", "old", "new"]).args).toEqual([
824
- "rename",
825
- "task",
826
- "old",
827
- "new",
828
- ])
829
- expect(() => parseCli(["task", "update", "example"])).toThrow(
830
- "At least one update option",
831
- )
832
- expect(() =>
833
- parseCli(["task", "move", "example", "--epic", "one", "--no-epic"]),
834
- ).toThrow("cannot be combined")
835
- expect(() => parseCli(["task", "dependency", "replace", "a", "b"])).toThrow(
836
- "must be 'add' or 'remove'",
837
- )
838
- })
839
-
840
- test("rejects required-option omissions and explicit conflicts", () => {
841
- expect(() => parseCli(["task", "create", "example"])).toThrow(
842
- "--repo' or '--context-repo' is required",
843
- )
844
- for (const option of ["repo", "reference", "branch", "base"] as const) {
845
- const value = option === "reference" ? "other:main" : "value"
846
- expect(() =>
847
- parseCli([
848
- "task",
849
- "create",
850
- "example",
851
- "--multi-phase",
852
- `--${option}`,
853
- value,
854
- ]),
855
- ).toThrow("cannot be combined")
856
- }
857
- expect(() =>
858
- parseCli(["task", "new", "example", "--multi-phase", "--repo", "repo"]),
859
- ).toThrow("cannot be combined")
860
- expect(() => parseCli(["work", "--opencode", "--claude"])).toThrow(
861
- "cannot be combined",
862
- )
863
- expect(() => parseCli(["work", "task", "--epic", "epic"])).toThrow(
864
- "cannot be combined",
865
- )
866
- expect(() => parseCli(["status", "--silent", "--verbose"])).toThrow(
867
- "cannot be combined",
868
- )
869
- })
870
-
871
- test("accepts the global interaction control on every command", () => {
872
- expect(parseCli(["--no-input", "task", "new"]).values["no-input"]).toBe(
873
- true,
874
- )
875
- expect(parseCli(["work", "--no-input"]).values["no-input"]).toBe(true)
876
- expect(parseCli(["validate", "--no-input"]).values["no-input"]).toBe(true)
877
- expect(parseCli(["status", "--no-input"]).values["no-input"]).toBe(true)
878
- expect(
879
- parseCli(["task", "create", "example", "--repo", "repo", "--no-input"])
880
- .values["no-input"],
881
- ).toBe(true)
882
- })
883
-
884
- test("normalizes explicit entity selectors into command targets", () => {
885
- expect(
886
- parseCli([
887
- "phase",
888
- "status",
889
- "done",
890
- "--task",
891
- "ship",
892
- "--phase",
893
- "release",
894
- ]),
895
- ).toMatchObject({
896
- commandName: "phase",
897
- args: ["status", "ship", "release", "done"],
898
- })
899
- expect(parseCli(["context", "--epic", "delivery"]).values.epic).toBe(
900
- "delivery",
901
- )
902
- })
903
-
904
- test("enforces explicit selector precedence and exclusions", () => {
905
- expect(() =>
906
- parseCli(["task", "show", "positional", "--task", "explicit"]),
907
- ).toThrow("cannot be combined with positional target IDs")
908
- expect(() => parseCli(["context", "--phase", "release"])).toThrow(
909
- "--phase' requires '--task",
910
- )
911
- expect(() =>
912
- parseCli(["work", "--epic", "delivery", "--task", "ship"]),
913
- ).toThrow("cannot be combined with '--task'")
914
- expect(() =>
915
- parseCli(["status", "--workbase", "primary", "--cwd", "/tmp"]),
916
- ).toThrow("--workbase' and '--cwd' cannot be combined")
917
- })
918
-
919
- test("accepts explicit workbase context before or after commands", () => {
920
- expect(
921
- parseCli(["--workbase", "primary", "task", "list", "--no-input"]).values
922
- .workbase,
923
- ).toBe("primary")
924
- expect(parseCli(["status", "--cwd", "/tmp"]).values.cwd).toBe("/tmp")
925
- expect(
926
- parseCli(["--workbase=primary", "task", "list"]).values.workbase,
927
- ).toBe("primary")
928
- expect(parseCli(["--cwd=/tmp", "status"]).values.cwd).toBe("/tmp")
929
- })
930
-
931
- test("rejects empty selectors", () => {
932
- for (const args of [
933
- ["status", "--workbase="],
934
- ["status", "--cwd="],
935
- ["context", "--task="],
936
- ]) {
937
- expect(() => parseCli(args)).toThrow("requires a non-empty value")
938
- }
939
- })
940
-
941
- test("accepts grouped global short options before a command", () => {
942
- const parsed = parseCli(["-sh", "task"])
943
- expect(parsed.values).toMatchObject({ silent: true, help: true })
944
- })
945
- })