@markjaquith/agency 1.11.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +326 -399
  24. package/src/commands/work.ts +153 -153
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
package/index.ts CHANGED
@@ -1 +1 @@
1
- console.log("Hello via Bun!")
1
+ export * from "./src/workbase/schemas"
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.11.1",
4
- "description": "Manages personal agents files",
3
+ "version": "2.1.0",
4
+ "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
7
7
  "bun",
8
- "personal-agents",
9
- "typescript"
8
+ "typescript",
9
+ "worktrees"
10
10
  ],
11
11
  "license": "MIT",
12
12
  "author": "Mark Jaquith",
@@ -21,7 +21,7 @@
21
21
  "index.ts",
22
22
  "cli.ts",
23
23
  "src",
24
- "templates",
24
+ "skills",
25
25
  "README.md",
26
26
  "LICENSE"
27
27
  ],
@@ -40,9 +40,7 @@
40
40
  "tag": "latest"
41
41
  },
42
42
  "scripts": {
43
- "test": "find src -name '*.test.ts' ! -name '*.integration.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
44
- "test:integration": "find src -name '*.integration.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
45
- "test:all": "find src -name '*.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
43
+ "test": "find src -name '*.test.ts' -print0 | xargs -0 -n 1 -P 4 bun test",
46
44
  "format": "oxfmt",
47
45
  "format:check": "oxfmt --check",
48
46
  "knip": "knip --production",
@@ -53,21 +51,18 @@
53
51
  "dependencies": {
54
52
  "@effect/schema": "^0.75.5",
55
53
  "effect": "^3.19.15",
56
- "jsonc-parser": "^3.3.1",
57
- "ora": "^8.2.0"
54
+ "yaml": "^2.9.0"
58
55
  },
59
56
  "devDependencies": {
60
57
  "@types/bun": "^1.3.6",
61
- "knip": "^5.82.1",
62
- "oxfmt": "^0.27.0"
58
+ "knip": "^6.27.0",
59
+ "oxfmt": "^0.27.0",
60
+ "typescript": "^7.0.2"
63
61
  },
64
62
  "peerDependencies": {
65
- "typescript": "^5.9.3"
63
+ "typescript": "^7.0.2"
66
64
  },
67
65
  "engines": {
68
66
  "bun": ">=1.0.0"
69
- },
70
- "trustedDependencies": [
71
- "@triggi/native-exec"
72
- ]
67
+ }
73
68
  }
@@ -0,0 +1,266 @@
1
+ ---
2
+ name: agency
3
+ description: >
4
+ Use Agency to manage filesystem-backed workbases, repository aliases, epics,
5
+ tasks, phases, execution worktrees, and GitHub pull requests. Use when the
6
+ user asks to create, inspect, validate, or work on Agency epics/tasks/phases;
7
+ coordinate work across repositories; materialize task worktrees; or create a
8
+ PR through Agency.
9
+ license: MIT
10
+ compatibility: Requires the agency CLI and Git, plus OpenCode or Claude Code. Interactive work selection uses fzf; PR creation uses gh.
11
+ ---
12
+
13
+ # Agency
14
+
15
+ Use Agency to coordinate durable work across one or more Git repositories.
16
+ Agency stores planning and orchestration as Markdown with YAML frontmatter while
17
+ keeping repository checkouts as local workbase state.
18
+
19
+ ## Mental Model
20
+
21
+ - A **workbase** is the root containing `agency.json`, `repos/`, `epics/`, and
22
+ `tasks/`.
23
+ - A repository alias is a bare Git repository or symlink at `repos/{alias}`.
24
+ - An **epic** orchestrates tasks and only reads repositories.
25
+ - A **task** describes one durable outcome. It may be standalone or belong to an
26
+ epic.
27
+ - A **phase** belongs to a multi-phase task and corresponds to one PR or intended
28
+ PR.
29
+ - An **execution unit** is either a single-phase task or a phase. It has one
30
+ writable `repo`, optional read-only `repos`, one branch, one base, and one
31
+ `pr` value.
32
+
33
+ IDs are directory names. Do not repeat IDs in document frontmatter.
34
+
35
+ ## Start By Inspecting
36
+
37
+ From anywhere beneath a workbase, run:
38
+
39
+ ```bash
40
+ agency status --json
41
+ agency validate --json
42
+ agency repo list --json
43
+ agency epic list --json
44
+ agency task list --json
45
+ ```
46
+
47
+ Use `show` before changing an existing entity:
48
+
49
+ ```bash
50
+ agency epic show <epic-id> --json
51
+ agency task show <task-id> --json
52
+ agency phase show <task-id> <phase-id> --json
53
+ ```
54
+
55
+ If no workbase is found, do not initialize one without user intent. When asked:
56
+
57
+ ```bash
58
+ agency init [path]
59
+ ```
60
+
61
+ ## Repository Aliases
62
+
63
+ Add a remote as an Agency-managed bare repository:
64
+
65
+ ```bash
66
+ agency repo add <alias> <git-remote>
67
+ ```
68
+
69
+ Link an existing local Git repository:
70
+
71
+ ```bash
72
+ agency repo link <alias> <path>
73
+ ```
74
+
75
+ Use aliases, never absolute paths or Git URLs, in epic/task/phase frontmatter.
76
+
77
+ ## Choose The Correct Work Shape
78
+
79
+ Use a single-phase task when one PR in one writable repository can deliver the
80
+ outcome. Use a multi-phase task when the outcome requires multiple intended PRs,
81
+ possibly across repositories or with sequencing dependencies. Use an epic when
82
+ several independently meaningful tasks need orchestration.
83
+
84
+ If phases are known up front, create a multi-phase task. To add a phase later,
85
+ use `--first-phase <id>` to name the phase created from the task's existing
86
+ execution fields and worktrees.
87
+
88
+ ## Create Epics
89
+
90
+ ```bash
91
+ agency epic create <id> \
92
+ --ticket-url <url> \
93
+ [--description <text>] \
94
+ --repo <read-only-alias>:<ref> \
95
+ [--repo <another-alias>:<ref>]
96
+ ```
97
+
98
+ Epic task ordering and dependencies live in `EPIC.md`. Creating a task with
99
+ `--epic` automatically writes both sides of the epic/task relationship.
100
+
101
+ ## Create Single-Phase Tasks
102
+
103
+ ```bash
104
+ agency task create <id> \
105
+ --ticket-url <url> \
106
+ [--description <text>] \
107
+ [--epic <epic-id>] \
108
+ --repo <writable-alias> \
109
+ [--reference <read-only-alias>:<ref>] \
110
+ --branch <branch> \
111
+ --base <base>
112
+ ```
113
+
114
+ The task itself is the execution unit. Its worktrees live under
115
+ `tasks/{id}/code/{alias}`.
116
+
117
+ ## Create Multi-Phase Tasks
118
+
119
+ Create the task container:
120
+
121
+ ```bash
122
+ agency task create <id> \
123
+ --ticket-url <url> \
124
+ [--description <text>] \
125
+ [--epic <epic-id>] \
126
+ --multi-phase
127
+ ```
128
+
129
+ Then create each phase:
130
+
131
+ ```bash
132
+ agency phase create <task-id> <phase-id> \
133
+ [--description <text>] \
134
+ --repo <writable-alias> \
135
+ [--reference <read-only-alias>:<ref>] \
136
+ --branch <branch> \
137
+ --base <base> \
138
+ [--depends-on <phase-id>]
139
+ ```
140
+
141
+ Repeat `--reference` and `--depends-on` when needed. Phase worktrees live under
142
+ `tasks/{task-id}/phases/{phase-id}/code/{alias}`.
143
+
144
+ Convert an existing single-phase task while adding another phase:
145
+
146
+ ```bash
147
+ agency phase create <task-id> <new-phase-id> \
148
+ --first-phase <existing-phase-id> \
149
+ --repo <writable-alias> \
150
+ --branch <new-branch> \
151
+ --base <base> \
152
+ [--depends-on <existing-phase-id>]
153
+ ```
154
+
155
+ The conversion preserves task-level metadata and prose, moves execution metadata
156
+ into the named existing phase, and relocates any materialized worktrees.
157
+
158
+ ## Frontmatter Rules
159
+
160
+ Agency documents use YAML 1.2 frontmatter:
161
+
162
+ ```yaml
163
+ ---
164
+ ticketUrl: https://example.com/tickets/example
165
+ description: Deliver the example outcome.
166
+ repo: application
167
+ repos:
168
+ - repo: api
169
+ ref: main
170
+ branch: task/example
171
+ base: main
172
+ pr: null
173
+ ---
174
+ ```
175
+
176
+ Follow these invariants:
177
+
178
+ - `ticketUrl` belongs to epics and tasks, not phases.
179
+ - `description` is an optional non-empty summary on epics, tasks, and phases.
180
+ - `repo` is the one writable repository for an execution unit.
181
+ - `repos` contains `{ repo, ref }` read-only references and must not repeat `repo`.
182
+ - Epics may declare `repos` but never `repo`.
183
+ - A writable `(repo, branch)` pair belongs to exactly one task or phase.
184
+ - Use a commit SHA for a read-only `ref` when reproducibility matters.
185
+
186
+ Commands that print Agency-owned results accept `--json` for machine-readable
187
+ output, including mutations, entity inspection, status, validation, and PR creation.
188
+
189
+ - Multi-phase task frontmatter owns the phase dependency graph.
190
+ - Epic frontmatter owns the child-task dependency graph.
191
+ - `pr` is either a GitHub PR URL string or `null`.
192
+ - Keep directory IDs stable; encode sequencing with `dependsOn`, not numeric
193
+ directory prefixes.
194
+ - Do not use YAML duplicate keys, anchors, aliases, or custom tags.
195
+
196
+ Prefer Agency commands for creation. When manually editing dependencies or
197
+ prose, preserve backlinks and run validation immediately afterward.
198
+
199
+ ## Validate Every Structural Change
200
+
201
+ ```bash
202
+ agency validate
203
+ ```
204
+
205
+ Use `--json` when diagnostics will be consumed programmatically. Resolve all
206
+ validation errors before materializing worktrees or creating PRs. Validation
207
+ checks schemas, aliases, backlinks, phase directories, duplicate references,
208
+ duplicate writable branch ownership, unknown dependencies, and dependency cycles.
209
+
210
+ ## Worktrees And Agent Launch
211
+
212
+ ```bash
213
+ agency work
214
+ agency work <task-id> [phase-id]
215
+ agency work --epic <epic-id>
216
+ ```
217
+
218
+ Use `--opencode` or `--claude` to require a specific agent. This command fetches
219
+ repositories for execution targets, creates or reuses their worktrees, and
220
+ replaces the current process with the selected agent. It infers the nearest
221
+ epic, task, or phase from the current directory; otherwise it opens an `fzf`
222
+ picker containing the workbase hierarchy.
223
+
224
+ Epic and multi-phase task targets are orchestration sessions launched beside
225
+ their documents. Single-phase tasks and phases are execution sessions launched
226
+ in their writable checkout.
227
+
228
+ The workbase may delegate writable checkout creation through
229
+ `worktreeCreateCommand` in `agency.json`. Do not bypass that command or create a
230
+ parallel worktree manually. Supplemental read-only checkouts are still detached
231
+ Git worktrees managed directly by Agency at their declared refs.
232
+
233
+ Agency inspects `git worktree list --porcelain` before materializing. It reuses a
234
+ writable checkout only when both path and branch match, and reuses a reference
235
+ checkout only when its commit matches the declared ref. If a branch is checked
236
+ out elsewhere, choose a different branch or remove the conflicting worktree;
237
+ never force a second checkout of the branch.
238
+
239
+ Do not run `agency work` from inside an active agent session unless the user
240
+ explicitly wants to launch a nested/replacement agent process. If already
241
+ working in an Agency checkout, read the owning `TASK.md` and optional `PHASE.md`
242
+ directly instead.
243
+
244
+ ## Create Pull Requests
245
+
246
+ Only create a PR when the user explicitly requests it:
247
+
248
+ ```bash
249
+ agency pr create <task-id> [phase-id] [--draft]
250
+ ```
251
+
252
+ Agency requires a clean writable worktree, pushes the execution branch, runs
253
+ `gh pr create --fill`, and records the returned URL in the owning document's
254
+ `pr` field. If PR creation fails, do not invent or manually write a URL.
255
+
256
+ ## Safety Rules
257
+
258
+ - Run `agency validate` before worktree or PR operations.
259
+ - Inspect existing entities before editing them.
260
+ - Never write through a repository listed in plural `repos`.
261
+ - Do not manually move generated `code/` worktrees.
262
+ - Do not manually edit bare repositories under `repos/`.
263
+ - Do not create a PR, initialize a workbase, clone a remote, or add a symlink
264
+ without user intent.
265
+ - Keep task-level decisions in `TASK.md` and delivery-specific context in
266
+ `PHASE.md`.
@@ -0,0 +1,290 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { realpath } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { cleanupTempDir, createTempDir } from "./test-utils"
5
+
6
+ const projectRoot = join(import.meta.dir, "..")
7
+ const cliPath = join(projectRoot, "cli.ts")
8
+
9
+ interface CliResult {
10
+ exitCode: number
11
+ stdout: string
12
+ stderr: string
13
+ }
14
+
15
+ async function runCli(args: string[], cwd = projectRoot): Promise<CliResult> {
16
+ const subprocess = Bun.spawn([process.execPath, cliPath, ...args], {
17
+ cwd,
18
+ stdout: "pipe",
19
+ stderr: "pipe",
20
+ })
21
+ const [exitCode, stdout, stderr] = await Promise.all([
22
+ subprocess.exited,
23
+ new Response(subprocess.stdout).text(),
24
+ new Response(subprocess.stderr).text(),
25
+ ])
26
+ return { exitCode, stdout, stderr }
27
+ }
28
+
29
+ function parseJson(result: CliResult) {
30
+ expect(result.exitCode).toBe(0)
31
+ expect(result.stderr).toBe("")
32
+ return JSON.parse(result.stdout)
33
+ }
34
+
35
+ describe("CLI", () => {
36
+ const tempDirs: string[] = []
37
+
38
+ afterEach(async () => {
39
+ await Promise.all(tempDirs.splice(0).map(cleanupTempDir))
40
+ })
41
+
42
+ test("reports version and routes top-level help with intentional statuses", async () => {
43
+ const version = await runCli(["--version"])
44
+ expect(version).toEqual({
45
+ exitCode: 0,
46
+ stdout: "v0.0.0-development\n",
47
+ stderr: "",
48
+ })
49
+
50
+ const noArgs = await runCli([])
51
+ expect(noArgs.exitCode).toBe(1)
52
+ expect(noArgs.stdout).toContain("Usage: agency <command> [options]")
53
+ expect(noArgs.stderr).toBe("")
54
+
55
+ const help = await runCli(["--help"])
56
+ expect(help.exitCode).toBe(0)
57
+ expect(help.stdout).toContain("Usage: agency <command> [options]")
58
+ expect(help.stderr).toBe("")
59
+ })
60
+
61
+ test("reports unknown commands and preserves tagged error messages", async () => {
62
+ const unknown = await runCli(["unknown"])
63
+ expect(unknown.exitCode).toBe(1)
64
+ expect(unknown.stdout).toBe("")
65
+ expect(unknown.stderr).toContain("Error: Unknown command 'unknown'")
66
+
67
+ const cwd = await createTempDir()
68
+ tempDirs.push(cwd)
69
+ const taggedError = await runCli(["repo", "list"], cwd)
70
+ expect(taggedError.exitCode).toBe(1)
71
+ expect(taggedError.stdout).toBe("")
72
+ expect(taggedError.stderr).toContain("ⓘ No Agency workbase found from")
73
+ expect(taggedError.stderr).not.toContain("An error has occurred")
74
+ })
75
+
76
+ test("routes command help and global options on either side of commands", async () => {
77
+ for (const [command, usage] of [
78
+ ["init", "Usage: agency init"],
79
+ ["repo", "Usage: agency repo"],
80
+ ["epic", "Usage: agency epic"],
81
+ ["task", "Usage: agency task"],
82
+ ["phase", "Usage: agency phase"],
83
+ ["work", "Usage: agency work"],
84
+ ["pr", "Usage: agency pr"],
85
+ ["status", "Usage: agency status"],
86
+ ["validate", "Usage: agency validate"],
87
+ ] as const) {
88
+ const result = await runCli([command, "--help"])
89
+ expect(result.exitCode).toBe(0)
90
+ expect(result.stdout).toContain(usage)
91
+ expect(result.stderr).toBe("")
92
+ }
93
+
94
+ const helpBefore = await runCli(["--help", "task"])
95
+ expect(helpBefore.exitCode).toBe(0)
96
+ expect(helpBefore.stdout).toContain("Usage: agency task <subcommand>")
97
+ expect(helpBefore.stderr).toBe("")
98
+
99
+ const root = await createTempDir()
100
+ tempDirs.push(root)
101
+ const before = await runCli(["--silent", "init", root])
102
+ expect(before).toEqual({ exitCode: 0, stdout: "", stderr: "" })
103
+
104
+ const after = await runCli(["status", "--silent"], root)
105
+ expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
106
+ })
107
+
108
+ test("runs a multi-phase domain workflow through subprocesses", async () => {
109
+ const parent = await createTempDir()
110
+ tempDirs.push(parent)
111
+ const root = join(parent, "workbase")
112
+ const source = join(parent, "source")
113
+
114
+ const git = Bun.spawn(["git", "init", "--initial-branch=main", source], {
115
+ stdout: "ignore",
116
+ stderr: "pipe",
117
+ })
118
+ expect(await git.exited).toBe(0)
119
+
120
+ expect(parseJson(await runCli(["init", root, "--json"]))).toEqual({
121
+ root,
122
+ })
123
+ const workbaseRoot = await realpath(root)
124
+ expect(
125
+ parseJson(
126
+ await runCli(["repo", "link", "agency", source, "--json"], root),
127
+ ),
128
+ ).toEqual({
129
+ alias: "agency",
130
+ path: join(workbaseRoot, "repos/agency"),
131
+ })
132
+ for (const alias of ["effect", "tooling"]) {
133
+ parseJson(await runCli(["repo", "link", alias, source, "--json"], root))
134
+ }
135
+
136
+ const epic = parseJson(
137
+ await runCli(
138
+ [
139
+ "epic",
140
+ "create",
141
+ "delivery",
142
+ "--ticket-url",
143
+ "https://example.com/epics/delivery",
144
+ "--repo",
145
+ "agency:main",
146
+ "--json",
147
+ ],
148
+ root,
149
+ ),
150
+ )
151
+ expect(epic).toMatchObject({
152
+ id: "delivery",
153
+ data: { repos: [{ repo: "agency", ref: "main" }], tasks: [] },
154
+ })
155
+
156
+ const task = parseJson(
157
+ await runCli(
158
+ [
159
+ "task",
160
+ "create",
161
+ "ship",
162
+ "--ticket-url",
163
+ "https://example.com/tasks/ship",
164
+ "--epic",
165
+ "delivery",
166
+ "--multi-phase",
167
+ "--json",
168
+ ],
169
+ root,
170
+ ),
171
+ )
172
+ expect(task).toMatchObject({
173
+ id: "ship",
174
+ data: { epic: "delivery", phases: [] },
175
+ })
176
+
177
+ for (const [id, branch] of [
178
+ ["prepare", "task/ship-prepare"],
179
+ ["implement", "task/ship-implement"],
180
+ ] as const) {
181
+ const phase = parseJson(
182
+ await runCli(
183
+ [
184
+ "phase",
185
+ "create",
186
+ "ship",
187
+ id,
188
+ "--repo",
189
+ "agency",
190
+ "--branch",
191
+ branch,
192
+ "--base",
193
+ "main",
194
+ "--json",
195
+ ],
196
+ root,
197
+ ),
198
+ )
199
+ expect(phase).toMatchObject({ taskId: "ship", id })
200
+ }
201
+
202
+ const release = parseJson(
203
+ await runCli(
204
+ [
205
+ "phase",
206
+ "create",
207
+ "ship",
208
+ "release",
209
+ "--repo",
210
+ "agency",
211
+ "--branch",
212
+ "task/ship-release",
213
+ "--base",
214
+ "main",
215
+ "--depends-on",
216
+ "prepare",
217
+ "--depends-on",
218
+ "implement",
219
+ "--reference",
220
+ "effect:main",
221
+ "--reference",
222
+ "tooling:main",
223
+ "--json",
224
+ ],
225
+ root,
226
+ ),
227
+ )
228
+ expect(release.data).toMatchObject({
229
+ branch: "task/ship-release",
230
+ base: "main",
231
+ repos: [
232
+ { repo: "effect", ref: "main" },
233
+ { repo: "tooling", ref: "main" },
234
+ ],
235
+ })
236
+
237
+ const epicList = parseJson(await runCli(["epic", "list", "--json"], root))
238
+ expect(epicList).toHaveLength(1)
239
+ expect(epicList[0].data.tasks).toEqual([{ id: "ship" }])
240
+
241
+ const taskShow = parseJson(
242
+ await runCli(["task", "show", "ship", "--json"], root),
243
+ )
244
+ expect(taskShow.data.phases).toEqual([
245
+ { id: "prepare" },
246
+ { id: "implement" },
247
+ { id: "release", dependsOn: ["prepare", "implement"] },
248
+ ])
249
+ const plainTaskShow = await runCli(["task", "show", "ship"], root)
250
+ expect(plainTaskShow.exitCode).toBe(0)
251
+ expect(plainTaskShow.stdout).toContain(
252
+ "ticketUrl: https://example.com/tasks/ship",
253
+ )
254
+ expect(plainTaskShow.stderr).toBe("")
255
+
256
+ const phaseList = parseJson(
257
+ await runCli(["phase", "list", "ship", "--json"], root),
258
+ )
259
+ expect(phaseList.map((phase: { id: string }) => phase.id)).toEqual([
260
+ "implement",
261
+ "prepare",
262
+ "release",
263
+ ])
264
+
265
+ const phaseShow = parseJson(
266
+ await runCli(["phase", "show", "ship", "release", "--json"], root),
267
+ )
268
+ expect(phaseShow).toMatchObject({ taskId: "ship", id: "release" })
269
+
270
+ const status = parseJson(await runCli(["status", "--json"], root))
271
+ expect(status).toMatchObject({
272
+ root: workbaseRoot,
273
+ epicCount: 1,
274
+ taskCount: 1,
275
+ phaseCount: 3,
276
+ valid: true,
277
+ issues: [],
278
+ })
279
+
280
+ const validation = parseJson(await runCli(["validate", "--json"], root))
281
+ expect(validation).toEqual({
282
+ root: workbaseRoot,
283
+ issues: [],
284
+ epicCount: 1,
285
+ taskCount: 1,
286
+ phaseCount: 3,
287
+ valid: true,
288
+ })
289
+ }, 30_000)
290
+ })