@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
@@ -0,0 +1,357 @@
1
+ import { Data, Effect } from "effect"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { FileSystemService } from "./FileSystemService"
4
+ import { WorkbaseService } from "./WorkbaseService"
5
+ import { TaskService } from "./TaskService"
6
+ import { PhaseService } from "./PhaseService"
7
+ import {
8
+ expandWorktreeCreateCommand,
9
+ worktreeCommandEnvironment,
10
+ } from "../workbase/worktree-command"
11
+ import type { RepositoryReference } from "../workbase/schemas"
12
+
13
+ class WorktreeError extends Data.TaggedError("WorktreeError")<{
14
+ readonly message: string
15
+ }> {}
16
+
17
+ interface ExecutionWorkspace {
18
+ readonly root: string
19
+ readonly taskPath: string
20
+ readonly phasePath: string | null
21
+ readonly codePath: string
22
+ readonly writablePath: string
23
+ readonly repo: string
24
+ readonly repos: readonly RepositoryReference[]
25
+ }
26
+
27
+ interface GitWorktree {
28
+ readonly path: string
29
+ readonly head?: string
30
+ readonly branch?: string
31
+ }
32
+
33
+ const parseWorktreeList = (output: string): readonly GitWorktree[] => {
34
+ const worktrees: GitWorktree[] = []
35
+ let current: { path: string; head?: string; branch?: string } | undefined
36
+
37
+ for (const field of output.split("\0")) {
38
+ if (field.startsWith("worktree ")) {
39
+ if (current) worktrees.push(current)
40
+ current = { path: field.slice("worktree ".length) }
41
+ } else if (current && field.startsWith("HEAD ")) {
42
+ current.head = field.slice("HEAD ".length)
43
+ } else if (current && field.startsWith("branch ")) {
44
+ current.branch = field.slice("branch ".length)
45
+ }
46
+ }
47
+ if (current) worktrees.push(current)
48
+
49
+ return worktrees
50
+ }
51
+
52
+ export class WorktreeService extends Effect.Service<WorktreeService>()(
53
+ "WorktreeService",
54
+ {
55
+ sync: () => ({
56
+ materialize: (
57
+ taskId: string,
58
+ phaseId?: string,
59
+ startPath: string = process.cwd(),
60
+ ) =>
61
+ Effect.gen(function* () {
62
+ const fs = yield* FileSystemService
63
+ const workbase = yield* WorkbaseService
64
+ const tasks = yield* TaskService
65
+ const phases = yield* PhaseService
66
+ const { root, config } = yield* workbase.loadConfig(startPath)
67
+ const report = yield* workbase.validate(root)
68
+ const ownershipIssue = report.issues.find((issue) =>
69
+ issue.message.startsWith("Writable branch "),
70
+ )
71
+ if (ownershipIssue) {
72
+ return yield* new WorktreeError({
73
+ message: `${ownershipIssue.path}: ${ownershipIssue.message}`,
74
+ })
75
+ }
76
+ const task = yield* tasks.show(taskId, root)
77
+
78
+ let execution: {
79
+ repo: string
80
+ repos?: readonly RepositoryReference[]
81
+ branch: string
82
+ base: string
83
+ }
84
+ let phasePath: string | null = null
85
+ let codePath: string
86
+ if ("phases" in task.data) {
87
+ if (!phaseId) {
88
+ return yield* new WorktreeError({
89
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
90
+ })
91
+ }
92
+ const phase = yield* phases.show(taskId, phaseId, root)
93
+ execution = phase.data
94
+ phasePath = phase.path
95
+ codePath = join(dirname(phase.path), "code")
96
+ } else {
97
+ if (phaseId) {
98
+ return yield* new WorktreeError({
99
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
100
+ })
101
+ }
102
+ execution = task.data
103
+ codePath = join(dirname(task.path), "code")
104
+ }
105
+
106
+ yield* fs.createDirectory(codePath)
107
+ const checkouts: readonly (
108
+ | { readonly repo: string; readonly branch: string }
109
+ | RepositoryReference
110
+ )[] = [
111
+ { repo: execution.repo, branch: execution.branch },
112
+ ...(execution.repos ?? []),
113
+ ]
114
+ for (const checkout of checkouts) {
115
+ const alias = checkout.repo
116
+ const repositoryPath = join(root, "repos", alias)
117
+ const checkoutPath = join(codePath, alias)
118
+ if (!(yield* fs.exists(repositoryPath))) {
119
+ return yield* new WorktreeError({
120
+ message: `Repository alias '${alias}' does not exist`,
121
+ })
122
+ }
123
+
124
+ const remote = yield* fs.runCommand(
125
+ ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
126
+ { captureOutput: true },
127
+ )
128
+ if (remote.exitCode === 0) {
129
+ const fetch = yield* fs.runCommand(
130
+ ["git", "-C", repositoryPath, "fetch", "origin"],
131
+ { captureOutput: true },
132
+ )
133
+ if (fetch.exitCode !== 0) {
134
+ return yield* new WorktreeError({
135
+ message: `Failed to fetch '${alias}': ${fetch.stderr}`,
136
+ })
137
+ }
138
+ }
139
+
140
+ const listed = yield* fs.runCommand(
141
+ [
142
+ "git",
143
+ "-C",
144
+ repositoryPath,
145
+ "worktree",
146
+ "list",
147
+ "--porcelain",
148
+ "-z",
149
+ ],
150
+ { captureOutput: true },
151
+ )
152
+ if (listed.exitCode !== 0) {
153
+ return yield* new WorktreeError({
154
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
155
+ })
156
+ }
157
+ const canonicalCodePath = yield* fs.realPath(codePath)
158
+ const canonicalCheckoutPath = join(canonicalCodePath, alias)
159
+ const worktrees: GitWorktree[] = []
160
+ for (const worktree of parseWorktreeList(listed.stdout)) {
161
+ worktrees.push({
162
+ ...worktree,
163
+ path: (yield* fs.exists(worktree.path))
164
+ ? yield* fs.realPath(worktree.path)
165
+ : resolve(worktree.path),
166
+ })
167
+ }
168
+ const registeredAtPath = worktrees.find(
169
+ (worktree) => worktree.path === canonicalCheckoutPath,
170
+ )
171
+
172
+ if ("branch" in checkout) {
173
+ const branchRef = `refs/heads/${checkout.branch}`
174
+ const branchWorktree = worktrees.find(
175
+ (worktree) => worktree.branch === branchRef,
176
+ )
177
+ if (
178
+ branchWorktree &&
179
+ branchWorktree.path !== canonicalCheckoutPath
180
+ ) {
181
+ return yield* new WorktreeError({
182
+ message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
183
+ })
184
+ }
185
+ if (yield* fs.isDirectory(checkoutPath)) {
186
+ if (registeredAtPath?.branch === branchRef) continue
187
+ return yield* new WorktreeError({
188
+ message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
189
+ })
190
+ }
191
+ if (registeredAtPath) {
192
+ return yield* new WorktreeError({
193
+ message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
194
+ })
195
+ }
196
+
197
+ let args: string[]
198
+ let env: Record<string, string> | undefined
199
+ if (config.worktreeCreateCommand) {
200
+ const variables = {
201
+ repo: repositoryPath,
202
+ worktree: checkoutPath,
203
+ branch: checkout.branch,
204
+ base: execution.base,
205
+ }
206
+ try {
207
+ args = expandWorktreeCreateCommand(
208
+ config.worktreeCreateCommand,
209
+ variables,
210
+ )
211
+ } catch (cause) {
212
+ return yield* new WorktreeError({
213
+ message:
214
+ cause instanceof Error
215
+ ? cause.message
216
+ : "Invalid worktreeCreateCommand",
217
+ })
218
+ }
219
+ env = worktreeCommandEnvironment(variables)
220
+ } else {
221
+ const branchExists = yield* fs.runCommand(
222
+ [
223
+ "git",
224
+ "-C",
225
+ repositoryPath,
226
+ "show-ref",
227
+ "--verify",
228
+ branchRef,
229
+ ],
230
+ { captureOutput: true },
231
+ )
232
+ if (branchExists.exitCode !== 0) {
233
+ const createBranch = yield* fs.runCommand(
234
+ [
235
+ "git",
236
+ "-C",
237
+ repositoryPath,
238
+ "branch",
239
+ checkout.branch,
240
+ execution.base,
241
+ ],
242
+ { captureOutput: true },
243
+ )
244
+ if (createBranch.exitCode !== 0) {
245
+ return yield* new WorktreeError({
246
+ message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
247
+ })
248
+ }
249
+ }
250
+ args = [
251
+ "git",
252
+ "-C",
253
+ repositoryPath,
254
+ "worktree",
255
+ "add",
256
+ checkoutPath,
257
+ checkout.branch,
258
+ ]
259
+ }
260
+
261
+ const result = yield* fs.runCommand(args, {
262
+ cwd: repositoryPath,
263
+ captureOutput: true,
264
+ env,
265
+ })
266
+ if (result.exitCode !== 0) {
267
+ return yield* new WorktreeError({
268
+ message: `Failed to create worktree for '${alias}': ${result.stderr}`,
269
+ })
270
+ }
271
+ if (!(yield* fs.isDirectory(checkoutPath))) {
272
+ return yield* new WorktreeError({
273
+ message: `Worktree command did not create ${checkoutPath}`,
274
+ })
275
+ }
276
+ } else {
277
+ const resolvedRef = yield* fs.runCommand(
278
+ [
279
+ "git",
280
+ "-C",
281
+ repositoryPath,
282
+ "rev-parse",
283
+ "--verify",
284
+ `${checkout.ref}^{commit}`,
285
+ ],
286
+ { captureOutput: true },
287
+ )
288
+ if (resolvedRef.exitCode !== 0) {
289
+ return yield* new WorktreeError({
290
+ message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
291
+ })
292
+ }
293
+ const commit = resolvedRef.stdout.trim()
294
+ if (yield* fs.isDirectory(checkoutPath)) {
295
+ if (!registeredAtPath) {
296
+ return yield* new WorktreeError({
297
+ message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
298
+ })
299
+ }
300
+ if (registeredAtPath.branch) {
301
+ return yield* new WorktreeError({
302
+ message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
303
+ })
304
+ }
305
+ const currentHead = yield* fs.runCommand(
306
+ ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
307
+ { captureOutput: true },
308
+ )
309
+ if (
310
+ currentHead.exitCode === 0 &&
311
+ currentHead.stdout.trim() === commit
312
+ ) {
313
+ continue
314
+ }
315
+ return yield* new WorktreeError({
316
+ message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
317
+ })
318
+ }
319
+ if (registeredAtPath) {
320
+ return yield* new WorktreeError({
321
+ message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
322
+ })
323
+ }
324
+ const result = yield* fs.runCommand(
325
+ [
326
+ "git",
327
+ "-C",
328
+ repositoryPath,
329
+ "worktree",
330
+ "add",
331
+ "--detach",
332
+ checkoutPath,
333
+ commit,
334
+ ],
335
+ { captureOutput: true },
336
+ )
337
+ if (result.exitCode !== 0) {
338
+ return yield* new WorktreeError({
339
+ message: `Failed to create worktree for '${alias}': ${result.stderr}`,
340
+ })
341
+ }
342
+ }
343
+ }
344
+
345
+ return {
346
+ root,
347
+ taskPath: task.path,
348
+ phasePath,
349
+ codePath,
350
+ writablePath: join(codePath, execution.repo),
351
+ repo: execution.repo,
352
+ repos: execution.repos ?? [],
353
+ } satisfies ExecutionWorkspace
354
+ }),
355
+ }),
356
+ },
357
+ ) {}