@markjaquith/agency 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cli.ts +569 -0
  4. package/index.ts +1 -0
  5. package/package.json +65 -0
  6. package/src/commands/base.test.ts +198 -0
  7. package/src/commands/base.ts +198 -0
  8. package/src/commands/clean.test.ts +299 -0
  9. package/src/commands/clean.ts +320 -0
  10. package/src/commands/emit.test.ts +412 -0
  11. package/src/commands/emit.ts +521 -0
  12. package/src/commands/emitted.test.ts +226 -0
  13. package/src/commands/emitted.ts +57 -0
  14. package/src/commands/init.test.ts +311 -0
  15. package/src/commands/init.ts +140 -0
  16. package/src/commands/merge.test.ts +365 -0
  17. package/src/commands/merge.ts +253 -0
  18. package/src/commands/pull.test.ts +385 -0
  19. package/src/commands/pull.ts +205 -0
  20. package/src/commands/push.test.ts +394 -0
  21. package/src/commands/push.ts +346 -0
  22. package/src/commands/save.test.ts +247 -0
  23. package/src/commands/save.ts +162 -0
  24. package/src/commands/source.test.ts +195 -0
  25. package/src/commands/source.ts +72 -0
  26. package/src/commands/status.test.ts +489 -0
  27. package/src/commands/status.ts +258 -0
  28. package/src/commands/switch.test.ts +194 -0
  29. package/src/commands/switch.ts +84 -0
  30. package/src/commands/task-branching.test.ts +334 -0
  31. package/src/commands/task-edit.test.ts +141 -0
  32. package/src/commands/task-main.test.ts +872 -0
  33. package/src/commands/task.ts +712 -0
  34. package/src/commands/tasks.test.ts +335 -0
  35. package/src/commands/tasks.ts +155 -0
  36. package/src/commands/template-delete.test.ts +178 -0
  37. package/src/commands/template-delete.ts +98 -0
  38. package/src/commands/template-list.test.ts +135 -0
  39. package/src/commands/template-list.ts +87 -0
  40. package/src/commands/template-view.test.ts +158 -0
  41. package/src/commands/template-view.ts +86 -0
  42. package/src/commands/template.test.ts +32 -0
  43. package/src/commands/template.ts +96 -0
  44. package/src/commands/use.test.ts +87 -0
  45. package/src/commands/use.ts +97 -0
  46. package/src/commands/work.test.ts +462 -0
  47. package/src/commands/work.ts +193 -0
  48. package/src/errors.ts +17 -0
  49. package/src/schemas.ts +33 -0
  50. package/src/services/AgencyMetadataService.ts +287 -0
  51. package/src/services/ClaudeService.test.ts +184 -0
  52. package/src/services/ClaudeService.ts +91 -0
  53. package/src/services/ConfigService.ts +115 -0
  54. package/src/services/FileSystemService.ts +222 -0
  55. package/src/services/GitService.ts +751 -0
  56. package/src/services/OpencodeService.ts +263 -0
  57. package/src/services/PromptService.ts +183 -0
  58. package/src/services/TemplateService.ts +75 -0
  59. package/src/test-utils.ts +362 -0
  60. package/src/types/native-exec.d.ts +8 -0
  61. package/src/types.ts +216 -0
  62. package/src/utils/colors.ts +178 -0
  63. package/src/utils/command.ts +17 -0
  64. package/src/utils/effect.ts +281 -0
  65. package/src/utils/exec.ts +48 -0
  66. package/src/utils/paths.ts +51 -0
  67. package/src/utils/pr-branch.test.ts +372 -0
  68. package/src/utils/pr-branch.ts +473 -0
  69. package/src/utils/process.ts +110 -0
  70. package/src/utils/spinner.ts +82 -0
  71. package/templates/AGENCY.md +20 -0
  72. package/templates/AGENTS.md +11 -0
  73. package/templates/CLAUDE.md +3 -0
  74. package/templates/TASK.md +5 -0
  75. package/templates/opencode.json +4 -0
@@ -0,0 +1,712 @@
1
+ import { resolve, join } from "path"
2
+ import { Effect } from "effect"
3
+ 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 { initializeManagedFiles, writeAgencyMetadata } from "../types"
12
+ import { RepositoryNotInitializedError } from "../errors"
13
+ import highlight, { done, info, plural } from "../utils/colors"
14
+ import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
15
+ import {
16
+ makeEmitBranchName,
17
+ extractCleanBranch,
18
+ makeSourceBranchName,
19
+ } from "../utils/pr-branch"
20
+
21
+ interface TaskOptions extends BaseCommandOptions {
22
+ path?: string
23
+ task?: string
24
+ branch?: string
25
+ from?: string
26
+ fromCurrent?: boolean
27
+ }
28
+
29
+ interface TaskEditOptions extends BaseCommandOptions {}
30
+
31
+ export const task = (options: TaskOptions = {}) =>
32
+ Effect.gen(function* () {
33
+ const { silent = false, verbose = false } = options
34
+ const { log, verboseLog } = createLoggers(options)
35
+
36
+ const git = yield* GitService
37
+ const configService = yield* ConfigService
38
+ const fs = yield* FileSystemService
39
+ const promptService = yield* PromptService
40
+ const templateService = yield* TemplateService
41
+ const opencodeService = yield* OpencodeService
42
+ const claudeService = yield* ClaudeService
43
+
44
+ // Determine target path
45
+ let targetPath: string
46
+
47
+ if (options.path) {
48
+ // If path is provided, validate it's a git repository root
49
+ targetPath = resolve(options.path)
50
+
51
+ const isRoot = yield* git.isGitRoot(targetPath)
52
+ if (!isRoot) {
53
+ return yield* Effect.fail(
54
+ new Error(
55
+ "The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
56
+ ),
57
+ )
58
+ }
59
+ } else {
60
+ // If no path provided, use git root of current directory
61
+ const isRepo = yield* git.isInsideGitRepo(process.cwd())
62
+ if (!isRepo) {
63
+ return yield* Effect.fail(
64
+ new Error(
65
+ "Not in a git repository. Please run this command inside a git repo.",
66
+ ),
67
+ )
68
+ }
69
+
70
+ targetPath = yield* git.getGitRoot(process.cwd())
71
+ }
72
+
73
+ const createdFiles: string[] = []
74
+ const injectedFiles: string[] = []
75
+
76
+ // Check if initialized (has template in git config)
77
+ const templateName = yield* getTemplateName(targetPath)
78
+
79
+ if (!templateName) {
80
+ return yield* Effect.fail(new RepositoryNotInitializedError())
81
+ }
82
+
83
+ verboseLog(`Using template: ${templateName}`)
84
+
85
+ // Define path to TASK.md for later checks
86
+ const taskMdPath = resolve(targetPath, "TASK.md")
87
+
88
+ // Check if we're on a feature branch
89
+ const currentBranch = yield* git.getCurrentBranch(targetPath)
90
+ verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
91
+ const isFeature = yield* git.isFeatureBranch(currentBranch, targetPath)
92
+ verboseLog(`Is feature branch: ${isFeature}`)
93
+
94
+ // Determine base branch to branch from
95
+ let baseBranchToBranchFrom: string | undefined
96
+
97
+ // Handle --from and --from-current flags
98
+ if (options.from && options.fromCurrent) {
99
+ return yield* Effect.fail(
100
+ new Error("Cannot use both --from and --from-current flags together."),
101
+ )
102
+ }
103
+
104
+ if (options.fromCurrent) {
105
+ // Branch from current branch
106
+ baseBranchToBranchFrom = currentBranch
107
+ verboseLog(
108
+ `Using current branch as base: ${highlight.branch(currentBranch)}`,
109
+ )
110
+ } else if (options.from) {
111
+ // Branch from specified branch
112
+ baseBranchToBranchFrom = options.from
113
+ verboseLog(
114
+ `Using specified branch as base: ${highlight.branch(options.from)}`,
115
+ )
116
+
117
+ // Verify the specified branch exists
118
+ const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
119
+ if (!exists) {
120
+ return yield* Effect.fail(
121
+ new Error(
122
+ `Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
123
+ ),
124
+ )
125
+ }
126
+ } else {
127
+ // Default: determine main upstream branch
128
+ baseBranchToBranchFrom =
129
+ (yield* git.getMainBranchConfig(targetPath)) ||
130
+ (yield* git.findMainBranch(targetPath)) ||
131
+ undefined
132
+
133
+ // If still no base branch, try to auto-detect from remote
134
+ if (!baseBranchToBranchFrom) {
135
+ const remote = yield* git
136
+ .resolveRemote(targetPath)
137
+ .pipe(Effect.catchAll(() => Effect.succeed(null)))
138
+
139
+ const commonBases: string[] = []
140
+ if (remote) {
141
+ commonBases.push(`${remote}/main`, `${remote}/master`)
142
+ }
143
+ commonBases.push("main", "master")
144
+
145
+ for (const base of commonBases) {
146
+ const exists = yield* git.branchExists(targetPath, base)
147
+ if (exists) {
148
+ baseBranchToBranchFrom = base
149
+ break
150
+ }
151
+ }
152
+ }
153
+
154
+ if (baseBranchToBranchFrom) {
155
+ verboseLog(
156
+ `Auto-detected base branch: ${highlight.branch(baseBranchToBranchFrom)}`,
157
+ )
158
+ }
159
+ }
160
+
161
+ // Check if the base branch is an agency source branch
162
+ // If so, we need to emit it first and use the emit branch instead
163
+ if (baseBranchToBranchFrom) {
164
+ const config = yield* configService.loadConfig()
165
+ const cleanFromBase = extractCleanBranch(
166
+ baseBranchToBranchFrom,
167
+ config.sourceBranchPattern,
168
+ )
169
+
170
+ if (cleanFromBase) {
171
+ // This is an agency source branch - we need to use its emit branch
172
+ verboseLog(
173
+ `Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch`,
174
+ )
175
+
176
+ const emitBranchName = makeEmitBranchName(
177
+ cleanFromBase,
178
+ config.emitBranch,
179
+ )
180
+
181
+ // Check if emit branch exists
182
+ const emitExists = yield* git.branchExists(targetPath, emitBranchName)
183
+
184
+ if (!emitExists) {
185
+ return yield* Effect.fail(
186
+ new Error(
187
+ `Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch, ` +
188
+ `but its emit branch ${highlight.branch(emitBranchName)} does not exist.\n` +
189
+ `Please run 'agency emit' on ${highlight.branch(baseBranchToBranchFrom)} first, ` +
190
+ `or choose a different base branch.`,
191
+ ),
192
+ )
193
+ }
194
+
195
+ // Use the emit branch as the base
196
+ baseBranchToBranchFrom = emitBranchName
197
+ verboseLog(
198
+ `Using emit branch as base: ${highlight.branch(baseBranchToBranchFrom)}`,
199
+ )
200
+ }
201
+ }
202
+
203
+ // If on main branch without a branch name, prompt for it (unless in silent mode)
204
+ let branchName = options.branch
205
+ if (!isFeature && !branchName) {
206
+ if (silent) {
207
+ return yield* Effect.fail(
208
+ new Error(
209
+ `You're currently on ${highlight.branch(currentBranch)}, which appears to be your main branch.\n` +
210
+ `To initialize on a feature branch, either:\n` +
211
+ ` 1. Switch to an existing feature branch first, then run 'agency task'\n` +
212
+ ` 2. Provide a new branch name: 'agency task <branch-name>'`,
213
+ ),
214
+ )
215
+ }
216
+ branchName = yield* promptService.prompt("Branch name: ")
217
+ if (!branchName) {
218
+ return yield* Effect.fail(
219
+ new Error("Branch name is required when on main branch."),
220
+ )
221
+ }
222
+ verboseLog(`Branch name from prompt: ${branchName}`)
223
+ }
224
+
225
+ // If we have a branch name, apply source pattern and check if branch exists
226
+ let sourceBranchName: string | undefined
227
+ if (branchName) {
228
+ const config = yield* configService.loadConfig()
229
+ sourceBranchName = makeSourceBranchName(
230
+ branchName,
231
+ config.sourceBranchPattern,
232
+ )
233
+
234
+ const exists = yield* git.branchExists(targetPath, sourceBranchName)
235
+ if (exists) {
236
+ return yield* Effect.fail(
237
+ new Error(
238
+ `Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
239
+ `Either switch to it first or choose a different branch name.`,
240
+ ),
241
+ )
242
+ }
243
+ verboseLog(
244
+ `Branch ${sourceBranchName} does not exist (from clean name: ${branchName}), will create it`,
245
+ )
246
+ }
247
+
248
+ // If we're going to create a branch, check if TASK.md will be created and prompt for description first
249
+ let taskDescription: string | undefined
250
+ if (branchName) {
251
+ const taskMdExists = yield* fs.exists(taskMdPath)
252
+ if (!taskMdExists) {
253
+ if (options.task) {
254
+ taskDescription = options.task
255
+ verboseLog(`Using task from option: ${taskDescription}`)
256
+ } else if (!silent) {
257
+ taskDescription = yield* promptService.prompt("Task description: ")
258
+ if (!taskDescription) {
259
+ log(
260
+ info(
261
+ "Skipping task description (TASK.md will use default placeholder)",
262
+ ),
263
+ )
264
+ taskDescription = undefined
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ if (sourceBranchName) {
271
+ yield* createFeatureBranchEffect(
272
+ targetPath,
273
+ sourceBranchName,
274
+ baseBranchToBranchFrom,
275
+ silent,
276
+ verbose,
277
+ )
278
+ }
279
+
280
+ // Get managed files for later use
281
+ const managedFiles = yield* Effect.tryPromise({
282
+ try: () => initializeManagedFiles(),
283
+ catch: (error) =>
284
+ new Error(`Failed to initialize managed files: ${error}`),
285
+ })
286
+
287
+ // Get template directory (it may or may not exist yet)
288
+ const templateDir = yield* templateService.getTemplateDir(templateName)
289
+
290
+ // Prompt for task if TASK.md will be created (only if not already prompted earlier)
291
+ if (taskDescription === undefined) {
292
+ const taskMdExists = yield* fs.exists(taskMdPath)
293
+ if (!taskMdExists) {
294
+ if (options.task) {
295
+ taskDescription = options.task
296
+ verboseLog(`Using task from option: ${taskDescription}`)
297
+ } else if (!silent) {
298
+ taskDescription = yield* promptService.prompt("Task description: ")
299
+ if (!taskDescription) {
300
+ log(
301
+ info(
302
+ "Skipping task description (TASK.md will use default placeholder)",
303
+ ),
304
+ )
305
+ taskDescription = undefined
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ // Build list of files to create, combining managed files with any additional template files
312
+ const filesToCreate = new Map<string, string>() // fileName -> content source
313
+
314
+ // Start with all managed files (these should always be created)
315
+ for (const managedFile of managedFiles) {
316
+ filesToCreate.set(managedFile.name, "default")
317
+ }
318
+
319
+ // Discover all files from the template directory
320
+ const templateFiles = yield* discoverTemplateFiles(templateDir, verboseLog)
321
+ for (const relativePath of templateFiles) {
322
+ filesToCreate.set(relativePath, "template")
323
+ }
324
+
325
+ verboseLog(
326
+ `Discovered ${templateFiles.length} files in template: ${templateFiles.join(", ")}`,
327
+ )
328
+
329
+ // Check if opencode.json or opencode.jsonc already exists before processing files
330
+ const existingOpencodeInfo = yield* opencodeService
331
+ .detectOpencodeFile(targetPath)
332
+ .pipe(Effect.catchAll(() => Effect.succeed(null)))
333
+
334
+ // Process each file to create
335
+ for (const [fileName, source] of filesToCreate) {
336
+ const targetFilePath = resolve(targetPath, fileName)
337
+
338
+ // Special handling for opencode.json if opencode.json/jsonc already exists
339
+ if (fileName === "opencode.json" && existingOpencodeInfo) {
340
+ // Merge with existing file instead of creating new one
341
+ verboseLog(
342
+ `Found existing ${existingOpencodeInfo.relativePath}, will merge instructions`,
343
+ )
344
+
345
+ // Get the instructions we want to add from our default content
346
+ const managedFile = managedFiles.find((f) => f.name === "opencode.json")
347
+ const defaultContent = managedFile?.defaultContent ?? "{}"
348
+ const defaultConfig = JSON.parse(defaultContent) as {
349
+ instructions?: string[]
350
+ }
351
+ const instructionsToAdd = defaultConfig.instructions || []
352
+
353
+ // Merge the instructions
354
+ const mergedFilePath = yield* opencodeService
355
+ .mergeOpencodeFile(targetPath, instructionsToAdd)
356
+ .pipe(
357
+ Effect.catchAll((error) => {
358
+ verboseLog(
359
+ `Failed to merge ${existingOpencodeInfo.relativePath}: ${error.message}`,
360
+ )
361
+ return Effect.succeed(existingOpencodeInfo.relativePath)
362
+ }),
363
+ )
364
+
365
+ // Track the file that was modified (use the actual relative path)
366
+ createdFiles.push(mergedFilePath)
367
+ injectedFiles.push(mergedFilePath)
368
+
369
+ log(done(`Merged ${highlight.file(mergedFilePath)}`))
370
+ continue
371
+ }
372
+
373
+ // Check if file exists in repo - if so, skip injection
374
+ const exists = yield* fs.exists(targetFilePath)
375
+ if (exists) {
376
+ log(info(`Skipped ${highlight.file(fileName)} (exists in repo)`))
377
+ continue
378
+ }
379
+
380
+ let content: string
381
+
382
+ // Try to read from template first, fall back to default content
383
+ if (source === "template") {
384
+ const templateFilePath = join(templateDir, fileName)
385
+ content = yield* fs.readFile(templateFilePath)
386
+ } else {
387
+ // Use default content from managed files
388
+ const managedFile = managedFiles.find((f) => f.name === fileName)
389
+ content = managedFile?.defaultContent ?? ""
390
+ }
391
+
392
+ // Replace {task} placeholder in TASK.md if task description was provided
393
+ if (fileName === "TASK.md" && taskDescription) {
394
+ content = content.replace("{task}", taskDescription)
395
+ verboseLog(`Replaced {task} placeholder with: ${taskDescription}`)
396
+ }
397
+
398
+ yield* fs.writeFile(targetFilePath, content)
399
+ createdFiles.push(fileName)
400
+
401
+ // Track backpack files (excluding TASK.md and AGENCY.md which are always filtered)
402
+ if (fileName !== "TASK.md" && fileName !== "AGENCY.md") {
403
+ injectedFiles.push(fileName)
404
+ }
405
+
406
+ log(done(`Created ${highlight.file(fileName)}`))
407
+ }
408
+
409
+ // Handle CLAUDE.md injection
410
+ const claudeResult = yield* claudeService.injectAgencySection(targetPath)
411
+ if (claudeResult.created) {
412
+ createdFiles.push("CLAUDE.md")
413
+ log(done(`Created ${highlight.file("CLAUDE.md")}`))
414
+ } else if (claudeResult.modified) {
415
+ createdFiles.push("CLAUDE.md")
416
+ log(done(`Updated ${highlight.file("CLAUDE.md")}`))
417
+ }
418
+
419
+ // Auto-detect base branch for this feature branch
420
+ let baseBranch: string | undefined
421
+
422
+ // Check repository-level default in git config
423
+ baseBranch =
424
+ (yield* git.getDefaultBaseBranchConfig(targetPath)) || undefined
425
+
426
+ // If no repo-level default, try to auto-detect
427
+ if (!baseBranch) {
428
+ // Try main branch config
429
+ baseBranch =
430
+ (yield* git.getMainBranchConfig(targetPath)) ||
431
+ (yield* git.findMainBranch(targetPath)) ||
432
+ undefined
433
+
434
+ // Try common base branches with dynamic remote
435
+ if (!baseBranch) {
436
+ const remote = yield* git
437
+ .resolveRemote(targetPath)
438
+ .pipe(Effect.catchAll(() => Effect.succeed(null)))
439
+
440
+ const commonBases: string[] = []
441
+ if (remote) {
442
+ commonBases.push(`${remote}/main`, `${remote}/master`)
443
+ }
444
+ commonBases.push("main", "master")
445
+
446
+ for (const base of commonBases) {
447
+ const exists = yield* git.branchExists(targetPath, base)
448
+ if (exists) {
449
+ baseBranch = base
450
+ break
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ if (baseBranch) {
457
+ verboseLog(`Auto-detected base branch: ${highlight.branch(baseBranch)}`)
458
+ }
459
+
460
+ // Calculate emitBranch name from current branch
461
+ const finalBranch = yield* git.getCurrentBranch(targetPath)
462
+ const config = yield* configService.loadConfig()
463
+ // Extract clean branch name from source pattern, or use branch as-is for legacy branches
464
+ const cleanBranch =
465
+ extractCleanBranch(finalBranch, config.sourceBranchPattern) || finalBranch
466
+ const emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
467
+
468
+ // Create agency.json metadata file
469
+ const metadata = {
470
+ version: 1 as const,
471
+ injectedFiles,
472
+ baseBranch, // Save the base branch if detected
473
+ emitBranch: emitBranchName, // Save the emit branch name
474
+ template: templateName,
475
+ createdAt: new Date().toISOString(),
476
+ }
477
+ yield* Effect.tryPromise({
478
+ try: () => writeAgencyMetadata(targetPath, metadata as any),
479
+ catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
480
+ })
481
+ createdFiles.push("agency.json")
482
+ log(done(`Created ${highlight.file("agency.json")}`))
483
+ if (baseBranch) {
484
+ verboseLog(`Base branch: ${highlight.branch(baseBranch)}`)
485
+ }
486
+ verboseLog(`Tracked backpack file${plural(injectedFiles.length)}`)
487
+
488
+ // Git add and commit the created files
489
+ if (createdFiles.length > 0) {
490
+ yield* Effect.gen(function* () {
491
+ yield* git.gitAdd(createdFiles, targetPath)
492
+ yield* git.gitCommit("chore: agency task", targetPath, {
493
+ noVerify: true,
494
+ })
495
+ verboseLog(
496
+ `Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
497
+ )
498
+ }).pipe(
499
+ Effect.catchAll((err) => {
500
+ // If commit fails, it might be because there are no changes (e.g., files already staged)
501
+ // We can ignore this error and let the user handle it manually
502
+ verboseLog(`Failed to commit: ${err}`)
503
+ return Effect.void
504
+ }),
505
+ )
506
+ }
507
+ })
508
+
509
+ // Helper: Create feature branch with interactive prompts
510
+ const createFeatureBranchEffect = (
511
+ targetPath: string,
512
+ branchName: string,
513
+ providedBaseBranch: string | undefined,
514
+ silent: boolean,
515
+ verbose: boolean,
516
+ ) =>
517
+ Effect.gen(function* () {
518
+ const { log, verboseLog } = createLoggers({ silent, verbose })
519
+ const git = yield* GitService
520
+ const promptService = yield* PromptService
521
+
522
+ // Use provided base branch if available, otherwise get or prompt for one
523
+ let baseBranch: string | undefined = providedBaseBranch
524
+
525
+ if (!baseBranch) {
526
+ baseBranch =
527
+ (yield* git.getMainBranchConfig(targetPath)) ||
528
+ (yield* git.findMainBranch(targetPath)) ||
529
+ undefined
530
+ }
531
+
532
+ // If no base branch is configured and not in silent mode, prompt for it
533
+ if (!baseBranch && !silent) {
534
+ const suggestions = yield* git.getSuggestedBaseBranches(targetPath)
535
+ if (suggestions.length > 0) {
536
+ baseBranch = yield* promptService.promptForSelection(
537
+ "Select base branch",
538
+ suggestions,
539
+ )
540
+ verboseLog(`Selected base branch: ${baseBranch}`)
541
+
542
+ // Save the main branch config if it's not already set
543
+ const mainBranchConfig = yield* git.getMainBranchConfig(targetPath)
544
+ if (!mainBranchConfig) {
545
+ yield* git.setMainBranchConfig(baseBranch, targetPath)
546
+ log(done(`Set main branch to ${highlight.branch(baseBranch)}`))
547
+ }
548
+ } else {
549
+ return yield* Effect.fail(
550
+ new Error(
551
+ "Could not find any base branches. Please ensure your repository has at least one branch.",
552
+ ),
553
+ )
554
+ }
555
+ } else if (!baseBranch && silent) {
556
+ return yield* Effect.fail(
557
+ new Error(
558
+ "No base branch configured. Run without --silent to configure, or set agency.mainBranch in git config.",
559
+ ),
560
+ )
561
+ }
562
+
563
+ yield* git.createBranch(branchName, targetPath, baseBranch)
564
+ log(
565
+ done(
566
+ `Created and switched to branch ${highlight.branch(branchName)}${baseBranch ? ` based on ${highlight.branch(baseBranch)}` : ""}`,
567
+ ),
568
+ )
569
+ })
570
+
571
+ // Helper: Discover template files
572
+ const discoverTemplateFiles = (templateDir: string, verboseLog: Function) =>
573
+ Effect.gen(function* () {
574
+ const fs = yield* FileSystemService
575
+
576
+ try {
577
+ const result = yield* fs.runCommand(["find", templateDir, "-type", "f"], {
578
+ captureOutput: true,
579
+ })
580
+
581
+ if (result.stdout) {
582
+ const foundFiles = result.stdout
583
+ .trim()
584
+ .split("\n")
585
+ .filter((f: string) => f.length > 0)
586
+ const templateFiles: string[] = []
587
+
588
+ for (const file of foundFiles) {
589
+ // Get relative path from template directory
590
+ const relativePath = file.replace(templateDir + "/", "")
591
+ if (relativePath && !relativePath.startsWith(".")) {
592
+ templateFiles.push(relativePath)
593
+ }
594
+ }
595
+
596
+ return templateFiles
597
+ }
598
+ } catch (err) {
599
+ verboseLog(`Error discovering template files: ${err}`)
600
+ }
601
+
602
+ return []
603
+ })
604
+
605
+ // Effect-based taskEdit implementation
606
+ const taskEditEffect = (options: TaskEditOptions = {}) =>
607
+ Effect.gen(function* () {
608
+ const { log, verboseLog } = createLoggers(options)
609
+
610
+ const git = yield* GitService
611
+ const fs = yield* FileSystemService
612
+
613
+ const gitRoot = yield* ensureGitRepo()
614
+
615
+ const taskFilePath = resolve(gitRoot, "TASK.md")
616
+ verboseLog(`TASK.md path: ${taskFilePath}`)
617
+
618
+ // Check if TASK.md exists
619
+ const exists = yield* fs.exists(taskFilePath)
620
+ if (!exists) {
621
+ return yield* Effect.fail(
622
+ new Error(
623
+ "TASK.md not found in repository root. Run 'agency task' first to create it.",
624
+ ),
625
+ )
626
+ }
627
+
628
+ // Get editor from environment or use sensible defaults
629
+ const editor =
630
+ process.env.VISUAL ||
631
+ process.env.EDITOR ||
632
+ (process.platform === "darwin" ? "open" : "vim")
633
+
634
+ verboseLog(`Using editor: ${editor}`)
635
+
636
+ const result = yield* fs.runCommand([editor, taskFilePath])
637
+
638
+ if (result.exitCode !== 0) {
639
+ return yield* Effect.fail(
640
+ new Error(`Editor exited with code ${result.exitCode}`),
641
+ )
642
+ }
643
+
644
+ log(done("TASK.md edited"))
645
+ })
646
+
647
+ export const help = `
648
+ Usage: agency task [branch-name] [options]
649
+
650
+ Initialize template files (AGENTS.md, TASK.md, opencode.json) in a git repository.
651
+
652
+ IMPORTANT:
653
+ - You must run 'agency init' first to select a template
654
+ - This command must be run on a feature branch, not the main branch
655
+
656
+ If you're on the main branch, you must either:
657
+ 1. Switch to an existing feature branch first, then run 'agency task'
658
+ 2. Provide a branch name: 'agency task <branch-name>'
659
+
660
+ Initializes files at the root of the current git repository.
661
+
662
+ Arguments:
663
+ branch-name Create and switch to this branch before initializing
664
+
665
+ Options:
666
+ -b, --branch Branch name to create (alternative to positional arg)
667
+ --from <branch> Branch to branch from instead of main upstream branch
668
+ --from-current Branch from the current branch
669
+
670
+ Base Branch Selection:
671
+ By default, 'agency task' branches from the main upstream branch (e.g., origin/main).
672
+ You can override this behavior with:
673
+
674
+ - --from <branch>: Branch from a specific branch
675
+ - --from-current: Branch from your current branch
676
+
677
+ If the base branch is an agency source branch (e.g., agency/branch-A), the command
678
+ will automatically use its emit branch instead. This allows you to layer work on top
679
+ of other feature branches while maintaining clean branch history.
680
+
681
+ Examples:
682
+ agency task # Branch from main upstream branch
683
+ agency task --from agency/branch-B # Branch from agency/branch-B's emit branch
684
+ agency task --from-current # Branch from current branch's emit branch
685
+ agency task my-feature --from develop # Create 'my-feature' from 'develop'
686
+
687
+ Template Workflow:
688
+ 1. Run 'agency init' to select template (saved to .git/config)
689
+ 2. Run 'agency task' to create template files on feature branch
690
+ 3. Use 'agency template save <file>' to update template with local changes
691
+ 4. Template directory only created when you save files to it
692
+
693
+ Branch Creation:
694
+ When creating a new branch without --from or --from-current:
695
+ 1. Auto-detects main upstream branch (origin/main, origin/master, etc.)
696
+ 2. Falls back to configured main branch in .git/config (agency.mainBranch)
697
+ 3. In --silent mode, a base branch must already be configured
698
+
699
+ When using --from with an agency source branch:
700
+ 1. Verifies the emit branch exists for the source branch
701
+ 2. Uses the emit branch as the actual base to avoid agency files
702
+ 3. Fails if emit branch doesn't exist (run 'agency emit' first)
703
+
704
+ Notes:
705
+ - Files are created at the git repository root, not the current directory
706
+ - If files already exist in the repository, they will not be overwritten
707
+ - Template selection is stored in .git/config (not committed)
708
+ - To edit TASK.md after creation, use 'agency edit'
709
+ `
710
+
711
+ export const taskEdit = (options: TaskEditOptions = {}) =>
712
+ taskEditEffect(options)