@markjaquith/agency 1.11.0 → 2.0.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 (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -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 +172 -419
  24. package/src/commands/work.ts +47 -166
  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/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -518
  73. package/src/commands/push.ts +0 -434
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1183
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -1,1314 +1,112 @@
1
- import { resolve, join, dirname } from "path"
2
1
  import { Effect } from "effect"
3
2
  import type { BaseCommandOptions } from "../utils/command"
4
- import { GitService } from "../services/GitService"
5
- import { ConfigService } from "../services/ConfigService"
6
- import { FileSystemService } from "../services/FileSystemService"
7
- import { PromptService } from "../services/PromptService"
8
- import { TemplateService } from "../services/TemplateService"
9
- import { OpencodeService } from "../services/OpencodeService"
10
- import { ClaudeService } from "../services/ClaudeService"
11
- import { AgencyMetadataService } from "../services/AgencyMetadataService"
12
- import { FormatterService } from "../services/FormatterService"
13
- import { initializeManagedFiles, writeAgencyMetadata } from "../types"
14
- import { RepositoryNotInitializedError } from "../errors"
15
- import highlight, { done, info, plural } from "../utils/colors"
16
- import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
17
- import {
18
- makeEmitBranchName,
19
- extractCleanBranch,
20
- makeSourceBranchName,
21
- } from "../utils/pr-branch"
22
- import { getTopLevelDir, dirToGlobPattern } from "../utils/glob"
23
- import { AGENCY_REMOVE_COMMIT } from "../constants"
24
- import { emitCore } from "./emit"
25
- import { withBranchProtection } from "../utils/effect"
3
+ import { TaskService } from "../services/TaskService"
4
+ import { createLoggers } from "../utils/effect"
5
+ import { parseRepositoryReferences } from "../workbase/repository-reference"
26
6
 
27
7
  interface TaskOptions extends BaseCommandOptions {
28
- path?: string
29
- task?: string
30
- emit?: string
31
- branch?: string // Deprecated: use emit instead
32
- from?: string
33
- fromCurrent?: boolean
34
- continue?: boolean
35
- squash?: boolean
8
+ readonly subcommand?: string
9
+ readonly args: readonly string[]
10
+ readonly ticketUrl?: string
11
+ readonly description?: string
12
+ readonly epic?: string
13
+ readonly repo?: string
14
+ readonly references?: readonly string[]
15
+ readonly branch?: string
16
+ readonly base?: string
17
+ readonly multiPhase?: boolean
18
+ readonly json?: boolean
36
19
  }
37
20
 
38
- interface TaskEditOptions extends BaseCommandOptions {}
39
-
40
- /**
41
- * Continue a task by creating a new branch with the same agency files.
42
- * This is useful after a PR is merged and you want to continue working on the task.
43
- *
44
- * With --squash: emits the old branch, squash-merges the emitted commits into the new
45
- * branch as a single commit, and creates a fresh TASK.md with a new task description.
46
- */
47
- const taskContinue = (options: TaskOptions) =>
21
+ export const task = (options: TaskOptions) =>
48
22
  Effect.gen(function* () {
49
- const { silent = false, squash = false } = options
50
- const { log, verboseLog } = createLoggers(options)
51
-
52
- const git = yield* GitService
53
- const configService = yield* ConfigService
54
- const fs = yield* FileSystemService
55
- const promptService = yield* PromptService
56
-
57
- // Determine target path
58
- let targetPath: string
59
-
60
- if (options.path) {
61
- targetPath = resolve(options.path)
62
- const isRoot = yield* git.isGitRoot(targetPath)
63
- if (!isRoot) {
64
- return yield* Effect.fail(
65
- new Error(
66
- "The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
67
- ),
68
- )
69
- }
70
- } else {
71
- const isRepo = yield* git.isInsideGitRepo(process.cwd())
72
- if (!isRepo) {
73
- return yield* Effect.fail(
74
- new Error(
75
- "Not in a git repository. Please run this command inside a git repo.",
76
- ),
77
- )
78
- }
79
- targetPath = yield* git.getGitRoot(process.cwd())
80
- }
81
-
82
- // Check if initialized (has template in git config)
83
- const templateName = yield* getTemplateName(targetPath)
84
- if (!templateName) {
85
- return yield* Effect.fail(new RepositoryNotInitializedError())
86
- }
87
-
88
- const currentBranch = yield* git.getCurrentBranch(targetPath)
89
- verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
90
-
91
- // Load agency config for branch patterns
92
- const config = yield* configService.loadConfig()
93
-
94
- // Verify we're on an agency source branch by checking if agency.json exists
95
- const agencyJsonPath = resolve(targetPath, "agency.json")
96
- const hasAgencyJson = yield* fs.exists(agencyJsonPath)
97
-
98
- if (!hasAgencyJson) {
99
- return yield* Effect.fail(
100
- new Error(
101
- `No agency.json found on current branch ${highlight.branch(currentBranch)}.\n` +
102
- `The --continue flag requires you to be on an agency source branch with existing agency files.`,
103
- ),
104
- )
105
- }
106
-
107
- // Read the existing agency.json to get metadata
108
- const existingMetadata = yield* Effect.gen(function* () {
109
- const service = yield* AgencyMetadataService
110
- return yield* service.readFromDisk(targetPath)
111
- }).pipe(Effect.provide(AgencyMetadataService.Default))
112
-
113
- if (!existingMetadata) {
114
- return yield* Effect.fail(
115
- new Error(
116
- `Failed to read agency.json on branch ${highlight.branch(currentBranch)}.`,
117
- ),
118
- )
119
- }
120
-
121
- verboseLog(
122
- `Existing metadata: ${JSON.stringify(existingMetadata, null, 2)}`,
123
- )
124
-
125
- // Get the list of agency files to copy
126
- const filesToCopy = [
127
- "agency.json",
128
- "TASK.md",
129
- "AGENCY.md",
130
- ...existingMetadata.injectedFiles,
131
- ]
132
- verboseLog(`Files to copy: ${filesToCopy.join(", ")}`)
133
-
134
- // Read all the existing files content before switching branches
135
- const fileContents = new Map<string, string>()
136
- for (const file of filesToCopy) {
137
- // When squashing, skip reading TASK.md — we'll generate a fresh one
138
- if (squash && file === "TASK.md") {
139
- verboseLog("Skipping TASK.md read (will create fresh for --squash)")
140
- continue
141
- }
142
- const filePath = resolve(targetPath, file)
143
- const exists = yield* fs.exists(filePath)
144
- if (exists) {
145
- const content = yield* fs.readFile(filePath)
146
- fileContents.set(file, content)
147
- verboseLog(`Read ${file} (${content.length} bytes)`)
148
- } else {
149
- verboseLog(`File ${file} not found, skipping`)
150
- }
151
- }
152
-
153
- // When squashing, prompt for fresh task description before switching branches
154
- let freshTaskDescription: string | undefined
155
- if (squash) {
156
- if (options.task) {
157
- freshTaskDescription = options.task
158
- verboseLog(`Using task from option: ${freshTaskDescription}`)
159
- } else if (!silent) {
160
- freshTaskDescription = yield* promptService.prompt(
161
- "New task description: ",
162
- )
163
- if (!freshTaskDescription) {
164
- log(
165
- info(
166
- "Skipping task description (TASK.md will use default placeholder)",
167
- ),
23
+ const tasks = yield* TaskService
24
+ const { log } = createLoggers(options)
25
+ const cwd = options.cwd ?? process.cwd()
26
+
27
+ switch (options.subcommand) {
28
+ case "create": {
29
+ const id = options.args[0]
30
+ if (!id || !options.ticketUrl) {
31
+ return yield* Effect.fail(
32
+ new Error("Usage: agency task create <id> --ticket-url <url>"),
168
33
  )
169
34
  }
170
- }
171
- }
172
-
173
- // Prompt for new branch name if not provided
174
- let branchName = options.emit || options.branch
175
- if (!branchName) {
176
- if (silent) {
177
- return yield* Effect.fail(
178
- new Error(
179
- "Branch name is required with --continue in silent mode. Use --emit to specify one.",
180
- ),
181
- )
182
- }
183
- branchName = yield* promptService.prompt("New branch name: ")
184
- if (!branchName) {
185
- return yield* Effect.fail(
186
- new Error("Branch name is required to continue the task."),
187
- )
188
- }
189
- }
190
-
191
- // Apply source pattern to get the full source branch name
192
- const sourceBranchName = makeSourceBranchName(
193
- branchName,
194
- config.sourceBranchPattern,
195
- )
196
-
197
- // Check if the new branch already exists
198
- const branchExists = yield* git.branchExists(targetPath, sourceBranchName)
199
- if (branchExists) {
200
- return yield* Effect.fail(
201
- new Error(
202
- `Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
203
- `Choose a different branch name.`,
204
- ),
205
- )
206
- }
207
-
208
- // When squashing, emit the old source branch first (before switching away)
209
- // This creates a clean emit branch with backpack files stripped out
210
- let oldEmitBranchName: string | undefined
211
- if (squash) {
212
- const cleanFromCurrent = extractCleanBranch(
213
- currentBranch,
214
- config.sourceBranchPattern,
215
- )
216
- oldEmitBranchName = cleanFromCurrent
217
- ? makeEmitBranchName(cleanFromCurrent, config.emitBranch)
218
- : existingMetadata.emitBranch || undefined
219
-
220
- if (!oldEmitBranchName) {
221
- return yield* Effect.fail(
222
- new Error(
223
- `Could not determine emit branch name for ${highlight.branch(currentBranch)}.\n` +
224
- `Ensure agency.json has an emitBranch field or the branch follows the source pattern.`,
225
- ),
226
- )
227
- }
228
-
229
- verboseLog(
230
- `Emitting ${highlight.branch(currentBranch)} → ${highlight.branch(oldEmitBranchName)}`,
231
- )
232
-
233
- yield* withBranchProtection(
234
- targetPath,
235
- emitCore(targetPath, {
236
- silent: true,
237
- verbose: options.verbose,
238
- emit: oldEmitBranchName,
239
- }),
240
- )
241
-
242
- log(done(`Emitted ${highlight.branch(oldEmitBranchName)} for squash`))
243
- }
244
-
245
- // Determine base branch to branch from
246
- let baseBranchToBranchFrom: string | undefined
247
-
248
- if (options.from) {
249
- baseBranchToBranchFrom = options.from
250
- const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
251
- if (!exists) {
252
- return yield* Effect.fail(
253
- new Error(
254
- `Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
255
- ),
256
- )
257
- }
258
- } else {
259
- // Default: branch from main upstream branch (preferring remote)
260
- baseBranchToBranchFrom =
261
- (yield* git.resolveMainBranch(targetPath)) || undefined
262
- }
263
-
264
- if (!baseBranchToBranchFrom) {
265
- return yield* Effect.fail(
266
- new Error(
267
- "Could not determine base branch. Use --from to specify one.",
268
- ),
269
- )
270
- }
271
-
272
- verboseLog(
273
- `Creating new branch ${highlight.branch(sourceBranchName)} from ${highlight.branch(baseBranchToBranchFrom)}`,
274
- )
275
-
276
- // Create the new branch from the base branch
277
- yield* git.createBranch(
278
- sourceBranchName,
279
- targetPath,
280
- baseBranchToBranchFrom,
281
- )
282
- // Calculate the emit branch name for display
283
- const cleanBranchForDisplay =
284
- extractCleanBranch(sourceBranchName, config.sourceBranchPattern) ||
285
- sourceBranchName
286
- const emitBranchForDisplay = makeEmitBranchName(
287
- cleanBranchForDisplay,
288
- config.emitBranch,
289
- )
290
-
291
- log(
292
- info(
293
- `(${highlight.branch(baseBranchToBranchFrom)}) ${highlight.branch(sourceBranchName)} → ${highlight.branch(emitBranchForDisplay)}`,
294
- ),
295
- )
296
- log(done(`Created and switched to ${highlight.branch(sourceBranchName)}`))
297
-
298
- // When squashing, merge the emitted commits as a single squash commit
299
- if (squash && oldEmitBranchName) {
300
- verboseLog(
301
- `Squash-merging ${highlight.branch(oldEmitBranchName)} into ${highlight.branch(sourceBranchName)}`,
302
- )
303
-
304
- // Squash merge the emitted (clean) branch
305
- const mergeResult = yield* git
306
- .merge(targetPath, oldEmitBranchName, { squash: true })
307
- .pipe(
308
- Effect.catchAll((err) => {
309
- return Effect.fail(
310
- new Error(
311
- `Squash merge of ${oldEmitBranchName} failed.\n` +
312
- `This typically means there are conflicts with the base branch.\n` +
313
- `Try rebasing the old branch first with 'agency rebase', then retry.\n` +
314
- `Details: ${err}`,
315
- ),
316
- )
317
- }),
318
- )
319
-
320
- // Check if the squash merge produced any staged changes
321
- const statusResult = yield* git
322
- .runGitCommand(["git", "diff", "--cached", "--quiet"], targetPath)
323
- .pipe(
324
- Effect.catchAll(() =>
325
- Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }),
326
- ),
327
- )
328
-
329
- if (statusResult.exitCode === 0) {
330
- // No changes — the old branch had no code commits (only agency files)
331
- verboseLog(
332
- "Squash merge produced no changes (old branch had no code commits), skipping squash commit",
333
- )
334
- } else {
335
- // There are staged changes — commit them
336
- yield* git.gitCommit(
337
- `squash: prior work from ${oldEmitBranchName}`,
338
- targetPath,
339
- { noVerify: true },
340
- )
35
+ const record = yield* tasks.create(
36
+ {
37
+ id,
38
+ ticketUrl: options.ticketUrl,
39
+ description: options.description,
40
+ epic: options.epic,
41
+ multiPhase: options.multiPhase,
42
+ repo: options.repo,
43
+ repos: parseRepositoryReferences(options.references),
44
+ branch: options.branch,
45
+ base: options.base,
46
+ },
47
+ cwd,
48
+ )
49
+ const { content: _, ...output } = record
341
50
  log(
342
- done(
343
- `Squashed prior work from ${highlight.branch(oldEmitBranchName)}`,
344
- ),
345
- )
346
- }
347
- }
348
-
349
- // Calculate the new emit branch name
350
- const newEmitBranchName = makeEmitBranchName(branchName, config.emitBranch)
351
- verboseLog(`New emit branch name: ${newEmitBranchName}`)
352
-
353
- // When squashing, generate fresh TASK.md content
354
- if (squash) {
355
- const taskTemplate = `{task}\n\n## Tasks\n\n- [ ] Populate this list with tasks\n`
356
- const taskContent = freshTaskDescription
357
- ? taskTemplate.replace("{task}", freshTaskDescription)
358
- : taskTemplate
359
- fileContents.set("TASK.md", taskContent)
360
- }
361
-
362
- // Write all the files to the new branch
363
- const createdFiles: string[] = []
364
-
365
- for (const [file, content] of fileContents) {
366
- const filePath = resolve(targetPath, file)
367
-
368
- // Ensure parent directory exists
369
- const parentDir = resolve(filePath, "..")
370
- const parentExists = yield* fs.exists(parentDir)
371
- if (!parentExists) {
372
- yield* fs.runCommand(["mkdir", "-p", parentDir])
373
- }
374
-
375
- let fileContent = content
376
-
377
- // Update emitBranch in agency.json
378
- if (file === "agency.json") {
379
- const metadata = JSON.parse(content)
380
- metadata.emitBranch = newEmitBranchName
381
- metadata.createdAt = new Date().toISOString()
382
- fileContent = JSON.stringify(metadata, null, 2) + "\n"
383
- verboseLog(
384
- `Updated agency.json with new emitBranch: ${newEmitBranchName}`,
385
- )
386
- }
387
-
388
- // Update emitBranch in opencode.json (if it has one)
389
- if (file === "opencode.json" || file.endsWith("/opencode.json")) {
390
- try {
391
- const opencodeConfig = JSON.parse(content)
392
- if (opencodeConfig.emitBranch) {
393
- opencodeConfig.emitBranch = newEmitBranchName
394
- fileContent = JSON.stringify(opencodeConfig, null, "\t") + "\n"
395
- verboseLog(
396
- `Updated ${file} with new emitBranch: ${newEmitBranchName}`,
397
- )
398
- }
399
- } catch {
400
- // If we can't parse it, just copy as-is
401
- }
402
- }
403
-
404
- yield* fs.writeFile(filePath, fileContent)
405
- createdFiles.push(file)
406
- log(done(`Created ${highlight.file(file)}`))
407
- }
408
-
409
- // Format created files using project's formatter (prettier/oxfmt)
410
- const formatterService = yield* FormatterService
411
- yield* formatterService.formatFiles(targetPath, createdFiles, verboseLog)
412
-
413
- // Git add and commit the created files
414
- if (createdFiles.length > 0) {
415
- yield* Effect.gen(function* () {
416
- yield* git.gitAdd(createdFiles, targetPath)
417
- // Format: chore: agency task --continue (baseBranch) originalSource => newSource => newEmit
418
- const continueFlag = squash
419
- ? "agency task --continue --squash"
420
- : "agency task --continue"
421
- const commitMessage = `chore: ${continueFlag} (${baseBranchToBranchFrom}) ${currentBranch} → ${sourceBranchName} → ${newEmitBranchName}`
422
- yield* git.gitCommit(commitMessage, targetPath, {
423
- noVerify: true,
424
- })
425
- verboseLog(
426
- `Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
427
- )
428
- }).pipe(
429
- Effect.catchAll((err) => {
430
- verboseLog(`Failed to commit: ${err}`)
431
- return Effect.void
432
- }),
433
- )
434
- }
435
-
436
- log(
437
- info(
438
- squash
439
- ? `Continued task with squash and ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`
440
- : `Continued task with ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`,
441
- ),
442
- )
443
- })
444
-
445
- export const task = (options: TaskOptions = {}) =>
446
- Effect.gen(function* () {
447
- const { silent = false, verbose = false } = options
448
- const { log, verboseLog } = createLoggers(options)
449
-
450
- // Validate --squash requires --continue
451
- if (options.squash && !options.continue) {
452
- return yield* Effect.fail(
453
- new Error("The --squash flag can only be used with --continue."),
454
- )
455
- }
456
-
457
- // Handle --continue flag
458
- if (options.continue) {
459
- return yield* taskContinue(options)
460
- }
461
-
462
- const git = yield* GitService
463
- const configService = yield* ConfigService
464
- const fs = yield* FileSystemService
465
- const promptService = yield* PromptService
466
- const templateService = yield* TemplateService
467
- const opencodeService = yield* OpencodeService
468
- const claudeService = yield* ClaudeService
469
-
470
- // Determine target path
471
- let targetPath: string
472
-
473
- if (options.path) {
474
- // If path is provided, validate it's a git repository root
475
- targetPath = resolve(options.path)
476
-
477
- const isRoot = yield* git.isGitRoot(targetPath)
478
- if (!isRoot) {
479
- return yield* Effect.fail(
480
- new Error(
481
- "The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
482
- ),
483
- )
484
- }
485
- } else {
486
- // If no path provided, use git root of current directory
487
- const isRepo = yield* git.isInsideGitRepo(process.cwd())
488
- if (!isRepo) {
489
- return yield* Effect.fail(
490
- new Error(
491
- "Not in a git repository. Please run this command inside a git repo.",
492
- ),
493
- )
494
- }
495
-
496
- targetPath = yield* git.getGitRoot(process.cwd())
497
- }
498
-
499
- const createdFiles: string[] = []
500
- const injectedFiles: string[] = []
501
- // Track directories that we create during this task
502
- // These will be converted to glob patterns for filtering
503
- const createdDirs = new Set<string>()
504
-
505
- // Check if initialized (has template in git config)
506
- const templateName = yield* getTemplateName(targetPath)
507
-
508
- if (!templateName) {
509
- return yield* Effect.fail(new RepositoryNotInitializedError())
510
- }
511
-
512
- verboseLog(`Using template: ${templateName}`)
513
-
514
- // Define path to TASK.md for later checks
515
- const taskMdPath = resolve(targetPath, "TASK.md")
516
-
517
- // Check if we're on a feature branch
518
- const currentBranch = yield* git.getCurrentBranch(targetPath)
519
- verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
520
- const isFeature = yield* git.isFeatureBranch(currentBranch, targetPath)
521
- verboseLog(`Is feature branch: ${isFeature}`)
522
-
523
- // Check if we're on an agency source branch (has agency.json with backpacked files)
524
- const agencyJsonPath = resolve(targetPath, "agency.json")
525
- const hasAgencyJson = yield* fs.exists(agencyJsonPath)
526
- verboseLog(`Has agency.json: ${hasAgencyJson}`)
527
-
528
- // Determine base branch to branch from
529
- let baseBranchToBranchFrom: string | undefined
530
-
531
- // Handle --from and --from-current flags
532
- if (options.from && options.fromCurrent) {
533
- return yield* Effect.fail(
534
- new Error("Cannot use both --from and --from-current flags together."),
535
- )
536
- }
537
-
538
- if (options.fromCurrent) {
539
- // Branch from current branch
540
- baseBranchToBranchFrom = currentBranch
541
- verboseLog(
542
- `Using current branch as base: ${highlight.branch(currentBranch)}`,
543
- )
544
- } else if (options.from) {
545
- // Branch from specified branch
546
- baseBranchToBranchFrom = options.from
547
- verboseLog(
548
- `Using specified branch as base: ${highlight.branch(options.from)}`,
549
- )
550
-
551
- // Verify the specified branch exists
552
- const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
553
- if (!exists) {
554
- return yield* Effect.fail(
555
- new Error(
556
- `Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
557
- ),
51
+ options.json
52
+ ? JSON.stringify(output, null, 2)
53
+ : `Created task '${record.id}'`,
558
54
  )
55
+ return
559
56
  }
560
- } else {
561
- // Default: fetch and use latest main upstream branch
562
- // First, determine the remote to fetch from
563
- const remote =
564
- (yield* git.getRemoteConfig(targetPath)) ||
565
- (yield* git.findDefaultRemote(targetPath))
566
-
567
- if (remote) {
568
- // Get just the branch name (e.g., "main") to fetch only that branch
569
- // This is much faster than fetching all branches in large repos
570
- const mainBranchName = yield* git.getMainBranchName(targetPath)
571
-
572
- if (mainBranchName) {
57
+ case "list": {
58
+ const records = yield* tasks.list(cwd)
59
+ if (options.json) {
573
60
  log(
574
- info(
575
- `Fetching latest from ${highlight.branch(`${remote}/${mainBranchName}`)}...`,
61
+ JSON.stringify(
62
+ records.map(({ content: _, ...record }) => record),
63
+ null,
64
+ 2,
576
65
  ),
577
66
  )
578
- verboseLog(`Fetching ${mainBranchName} from remote: ${remote}`)
579
- yield* git.fetch(targetPath, remote, mainBranchName).pipe(
580
- Effect.catchAll((err) => {
581
- verboseLog(
582
- `Failed to fetch ${mainBranchName} from ${remote}: ${err}`,
583
- )
584
- return Effect.void
585
- }),
586
- )
587
67
  } else {
588
- // Fallback: fetch all branches if we can't determine the main branch
589
- log(info(`Fetching latest from ${highlight.branch(remote)}...`))
590
- verboseLog(`Fetching all branches from remote: ${remote}`)
591
- yield* git.fetch(targetPath, remote).pipe(
592
- Effect.catchAll((err) => {
593
- verboseLog(`Failed to fetch from ${remote}: ${err}`)
594
- return Effect.void
595
- }),
596
- )
597
- }
598
- }
599
-
600
- // Now resolve the main branch (preferring remote)
601
- baseBranchToBranchFrom =
602
- (yield* git.resolveMainBranch(targetPath)) || undefined
603
-
604
- if (baseBranchToBranchFrom) {
605
- verboseLog(
606
- `Auto-detected base branch: ${highlight.branch(baseBranchToBranchFrom)}`,
607
- )
608
- }
609
- }
610
-
611
- // Load config early for branch pattern operations
612
- const config = yield* configService.loadConfig()
613
-
614
- // Check if the base branch is an agency source branch
615
- // If so, we need to emit it first and use the emit branch instead
616
- // Skip this check if using --from-current (we want to stay on current branch, not branch from it)
617
- if (baseBranchToBranchFrom && !options.fromCurrent) {
618
- const cleanFromBase = extractCleanBranch(
619
- baseBranchToBranchFrom,
620
- config.sourceBranchPattern,
621
- )
622
-
623
- if (cleanFromBase) {
624
- // This is an agency source branch - we need to use its emit branch
625
- verboseLog(
626
- `Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch`,
627
- )
628
-
629
- const emitBranchName = makeEmitBranchName(
630
- cleanFromBase,
631
- config.emitBranch,
632
- )
633
-
634
- // Check if emit branch exists
635
- const emitExists = yield* git.branchExists(targetPath, emitBranchName)
636
-
637
- if (!emitExists) {
638
- return yield* Effect.fail(
639
- new Error(
640
- `Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch, ` +
641
- `but its emit branch ${highlight.branch(emitBranchName)} does not exist.\n` +
642
- `Please run 'agency emit' on ${highlight.branch(baseBranchToBranchFrom)} first, ` +
643
- `or choose a different base branch.`,
644
- ),
645
- )
646
- }
647
-
648
- // Use the emit branch as the base
649
- baseBranchToBranchFrom = emitBranchName
650
- verboseLog(
651
- `Using emit branch as base: ${highlight.branch(baseBranchToBranchFrom)}`,
652
- )
653
- }
654
- }
655
-
656
- // Determine branch name logic
657
- let branchName = options.emit || options.branch
658
-
659
- // Determine if we need a new branch name:
660
- // - With --from-current on a feature branch without agency.json: can stay on current branch
661
- // - All other cases: require a new branch name
662
- const canStayOnCurrentBranch =
663
- options.fromCurrent && isFeature && !hasAgencyJson
664
-
665
- if (!branchName && !canStayOnCurrentBranch) {
666
- if (silent) {
667
- if (hasAgencyJson) {
668
- return yield* Effect.fail(
669
- new Error(
670
- `You're currently on ${highlight.branch(currentBranch)}, which is an agency source branch.\n` +
671
- `Branch name is required when re-importing backpacked files.\n` +
672
- `Use: 'agency task <branch-name>' or 'agency task --continue <branch-name>'`,
673
- ),
674
- )
675
- }
676
- return yield* Effect.fail(
677
- new Error(
678
- `Branch name is required.\n` +
679
- `Use: 'agency task <branch-name>'\n` +
680
- `Or use --from-current to initialize on the current branch.`,
681
- ),
682
- )
683
- }
684
- branchName = yield* promptService.prompt("Branch name: ")
685
- if (!branchName) {
686
- return yield* Effect.fail(new Error("Branch name is required."))
687
- }
688
- verboseLog(`Branch name from prompt: ${branchName}`)
689
- }
690
-
691
- // If we have a branch name, apply source pattern and check if branch exists
692
- let sourceBranchName: string | undefined
693
- if (branchName) {
694
- sourceBranchName = makeSourceBranchName(
695
- branchName,
696
- config.sourceBranchPattern,
697
- )
698
-
699
- const exists = yield* git.branchExists(targetPath, sourceBranchName)
700
- if (exists) {
701
- return yield* Effect.fail(
702
- new Error(
703
- `Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
704
- `Either switch to it first or choose a different branch name.`,
705
- ),
706
- )
707
- }
708
- verboseLog(
709
- `Branch ${sourceBranchName} does not exist (from clean name: ${branchName}), will create it`,
710
- )
711
- }
712
-
713
- // If we're going to create a branch, check if TASK.md will be created and prompt for description first
714
- let taskDescription: string | undefined
715
- if (branchName) {
716
- const taskMdExists = yield* fs.exists(taskMdPath)
717
- if (!taskMdExists) {
718
- if (options.task) {
719
- taskDescription = options.task
720
- verboseLog(`Using task from option: ${taskDescription}`)
721
- } else if (!silent) {
722
- taskDescription = yield* promptService.prompt("Task description: ")
723
- if (!taskDescription) {
724
- log(
725
- info(
726
- "Skipping task description (TASK.md will use default placeholder)",
727
- ),
728
- )
729
- taskDescription = undefined
730
- }
68
+ for (const record of records) log(record.id)
731
69
  }
70
+ return
732
71
  }
733
- }
734
-
735
- if (sourceBranchName) {
736
- yield* createFeatureBranchEffect(
737
- targetPath,
738
- sourceBranchName,
739
- baseBranchToBranchFrom,
740
- silent,
741
- verbose,
742
- )
743
- }
744
-
745
- // Get managed files for later use
746
- const managedFiles = yield* Effect.tryPromise({
747
- try: () => initializeManagedFiles(),
748
- catch: (error) =>
749
- new Error(`Failed to initialize managed files: ${error}`),
750
- })
751
-
752
- // Get template directory (it may or may not exist yet)
753
- const templateDir = yield* templateService.getTemplateDir(templateName)
754
-
755
- // Prompt for task if TASK.md will be created (only if not already prompted earlier)
756
- if (taskDescription === undefined) {
757
- const taskMdExists = yield* fs.exists(taskMdPath)
758
- if (!taskMdExists) {
759
- if (options.task) {
760
- taskDescription = options.task
761
- verboseLog(`Using task from option: ${taskDescription}`)
762
- } else if (!silent) {
763
- taskDescription = yield* promptService.prompt("Task description: ")
764
- if (!taskDescription) {
765
- log(
766
- info(
767
- "Skipping task description (TASK.md will use default placeholder)",
768
- ),
769
- )
770
- taskDescription = undefined
771
- }
772
- }
773
- }
774
- }
775
-
776
- // Build list of files to create, combining managed files with any additional template files
777
- const filesToCreate = new Map<string, string>() // fileName -> content source
778
-
779
- // Start with all managed files (these should always be created)
780
- for (const managedFile of managedFiles) {
781
- filesToCreate.set(managedFile.name, "default")
782
- }
783
-
784
- // Discover all files from the template directory
785
- const templateFiles = yield* discoverTemplateFiles(templateDir, verboseLog)
786
- for (const relativePath of templateFiles) {
787
- filesToCreate.set(relativePath, "template")
788
- }
789
-
790
- verboseLog(
791
- `Discovered ${templateFiles.length} files in template: ${templateFiles.join(", ")}`,
792
- )
793
-
794
- // Check if opencode.json or opencode.jsonc already exists before processing files
795
- const existingOpencodeInfo = yield* opencodeService
796
- .detectOpencodeFile(targetPath)
797
- .pipe(Effect.catchAll(() => Effect.succeed(null)))
798
-
799
- // Process each file to create
800
- for (const [fileName, source] of filesToCreate) {
801
- const targetFilePath = resolve(targetPath, fileName)
802
-
803
- // Special handling for opencode.json if opencode.json/jsonc already exists
804
- if (fileName === "opencode.json" && existingOpencodeInfo) {
805
- // Merge with existing file instead of creating new one
806
- verboseLog(
807
- `Found existing ${existingOpencodeInfo.relativePath}, will merge instructions`,
72
+ case "show": {
73
+ const id = options.args[0]
74
+ if (!id) return yield* Effect.fail(new Error("Task ID is required"))
75
+ const record = yield* tasks.show(id, cwd)
76
+ const { content: _, ...output } = record
77
+ log(
78
+ options.json
79
+ ? JSON.stringify(output, null, 2)
80
+ : record.content.trimEnd(),
808
81
  )
809
-
810
- // Get the instructions we want to add from our default content
811
- const managedFile = managedFiles.find((f) => f.name === "opencode.json")
812
- const defaultContent = managedFile?.defaultContent ?? "{}"
813
- const defaultConfig = JSON.parse(defaultContent) as {
814
- instructions?: string[]
815
- }
816
- const instructionsToAdd = defaultConfig.instructions || []
817
-
818
- // Merge the instructions
819
- const mergedFilePath = yield* opencodeService
820
- .mergeOpencodeFile(targetPath, instructionsToAdd)
821
- .pipe(
822
- Effect.catchAll((error) => {
823
- verboseLog(
824
- `Failed to merge ${existingOpencodeInfo.relativePath}: ${error.message}`,
825
- )
826
- return Effect.succeed(existingOpencodeInfo.relativePath)
827
- }),
828
- )
829
-
830
- // Track the file that was modified (use the actual relative path)
831
- createdFiles.push(mergedFilePath)
832
- injectedFiles.push(mergedFilePath)
833
-
834
- log(done(`Merged ${highlight.file(mergedFilePath)}`))
835
- continue
836
- }
837
-
838
- // Check if file exists in repo - if so, skip injection
839
- const exists = yield* fs.exists(targetFilePath)
840
- if (exists) {
841
- log(info(`Skipped ${highlight.file(fileName)} (exists in repo)`))
842
- continue
843
- }
844
-
845
- // Check if this file is in a subdirectory that doesn't exist yet
846
- // If so, we'll track it for glob pattern creation
847
- const topLevelDir = getTopLevelDir(fileName)
848
- if (topLevelDir && source === "template") {
849
- const dirPath = resolve(targetPath, topLevelDir)
850
- const dirExists = yield* fs.exists(dirPath)
851
- if (!dirExists) {
852
- createdDirs.add(topLevelDir)
853
- verboseLog(`Will create new directory: ${topLevelDir}`)
854
- }
855
- }
856
-
857
- let content: string
858
-
859
- // Try to read from template first, fall back to default content
860
- if (source === "template") {
861
- const templateFilePath = join(templateDir, fileName)
862
- content = yield* fs.readFile(templateFilePath)
863
- } else {
864
- // Use default content from managed files
865
- const managedFile = managedFiles.find((f) => f.name === fileName)
866
- content = managedFile?.defaultContent ?? ""
82
+ return
867
83
  }
868
-
869
- // Replace {task} placeholder in TASK.md if task description was provided
870
- if (fileName === "TASK.md" && taskDescription) {
871
- content = content.replace("{task}", taskDescription)
872
- verboseLog(`Replaced {task} placeholder with: ${taskDescription}`)
873
- }
874
-
875
- // Ensure parent directory exists before writing file
876
- const parentDir = dirname(targetFilePath)
877
- const parentExists = yield* fs.exists(parentDir)
878
- if (!parentExists) {
879
- yield* fs.createDirectory(parentDir)
880
- }
881
-
882
- yield* fs.writeFile(targetFilePath, content)
883
- createdFiles.push(fileName)
884
-
885
- // Track backpack files (excluding TASK.md and AGENCY.md which are always filtered)
886
- // For files in new directories, the glob pattern will be added below
887
- if (fileName !== "TASK.md" && fileName !== "AGENCY.md") {
888
- // Only track individual files if they're NOT in a newly created directory
889
- // (directories will be tracked as glob patterns)
890
- if (!topLevelDir || !createdDirs.has(topLevelDir)) {
891
- injectedFiles.push(fileName)
892
- }
893
- }
894
-
895
- log(done(`Created ${highlight.file(fileName)}`))
896
- }
897
-
898
- // Handle CLAUDE.md injection
899
- // If CLAUDE.md is created (new file), include it in main commit and add to injectedFiles
900
- // If CLAUDE.md is modified (existing file), commit separately with AGENCY_REMOVE_COMMIT marker
901
- // so it can be completely removed during emission
902
- const claudeResult = yield* claudeService.injectAgencySection(targetPath)
903
- let claudeModifiedExisting = false
904
- if (claudeResult.created) {
905
- createdFiles.push("CLAUDE.md")
906
- injectedFiles.push("CLAUDE.md")
907
- log(done(`Created ${highlight.file("CLAUDE.md")}`))
908
- } else if (claudeResult.modified) {
909
- // Mark that we need a separate commit for CLAUDE.md modification
910
- claudeModifiedExisting = true
911
- log(done(`Updated ${highlight.file("CLAUDE.md")}`))
912
- }
913
-
914
- // Auto-detect base branch for this feature branch
915
- let baseBranch: string | undefined
916
-
917
- // Check repository-level default in git config
918
- baseBranch =
919
- (yield* git.getDefaultBaseBranchConfig(targetPath)) || undefined
920
-
921
- // If no repo-level default, try to auto-detect (preferring remote)
922
- if (!baseBranch) {
923
- baseBranch = (yield* git.resolveMainBranch(targetPath)) || undefined
924
- }
925
-
926
- if (baseBranch) {
927
- verboseLog(`Auto-detected base branch: ${highlight.branch(baseBranch)}`)
928
- }
929
-
930
- // Calculate emitBranch name from current branch
931
- const finalBranch = yield* git.getCurrentBranch(targetPath)
932
- // Extract clean branch name from source pattern, or use branch as-is for legacy branches
933
- const cleanBranch =
934
- extractCleanBranch(finalBranch, config.sourceBranchPattern) || finalBranch
935
- const emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
936
-
937
- // Convert created directories to glob patterns and add to injectedFiles
938
- // This allows filtering all files in new directories during emit
939
- for (const dir of createdDirs) {
940
- const globPattern = dirToGlobPattern(dir)
941
- injectedFiles.push(globPattern)
942
- verboseLog(`Tracking directory as glob pattern: ${globPattern}`)
943
- }
944
-
945
- // Create agency.json metadata file
946
- const metadata = {
947
- version: 1 as const,
948
- injectedFiles,
949
- baseBranch, // Save the base branch if detected
950
- emitBranch: emitBranchName, // Save the emit branch name
951
- template: templateName,
952
- createdAt: new Date().toISOString(),
953
- }
954
- yield* Effect.tryPromise({
955
- try: () => writeAgencyMetadata(targetPath, metadata as any),
956
- catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
957
- })
958
- createdFiles.push("agency.json")
959
- log(done(`Created ${highlight.file("agency.json")}`))
960
- if (baseBranch) {
961
- verboseLog(`Base branch: ${highlight.branch(baseBranch)}`)
962
- }
963
- verboseLog(`Tracked backpack file${plural(injectedFiles.length)}`)
964
-
965
- // Format created files using project's formatter (prettier/oxfmt)
966
- const formatterService = yield* FormatterService
967
- yield* formatterService.formatFiles(targetPath, createdFiles, verboseLog)
968
-
969
- // Git add and commit the created files
970
- if (createdFiles.length > 0) {
971
- yield* Effect.gen(function* () {
972
- yield* git.gitAdd(createdFiles, targetPath)
973
- // Format: chore: agency task (baseBranch) sourceBranch → emitBranch
974
- const commitMessage = baseBranch
975
- ? `chore: agency task (${baseBranch}) ${finalBranch} → ${emitBranchName}`
976
- : `chore: agency task ${finalBranch} → ${emitBranchName}`
977
- yield* git.gitCommit(commitMessage, targetPath, {
978
- noVerify: true,
979
- })
980
- verboseLog(
981
- `Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
982
- )
983
- }).pipe(
984
- Effect.catchAll((err) => {
985
- // If commit fails, it might be because there are no changes (e.g., files already staged)
986
- // We can ignore this error and let the user handle it manually
987
- verboseLog(`Failed to commit: ${err}`)
988
- return Effect.void
989
- }),
990
- )
991
- }
992
-
993
- // Create separate commit for CLAUDE.md modification with AGENCY_REMOVE_COMMIT marker
994
- // This commit will be completely removed during emission
995
- if (claudeModifiedExisting) {
996
- yield* Effect.gen(function* () {
997
- // Check if CLAUDE.md is a symlink - if so, we need to add both the symlink
998
- // and its target file, since git add on a symlink only stages the symlink itself
999
- const claudePath = resolve(targetPath, "CLAUDE.md")
1000
- const filesToAdd = ["CLAUDE.md"]
1001
-
1002
- const symlinkTarget = yield* fs.readSymlinkTarget(claudePath)
1003
- if (symlinkTarget) {
1004
- // Symlink target can be relative or absolute
1005
- // If relative, resolve it relative to the symlink's directory
1006
- const resolvedTarget = symlinkTarget.startsWith("/")
1007
- ? symlinkTarget
1008
- : resolve(targetPath, symlinkTarget)
1009
-
1010
- // Make the path relative to targetPath for git add
1011
- const relativeTarget = resolvedTarget.startsWith(targetPath)
1012
- ? resolvedTarget.slice(targetPath.length + 1)
1013
- : resolvedTarget
1014
-
1015
- filesToAdd.push(relativeTarget)
1016
- verboseLog(
1017
- `CLAUDE.md is a symlink to ${relativeTarget}, adding both files`,
1018
- )
1019
- }
1020
-
1021
- yield* git.gitAdd(filesToAdd, targetPath)
1022
- // The AGENCY_REMOVE_COMMIT marker in the commit body tells emit to drop this commit entirely
1023
- const commitMessage = `chore: agency edit CLAUDE.md\n\n${AGENCY_REMOVE_COMMIT}`
1024
- yield* git.gitCommit(commitMessage, targetPath, {
1025
- noVerify: true,
1026
- })
1027
- verboseLog(
1028
- "Created CLAUDE.md modification commit (will be removed on emit)",
1029
- )
1030
- }).pipe(
1031
- Effect.catchAll((err) => {
1032
- verboseLog(`Failed to commit CLAUDE.md modification: ${err}`)
1033
- return Effect.void
1034
- }),
1035
- )
1036
- }
1037
- })
1038
-
1039
- // Helper: Create feature branch with interactive prompts
1040
- const createFeatureBranchEffect = (
1041
- targetPath: string,
1042
- branchName: string,
1043
- providedBaseBranch: string | undefined,
1044
- silent: boolean,
1045
- verbose: boolean,
1046
- ) =>
1047
- Effect.gen(function* () {
1048
- const { log, verboseLog } = createLoggers({ silent, verbose })
1049
- const git = yield* GitService
1050
- const promptService = yield* PromptService
1051
-
1052
- // Use provided base branch if available, otherwise resolve (preferring remote)
1053
- let baseBranch: string | undefined = providedBaseBranch
1054
-
1055
- if (!baseBranch) {
1056
- baseBranch = (yield* git.resolveMainBranch(targetPath)) || undefined
1057
- }
1058
-
1059
- // If no base branch is configured and not in silent mode, prompt for it
1060
- if (!baseBranch && !silent) {
1061
- const suggestions = yield* git.getSuggestedBaseBranches(targetPath)
1062
- if (suggestions.length > 0) {
1063
- baseBranch = yield* promptService.promptForSelection(
1064
- "Select base branch",
1065
- suggestions,
1066
- )
1067
- verboseLog(`Selected base branch: ${baseBranch}`)
1068
- } else {
84
+ default:
1069
85
  return yield* Effect.fail(
1070
- new Error(
1071
- "Could not find any base branches. Please ensure your repository has at least one branch.",
1072
- ),
86
+ new Error("Subcommand is required. Available: create, list, show"),
1073
87
  )
1074
- }
1075
- } else if (!baseBranch && silent) {
1076
- return yield* Effect.fail(
1077
- new Error(
1078
- "No base branch configured. Run without --silent to configure, or set agency.mainBranch in git config.",
1079
- ),
1080
- )
1081
- }
1082
-
1083
- yield* git.createBranch(branchName, targetPath, baseBranch)
1084
-
1085
- // Load config for emit pattern calculation
1086
- const configService = yield* ConfigService
1087
- const config = yield* configService.loadConfig()
1088
-
1089
- // Calculate the emit branch name for display
1090
- const cleanBranchForDisplay =
1091
- extractCleanBranch(branchName, config.sourceBranchPattern) || branchName
1092
- const emitBranchForDisplay = makeEmitBranchName(
1093
- cleanBranchForDisplay,
1094
- config.emitBranch,
1095
- )
1096
-
1097
- log(
1098
- info(
1099
- baseBranch
1100
- ? `(${highlight.branch(baseBranch)}) ${highlight.branch(branchName)} → ${highlight.branch(emitBranchForDisplay)}`
1101
- : `${highlight.branch(branchName)} → ${highlight.branch(emitBranchForDisplay)}`,
1102
- ),
1103
- )
1104
- log(done(`Created and switched to ${highlight.branch(branchName)}`))
1105
- })
1106
-
1107
- // Helper: Discover template files
1108
- const discoverTemplateFiles = (templateDir: string, verboseLog: Function) =>
1109
- Effect.gen(function* () {
1110
- const fs = yield* FileSystemService
1111
-
1112
- try {
1113
- const result = yield* fs.runCommand(["find", templateDir, "-type", "f"], {
1114
- captureOutput: true,
1115
- })
1116
-
1117
- if (result.stdout) {
1118
- const foundFiles = result.stdout
1119
- .trim()
1120
- .split("\n")
1121
- .filter((f: string) => f.length > 0)
1122
- const templateFiles: string[] = []
1123
-
1124
- for (const file of foundFiles) {
1125
- // Get relative path from template directory
1126
- const relativePath = file.replace(templateDir + "/", "")
1127
- if (relativePath) {
1128
- templateFiles.push(relativePath)
1129
- }
1130
- }
1131
-
1132
- return templateFiles
1133
- }
1134
- } catch (err) {
1135
- verboseLog(`Error discovering template files: ${err}`)
1136
- }
1137
-
1138
- return []
1139
- })
1140
-
1141
- // Effect-based taskEdit implementation
1142
- const taskEditEffect = (options: TaskEditOptions = {}) =>
1143
- Effect.gen(function* () {
1144
- const { log, verboseLog } = createLoggers(options)
1145
-
1146
- const fs = yield* FileSystemService
1147
- const git = yield* GitService
1148
-
1149
- const gitRoot = yield* ensureGitRepo()
1150
-
1151
- const taskFilePath = resolve(gitRoot, "TASK.md")
1152
- verboseLog(`TASK.md path: ${taskFilePath}`)
1153
-
1154
- // Check if TASK.md exists
1155
- const exists = yield* fs.exists(taskFilePath)
1156
- if (!exists) {
1157
- return yield* Effect.fail(
1158
- new Error(
1159
- "TASK.md not found in repository root. Run 'agency task' first to create it.",
1160
- ),
1161
- )
1162
- }
1163
-
1164
- // Get editor from environment or use sensible defaults
1165
- // On macOS, use 'open -W' to wait for the editor to close
1166
- const editor =
1167
- process.env.VISUAL ||
1168
- process.env.EDITOR ||
1169
- (process.platform === "darwin" ? "open" : "vim")
1170
-
1171
- verboseLog(`Using editor: ${editor}`)
1172
-
1173
- // Build the command array
1174
- // If using 'open' on macOS, add -W flag to wait for the app to close
1175
- const editorCommand =
1176
- editor === "open" ? [editor, "-W", taskFilePath] : [editor, taskFilePath]
1177
-
1178
- // Use interactive mode for editors that need terminal access (stdin/stdout/stderr)
1179
- // 'open' on macOS launches a separate app, so it doesn't need interactive mode
1180
- const result = yield* fs.runCommand(editorCommand, {
1181
- interactive: editor !== "open",
1182
- })
1183
-
1184
- if (result.exitCode !== 0) {
1185
- return yield* Effect.fail(
1186
- new Error(`Editor exited with code ${result.exitCode}`),
1187
- )
1188
- }
1189
-
1190
- log(done("TASK.md edited"))
1191
-
1192
- // Check if TASK.md has uncommitted changes
1193
- const hasChanges = yield* git.hasUncommittedChanges(gitRoot, "TASK.md")
1194
- verboseLog(`TASK.md has uncommitted changes: ${hasChanges}`)
1195
-
1196
- if (hasChanges) {
1197
- // Commit the changes
1198
- yield* Effect.gen(function* () {
1199
- yield* git.gitAdd(["TASK.md"], gitRoot)
1200
- yield* git.gitCommit("chore: agency edit", gitRoot, {
1201
- noVerify: true,
1202
- })
1203
- log(done("Committed TASK.md changes"))
1204
- }).pipe(
1205
- Effect.catchAll((err) => {
1206
- verboseLog(`Failed to commit TASK.md: ${err}`)
1207
- return Effect.void
1208
- }),
1209
- )
1210
88
  }
1211
89
  })
1212
90
 
1213
- export const editHelp = `
1214
- Usage: agency edit [options]
1215
-
1216
- Open TASK.md in the system editor for editing.
1217
-
1218
- Notes:
1219
- - Requires TASK.md to exist (run 'agency task' first)
1220
- - Respects VISUAL and EDITOR environment variables
1221
- - On macOS, defaults to 'open' which uses the default app for .md files
1222
- - On other platforms, defaults to 'vim'
1223
- - The command waits for the editor to close before returning
1224
-
1225
- Example:
1226
- agency edit # Open TASK.md in default editor
1227
- `
1228
-
1229
91
  export const help = `
1230
- Usage: agency task <branch-name> [options]
1231
-
1232
- Initialize template files (AGENTS.md, TASK.md, opencode.json) in a git repository.
1233
-
1234
- IMPORTANT:
1235
- - You must run 'agency init' first to select a template
1236
- - A branch name is required (creates a new branch from the latest origin/main)
1237
-
1238
- Arguments:
1239
- branch-name Name for the new feature branch (required)
92
+ Usage: agency task <subcommand>
93
+
94
+ Subcommands:
95
+ create <id> Create a task
96
+ list List tasks
97
+ show <id> Show a task
98
+
99
+ Create options:
100
+ --ticket-url <url> External ticket URL
101
+ --description <text> Short description of the task
102
+ --epic <id> Parent epic
103
+ --repo <alias> Writable repository
104
+ --reference <alias>:<ref>
105
+ Read-only repository reference; repeatable
106
+ --branch <name> Working branch
107
+ --base <name> Base branch
108
+ --multi-phase Create a task container for phases
1240
109
 
1241
110
  Options:
1242
- --emit Branch name to create (alternative to positional arg)
1243
- --branch (Deprecated: use --emit) Branch name to create
1244
- --from <branch> Branch to branch from instead of main upstream branch
1245
- --from-current Initialize on current branch instead of creating a new one
1246
- --continue Continue a task by copying agency files to a new branch
1247
- --squash With --continue: squash the old branch's emitted commits into one
1248
- --task <desc> Task description for TASK.md (avoids interactive prompt)
1249
-
1250
- Continue Mode (--continue):
1251
- After a PR is merged, use '--continue' to create a new branch that preserves
1252
- your agency files (TASK.md, AGENCY.md, opencode.json, agency.json, and all
1253
- backpacked files). This allows you to continue working on a task after its
1254
- PR has been merged to main.
1255
-
1256
- The continue workflow:
1257
- 1. Be on an agency source branch with agency files
1258
- 2. Run 'agency task --continue <new-branch-name>'
1259
- 3. A new branch is created from main with all your agency files
1260
- 4. The emitBranch in agency.json is updated for the new branch
1261
-
1262
- Squash Mode (--continue --squash):
1263
- Combines --continue with squashing the old branch's emitted commits into a
1264
- single commit on the new branch. This is useful when you want to carry forward
1265
- code changes but collapse the commit history. A fresh TASK.md is created with
1266
- a new task description.
1267
-
1268
- The squash workflow:
1269
- 1. Be on an agency source branch with agency files
1270
- 2. Run 'agency task --continue --squash <new-branch-name>'
1271
- 3. The old branch is emitted (backpack files stripped)
1272
- 4. A new branch is created from main
1273
- 5. The emitted commits are squash-merged as a single commit
1274
- 6. Fresh agency files (including new TASK.md) are added
1275
-
1276
- Base Branch Selection:
1277
- By default, 'agency task' fetches from the remote and branches from the latest
1278
- main upstream branch (e.g., origin/main). You can override this behavior with:
1279
-
1280
- - --from <branch>: Branch from a specific branch
1281
- - --from-current: Initialize on your current branch (no new branch created)
1282
-
1283
- If the base branch is an agency source branch (e.g., agency--branch-A), the command
1284
- will automatically use its emit branch instead. This allows you to layer work on top
1285
- of other feature branches while maintaining clean branch history.
1286
-
1287
- Examples:
1288
- agency task my-feature # Create 'my-feature' from latest origin/main
1289
- agency task my-feature --from develop # Create 'my-feature' from 'develop'
1290
- agency task --from-current # Initialize on current branch (no new branch)
1291
- agency task --continue my-feature-v2 # Continue task on new branch after PR merge
1292
- agency task --continue --squash v2 # Continue with squashed code from old branch
1293
-
1294
- Template Workflow:
1295
- 1. Run 'agency init' to select template (saved to .git/config)
1296
- 2. Run 'agency task <branch-name>' to create feature branch with template files
1297
- 3. Use 'agency template save <file>' to update template with local changes
1298
- 4. Template directory only created when you save files to it
1299
-
1300
- Branch Creation:
1301
- When creating a new branch without --from or --from-current:
1302
- 1. Fetches from the configured remote (or origin)
1303
- 2. Auto-detects main upstream branch (origin/main, origin/master, etc.)
1304
- 3. Creates new branch from the latest remote main branch
1305
-
1306
- Notes:
1307
- - Files are created at the git repository root, not the current directory
1308
- - If files already exist in the repository, they will not be overwritten
1309
- - Template selection is stored in .git/config (not committed)
1310
- - To edit TASK.md after creation, use 'agency edit'
111
+ --json Output results as JSON
1311
112
  `
1312
-
1313
- export const taskEdit = (options: TaskEditOptions = {}) =>
1314
- taskEditEffect(options)