@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
@@ -1,700 +0,0 @@
1
- import { Effect } from "effect"
2
- import type { BaseCommandOptions } from "../utils/command"
3
- import { GitService } from "../services/GitService"
4
- import { ConfigService } from "../services/ConfigService"
5
- import { FileSystemService } from "../services/FileSystemService"
6
- import { FilterRepoService } from "../services/FilterRepoService"
7
- import {
8
- makeEmitBranchName,
9
- makeSourceBranchName,
10
- extractCleanBranch,
11
- extractCleanFromEmit,
12
- } from "../utils/pr-branch"
13
- import {
14
- getFilesToFilter,
15
- readAgencyMetadata,
16
- writeAgencyMetadata,
17
- } from "../types"
18
- import highlight, { done } from "../utils/colors"
19
- import {
20
- createLoggers,
21
- ensureGitRepo,
22
- resolveBaseBranch,
23
- withBranchProtection,
24
- } from "../utils/effect"
25
- import { resolveGitInternalPath } from "../utils/git-path"
26
- import { withSpinner } from "../utils/spinner"
27
- import { AGENCY_REMOVE_COMMIT } from "../constants"
28
-
29
- interface EmitOptions extends BaseCommandOptions {
30
- emit?: string
31
- branch?: string // Deprecated: use emit instead
32
- baseBranch?: string
33
- force?: boolean
34
- /** Skip the git-filter-repo step (for testing) */
35
- skipFilter?: boolean
36
- }
37
-
38
- export const emit = (options: EmitOptions = {}) =>
39
- Effect.gen(function* () {
40
- const gitRoot = yield* ensureGitRepo()
41
-
42
- // Wrap the entire emit operation with branch protection
43
- // This ensures we return to the original branch on Ctrl-C interrupt
44
- yield* withBranchProtection(gitRoot, emitCore(gitRoot, options))
45
- })
46
-
47
- export const emitCore = (gitRoot: string, options: EmitOptions) =>
48
- Effect.gen(function* () {
49
- const { force = false, verbose = false, skipFilter = 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 filterRepo = yield* FilterRepoService
56
-
57
- // Check for uncommitted changes
58
- const statusOutput = yield* git.getStatus(gitRoot)
59
-
60
- if (statusOutput && statusOutput.trim().length > 0) {
61
- return yield* Effect.fail(
62
- new Error(
63
- `You have uncommitted changes. Please commit or stash them before emitting.\n` +
64
- `The emit command performs internal checkouts that would destroy uncommitted work.\n` +
65
- `Run 'git status' to see the changes.`,
66
- ),
67
- )
68
- }
69
-
70
- verboseLog(`Working directory is clean`)
71
-
72
- // Check if git-filter-repo is installed
73
- const hasFilterRepo = yield* filterRepo.isInstalled()
74
- if (!hasFilterRepo) {
75
- const isMac = process.platform === "darwin"
76
- const installInstructions = isMac
77
- ? "Please install it via Homebrew: brew install git-filter-repo"
78
- : "Please install it using your package manager. See: https://github.com/newren/git-filter-repo/blob/main/INSTALL.md"
79
- return yield* Effect.fail(
80
- new Error(`git-filter-repo is not installed. ${installInstructions}`),
81
- )
82
- }
83
-
84
- // Load config
85
- const config = yield* configService.loadConfig()
86
-
87
- // Get current branch
88
- let currentBranch = yield* git.getCurrentBranch(gitRoot)
89
-
90
- // Check if current branch is an emit branch (doesn't match source pattern)
91
- // If so, try to find and switch to the corresponding source branch
92
- const possibleCleanBranch = extractCleanFromEmit(
93
- currentBranch,
94
- config.emitBranch,
95
- )
96
- if (possibleCleanBranch) {
97
- // This looks like an emit branch, try to find the source branch
98
- const possibleSourceBranch = makeSourceBranchName(
99
- possibleCleanBranch,
100
- config.sourceBranchPattern,
101
- )
102
- const sourceExists = yield* git.branchExists(
103
- gitRoot,
104
- possibleSourceBranch,
105
- )
106
- if (sourceExists) {
107
- // Switch to the source branch and continue
108
- verboseLog(
109
- `Currently on emit branch ${highlight.branch(currentBranch)}, switching to source branch ${highlight.branch(possibleSourceBranch)}`,
110
- )
111
- yield* git.checkoutBranch(gitRoot, possibleSourceBranch)
112
- currentBranch = possibleSourceBranch
113
- }
114
- }
115
-
116
- // First, check if agency.json already has emitBranch set (source of truth)
117
- const metadata = yield* Effect.tryPromise({
118
- try: () => readAgencyMetadata(gitRoot),
119
- catch: () => null,
120
- })
121
-
122
- let emitBranchName: string
123
-
124
- // Support both --emit (new) and --branch (deprecated)
125
- const explicitBranchName = options.emit || options.branch
126
-
127
- if (explicitBranchName) {
128
- // Explicit branch name provided via CLI
129
- emitBranchName = explicitBranchName
130
- } else if (metadata?.emitBranch) {
131
- // Use emitBranch from agency.json (source of truth)
132
- emitBranchName = metadata.emitBranch
133
- verboseLog(
134
- `Using emit branch from agency.json: ${highlight.branch(emitBranchName)}`,
135
- )
136
- } else {
137
- // Compute emit branch name from patterns
138
- // Extract clean branch from source branch pattern
139
- // If the branch matches the source pattern, extract the clean name
140
- // Otherwise, treat the current branch as a "legacy" branch (the clean name itself)
141
- const cleanBranch =
142
- extractCleanBranch(currentBranch, config.sourceBranchPattern) ||
143
- currentBranch
144
- emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
145
- }
146
-
147
- // Check and update agency.json with emitBranch if needed
148
- yield* ensureEmitBranchInMetadata(gitRoot, currentBranch, emitBranchName)
149
-
150
- // Find the base branch this was created from
151
- const baseBranch = yield* resolveBaseBranch(gitRoot, options.baseBranch)
152
-
153
- verboseLog(`Using base branch: ${highlight.branch(baseBranch)}`)
154
-
155
- // Get the fork-point (where the branch actually forked from, using reflog)
156
- // This is more accurate than merge-base because it accounts for rebases
157
- const forkPoint = yield* findBestForkPoint(
158
- gitRoot,
159
- currentBranch,
160
- baseBranch,
161
- options,
162
- )
163
-
164
- verboseLog(`Branch forked at commit: ${highlight.commit(forkPoint)}`)
165
-
166
- // Create or reset emit branch from current branch
167
- let branchExisted = false
168
- const createBranch = Effect.gen(function* () {
169
- const existed = yield* createOrResetBranchEffect(
170
- gitRoot,
171
- currentBranch,
172
- emitBranchName,
173
- )
174
- branchExisted = existed
175
-
176
- yield* configureEmitBranchTracking(gitRoot, emitBranchName, verboseLog)
177
- })
178
-
179
- yield* withSpinner(createBranch, {
180
- text: `Creating emit branch ${highlight.branch(emitBranchName)}`,
181
- successText: `${branchExisted ? "Recreated" : "Created"} emit branch ${highlight.branch(emitBranchName)}`,
182
- enabled: !options.silent && !verbose,
183
- })
184
-
185
- // Skip filtering if requested (for testing)
186
- if (skipFilter) {
187
- verboseLog("Skipping git-filter-repo (skipFilter=true)")
188
- // Just switch back to source branch
189
- yield* git.checkoutBranch(gitRoot, currentBranch)
190
- log(done(`Emitted ${highlight.branch(emitBranchName)} (filter skipped)`))
191
- return
192
- }
193
-
194
- verboseLog(
195
- `Filtering backpack files from commits in range: ${highlight.commit(forkPoint.substring(0, 8))}..${highlight.branch(emitBranchName)}`,
196
- )
197
- verboseLog(
198
- `Files will revert to their state at fork-point (base branch: ${highlight.branch(baseBranch)})`,
199
- )
200
-
201
- // Filter backpack files
202
- const filterOperation = Effect.gen(function* () {
203
- // Resolve the actual git-filter-repo state directory.
204
- // In linked worktrees this lives under the worktree git dir, not repo/.git.
205
- const filterRepoDir = yield* getFilterRepoStateDir(gitRoot)
206
- verboseLog(
207
- `Cleaning git-filter-repo state in ${highlight.file(filterRepoDir)}`,
208
- )
209
- yield* fs.deleteDirectory(filterRepoDir)
210
- verboseLog("Cleaned up previous git-filter-repo state")
211
-
212
- // Get files to filter from agency.json metadata
213
- const filesToFilter = yield* Effect.tryPromise({
214
- try: () => getFilesToFilter(gitRoot),
215
- catch: (error) => new Error(`Failed to get files to filter: ${error}`),
216
- })
217
- verboseLog(`Files to filter: ${filesToFilter.join(", ")}`)
218
-
219
- // Run git-filter-repo with:
220
- // 1. Path filtering to remove backpack files
221
- // 2. Commit callback to drop file changes from commits marked with AGENCY_REMOVE_COMMIT
222
- // (clearing file_changes makes the commit empty and it gets pruned, while preserving tree state)
223
- const filterRepoArgs = [
224
- ...filesToFilter.flatMap((f) => ["--path", f]),
225
- "--invert-paths",
226
- "--commit-callback",
227
- // Clear file changes from commits with AGENCY_REMOVE_COMMIT marker
228
- // This makes the commit empty (which gets pruned) while preserving the tree state
229
- `if b"${AGENCY_REMOVE_COMMIT}" in commit.message: commit.file_changes = []`,
230
- "--force",
231
- "--refs",
232
- `${forkPoint}..${emitBranchName}`,
233
- ]
234
-
235
- verboseLog(`git-filter-repo refs: ${forkPoint}..${emitBranchName}`)
236
- verboseLog(
237
- `git-filter-repo will evaluate ${filesToFilter.length} filtered path${filesToFilter.length === 1 ? "" : "s"}`,
238
- )
239
- yield* logFilterPreflight(
240
- gitRoot,
241
- forkPoint,
242
- emitBranchName,
243
- filesToFilter,
244
- verboseLog,
245
- )
246
-
247
- yield* filterRepo.run(gitRoot, filterRepoArgs, {
248
- env: { GIT_CONFIG_GLOBAL: "" },
249
- verboseLog,
250
- streamOutput: verbose,
251
- progressIntervalMs: 5_000,
252
- })
253
-
254
- verboseLog("git-filter-repo completed")
255
-
256
- // Switch back to source branch (git-filter-repo may have checked out the emit branch)
257
- yield* git.checkoutBranch(gitRoot, currentBranch)
258
- })
259
-
260
- yield* withSpinner(filterOperation, {
261
- text: "Filtering backpack files from branch history",
262
- successText: "Filtered backpack files from branch history",
263
- enabled: !options.silent && !verbose,
264
- })
265
-
266
- yield* validateClaudeDoesNotReferenceAgency(gitRoot, emitBranchName)
267
-
268
- log(done(`Emitted ${highlight.branch(emitBranchName)}`))
269
- })
270
-
271
- const validateClaudeDoesNotReferenceAgency = (
272
- gitRoot: string,
273
- emitBranchName: string,
274
- ) =>
275
- Effect.gen(function* () {
276
- const git = yield* GitService
277
- const claudeContents = yield* git.getFileAtRef(
278
- gitRoot,
279
- emitBranchName,
280
- "CLAUDE.md",
281
- )
282
-
283
- if (!claudeContents?.includes("@AGENCY.md")) {
284
- return
285
- }
286
-
287
- return yield* Effect.fail(
288
- new Error(
289
- `CLAUDE.md on emitted branch ${emitBranchName} still references @AGENCY.md. Remove the agency file reference before emitting.`,
290
- ),
291
- )
292
- })
293
-
294
- const logFilterPreflight = (
295
- gitRoot: string,
296
- forkPoint: string,
297
- emitBranchName: string,
298
- filesToFilter: readonly string[],
299
- verboseLog: (message: string) => void,
300
- ) =>
301
- Effect.gen(function* () {
302
- const git = yield* GitService
303
- const range = `${forkPoint}..${emitBranchName}`
304
- const summarize = (value: string) => value.trim() || "0"
305
-
306
- const commitCount = yield* git
307
- .runGitCommand(["git", "rev-list", "--count", range], gitRoot, {
308
- captureOutput: true,
309
- })
310
- .pipe(Effect.option)
311
-
312
- if (commitCount._tag === "Some" && commitCount.value.exitCode === 0) {
313
- verboseLog(
314
- `Commit range contains ${summarize(commitCount.value.stdout)} commit${summarize(commitCount.value.stdout) === "1" ? "" : "s"}`,
315
- )
316
- } else {
317
- verboseLog("Unable to count commits in git-filter-repo range")
318
- }
319
-
320
- if (filesToFilter.length === 0) {
321
- verboseLog("No filtered paths were resolved before git-filter-repo")
322
- return
323
- }
324
-
325
- const pathArgs = ["--", ...filesToFilter]
326
- const matchingCommitCount = yield* git
327
- .runGitCommand(
328
- ["git", "rev-list", "--count", range, ...pathArgs],
329
- gitRoot,
330
- { captureOutput: true },
331
- )
332
- .pipe(Effect.option)
333
-
334
- if (
335
- matchingCommitCount._tag === "Some" &&
336
- matchingCommitCount.value.exitCode === 0
337
- ) {
338
- verboseLog(
339
- `Filtered paths are touched by ${summarize(matchingCommitCount.value.stdout)} commit${summarize(matchingCommitCount.value.stdout) === "1" ? "" : "s"} in the range`,
340
- )
341
- } else {
342
- verboseLog("Unable to count commits touching filtered paths")
343
- }
344
-
345
- const changedFiles = yield* git
346
- .runGitCommand(
347
- ["git", "diff", "--name-only", range, ...pathArgs],
348
- gitRoot,
349
- { captureOutput: true },
350
- )
351
- .pipe(Effect.option)
352
-
353
- if (changedFiles._tag === "Some" && changedFiles.value.exitCode === 0) {
354
- const changedFileCount = changedFiles.value.stdout
355
- .split("\n")
356
- .filter(Boolean).length
357
- verboseLog(
358
- `Filtered path diff contains ${changedFileCount} changed file${changedFileCount === 1 ? "" : "s"}`,
359
- )
360
- } else {
361
- verboseLog("Unable to count changed filtered files")
362
- }
363
- })
364
-
365
- // Helper: Ensure emitBranch is set in agency.json metadata
366
- const ensureEmitBranchInMetadata = (
367
- gitRoot: string,
368
- currentBranch: string,
369
- emitBranchName: string,
370
- ) =>
371
- Effect.gen(function* () {
372
- const git = yield* GitService
373
-
374
- // Read existing metadata
375
- const metadata = yield* Effect.tryPromise({
376
- try: () => readAgencyMetadata(gitRoot),
377
- catch: (error) => new Error(`Failed to read agency metadata: ${error}`),
378
- })
379
-
380
- // If no metadata exists, skip this step
381
- if (!metadata) {
382
- return
383
- }
384
-
385
- // If emitBranch is already set, nothing to do
386
- if (metadata.emitBranch) {
387
- return
388
- }
389
-
390
- // Add emitBranch to metadata
391
- const updatedMetadata = {
392
- ...metadata,
393
- emitBranch: emitBranchName,
394
- }
395
-
396
- // Write updated metadata
397
- yield* Effect.tryPromise({
398
- try: () => writeAgencyMetadata(gitRoot, updatedMetadata),
399
- catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
400
- })
401
-
402
- // Stage and commit the change
403
- yield* git.gitAdd(["agency.json"], gitRoot)
404
- // Note: baseBranch not available in this helper function context
405
- // This commit only happens when backfilling emitBranch in legacy repos
406
- yield* git.gitCommit(
407
- `chore: agency emit ${currentBranch} → ${emitBranchName}`,
408
- gitRoot,
409
- { noVerify: true },
410
- )
411
- })
412
-
413
- /**
414
- * Get the fork point for a branch against a reference.
415
- * Falls back to merge-base if fork-point command fails.
416
- */
417
- const getForkPointOrMergeBase = (
418
- git: GitService,
419
- gitRoot: string,
420
- referenceBranch: string,
421
- featureBranch: string,
422
- ) =>
423
- git.getMergeBaseForkPoint(gitRoot, referenceBranch, featureBranch).pipe(
424
- Effect.catchAll(() =>
425
- // Fork-point can fail if the reflog doesn't have enough history
426
- git.getMergeBase(gitRoot, featureBranch, referenceBranch),
427
- ),
428
- )
429
-
430
- /**
431
- * Get the remote tracking branch name for a local branch.
432
- * Returns null if the branch doesn't track a remote.
433
- */
434
- const getRemoteTrackingBranch = (
435
- git: GitService,
436
- gitRoot: string,
437
- localBranch: string,
438
- ) =>
439
- Effect.gen(function* () {
440
- const remote = yield* git
441
- .getGitConfig(`branch.${localBranch}.remote`, gitRoot)
442
- .pipe(Effect.option)
443
-
444
- const remoteBranch = yield* git
445
- .getGitConfig(`branch.${localBranch}.merge`, gitRoot)
446
- .pipe(Effect.option)
447
-
448
- if (
449
- remote._tag === "Some" &&
450
- remoteBranch._tag === "Some" &&
451
- remoteBranch.value
452
- ) {
453
- return `${remote.value}/${remoteBranch.value.replace("refs/heads/", "")}`
454
- }
455
-
456
- return null
457
- })
458
-
459
- const getFilterRepoStateDir = (gitRoot: string) =>
460
- Effect.gen(function* () {
461
- const git = yield* GitService
462
- const result = yield* git.runGitCommand(
463
- ["git", "rev-parse", "--git-path", "filter-repo"],
464
- gitRoot,
465
- { captureOutput: true },
466
- )
467
-
468
- const gitPath = result.stdout.trim()
469
- if (result.exitCode !== 0 || !gitPath) {
470
- return `${gitRoot}/.git/filter-repo`
471
- }
472
-
473
- return resolveGitInternalPath(gitRoot, gitPath)
474
- })
475
-
476
- const configureEmitBranchTracking = (
477
- gitRoot: string,
478
- emitBranchName: string,
479
- verboseLog: (message: string) => void,
480
- ) =>
481
- Effect.gen(function* () {
482
- const git = yield* GitService
483
- const remote = yield* git.resolveRemote(gitRoot).pipe(Effect.option)
484
-
485
- if (remote._tag === "None") {
486
- yield* git.unsetGitConfig(`branch.${emitBranchName}.remote`, gitRoot)
487
- yield* git.unsetGitConfig(`branch.${emitBranchName}.merge`, gitRoot)
488
- return
489
- }
490
-
491
- const remoteBranch = `${remote.value}/${emitBranchName}`
492
- const remoteBranchExists = yield* git.branchExists(gitRoot, remoteBranch)
493
-
494
- if (!remoteBranchExists) {
495
- yield* git.unsetGitConfig(`branch.${emitBranchName}.remote`, gitRoot)
496
- yield* git.unsetGitConfig(`branch.${emitBranchName}.merge`, gitRoot)
497
- verboseLog(
498
- `No existing remote branch ${highlight.branch(remoteBranch)}; leaving ${highlight.branch(emitBranchName)} without upstream`,
499
- )
500
- return
501
- }
502
-
503
- yield* git.setGitConfig(
504
- `branch.${emitBranchName}.remote`,
505
- remote.value,
506
- gitRoot,
507
- )
508
- yield* git.setGitConfig(
509
- `branch.${emitBranchName}.merge`,
510
- `refs/heads/${emitBranchName}`,
511
- gitRoot,
512
- )
513
- verboseLog(
514
- `Set ${highlight.branch(emitBranchName)} upstream to ${highlight.branch(remoteBranch)}`,
515
- )
516
- })
517
-
518
- /**
519
- * Find the best fork point by checking both local and remote tracking branches.
520
- *
521
- * Strategy:
522
- * 1. Get fork-point against the local base branch
523
- * 2. If base branch tracks a remote, also get fork-point against remote tracking branch
524
- * 3. Choose the more recent fork point (prefer remote if local is its ancestor)
525
- */
526
- const findBestForkPoint = (
527
- gitRoot: string,
528
- featureBranch: string,
529
- baseBranch: string,
530
- options: Pick<EmitOptions, "silent" | "verbose">,
531
- ) =>
532
- Effect.gen(function* () {
533
- const git = yield* GitService
534
- const { verboseLog } = createLoggers(options)
535
-
536
- // Strategy 1: Get fork-point against local base branch
537
- const localForkPoint = yield* getForkPointOrMergeBase(
538
- git,
539
- gitRoot,
540
- baseBranch,
541
- featureBranch,
542
- )
543
- verboseLog(
544
- `Fork-point with ${highlight.branch(baseBranch)}: ${highlight.commit(localForkPoint.substring(0, 8))}`,
545
- )
546
-
547
- // Strategy 2: Check if base branch tracks a remote
548
- const remoteTrackingBranch = yield* getRemoteTrackingBranch(
549
- git,
550
- gitRoot,
551
- baseBranch,
552
- )
553
-
554
- if (!remoteTrackingBranch) {
555
- return localForkPoint
556
- }
557
-
558
- // Get fork-point against remote tracking branch
559
- const remoteForkPoint = yield* getForkPointOrMergeBase(
560
- git,
561
- gitRoot,
562
- remoteTrackingBranch,
563
- featureBranch,
564
- )
565
- verboseLog(
566
- `Fork-point with ${highlight.branch(remoteTrackingBranch)}: ${highlight.commit(remoteForkPoint.substring(0, 8))}`,
567
- )
568
-
569
- // Strategy 3: Choose the more recent fork point
570
- // If local fork point is an ancestor of remote, prefer remote (it's more recent)
571
- const localIsAncestorOfRemote = yield* git.isAncestor(
572
- gitRoot,
573
- localForkPoint,
574
- remoteForkPoint,
575
- )
576
-
577
- if (localIsAncestorOfRemote) {
578
- verboseLog(
579
- `Using remote fork-point ${highlight.commit(remoteForkPoint.substring(0, 8))} (local is ancestor)`,
580
- )
581
- return remoteForkPoint
582
- }
583
-
584
- verboseLog(
585
- `Using local fork-point ${highlight.commit(localForkPoint.substring(0, 8))}`,
586
- )
587
- return localForkPoint
588
- })
589
-
590
- // Helper: Create or reset branch
591
- const createOrResetBranchEffect = (
592
- gitRoot: string,
593
- sourceBranch: string,
594
- targetBranch: string,
595
- ) =>
596
- Effect.gen(function* () {
597
- const git = yield* GitService
598
-
599
- const exists = yield* git.branchExists(gitRoot, targetBranch)
600
- const currentBranch = yield* git.getCurrentBranch(gitRoot)
601
-
602
- if (exists) {
603
- // If we're currently on the target branch, switch away first
604
- if (currentBranch === targetBranch) {
605
- yield* git.checkoutBranch(gitRoot, sourceBranch)
606
- }
607
-
608
- // Delete the existing branch
609
- yield* git.deleteBranch(gitRoot, targetBranch, true)
610
- }
611
-
612
- // Create new branch from source
613
- yield* git.createBranch(targetBranch, gitRoot, sourceBranch)
614
-
615
- return exists
616
- })
617
-
618
- export const help = `
619
- Usage: agency emit [base-branch] [options]
620
-
621
- Create an emit branch from the current source branch with backpack files (AGENTS.md, TASK.md, etc.)
622
- reverted to their state on the base branch.
623
-
624
- Source and Emit Branches:
625
- - Source branches: Your working branches with agency-specific files (e.g., agency--feature-foo)
626
- - Emit branches: Clean branches suitable for PRs without agency files (e.g., feature-foo)
627
-
628
- This command creates a clean emit branch from your source branch by filtering out
629
- backpack files. Your source branch remains completely untouched.
630
-
631
- Behavior:
632
- - If a file existed on the base branch: It is reverted to that version
633
- - If a file did NOT exist on base branch: It is completely removed
634
- - Only commits since the branch diverged are rewritten
635
- - This allows you to layer feature-specific instructions on top of base instructions
636
- during development, then remove those modifications when submitting code
637
-
638
- Base Branch Selection:
639
- The command determines the base branch in this order:
640
- 1. Explicitly provided base-branch argument
641
- 2. Branch-specific base branch from agency.json (set by 'agency task')
642
- 3. Repository-level default base branch from .git/config (all branches)
643
- 4. Auto-detected from origin/HEAD or common branches (origin/main, origin/master, etc.)
644
-
645
- The base branch is set when you run 'agency task' to initialize a feature branch.
646
- Set a repository-level default with: agency base set --repo <branch>
647
- Update a branch's base branch with: agency base set <branch>
648
-
649
- Prerequisites:
650
- - git-filter-repo must be installed: brew install git-filter-repo
651
-
652
- Arguments:
653
- base-branch Base branch to compare against (e.g., origin/main)
654
- If not provided, will use saved config or prompt interactively
655
-
656
- Options:
657
- --emit Custom name for emit branch (defaults to pattern from config)
658
- --branch (Deprecated: use --emit) Custom name for emit branch
659
- -f, --force Force emit branch creation even if current branch looks like an emit branch
660
-
661
- Configuration:
662
- ~/.config/agency/agency.json can contain:
663
- {
664
- "sourceBranchPattern": "agency--%branch%", // Pattern for source branch names
665
- "emitBranch": "%branch%" // Pattern for emit branch names
666
- }
667
-
668
- Use %branch% as placeholder for the clean branch name.
669
-
670
- Source Pattern Examples:
671
- "agency--%branch%" -> main becomes agency--main (default)
672
- "wip/%branch%" -> feature becomes wip/feature
673
-
674
- Emit Pattern Examples:
675
- "%branch%" -> agency--main emits to main (default)
676
- "%branch%--PR" -> agency--feature emits to feature--PR
677
- "PR/%branch%" -> agency--feature emits to PR/feature
678
-
679
- Examples:
680
- agency emit # Prompt for base branch (first time) or use saved
681
- agency emit origin/main # Explicitly use origin/main as base branch
682
- agency emit --force # Force creation even from an emit branch
683
-
684
- Notes:
685
- - Emit branch is created from your current branch (not the base)
686
- - Base branch is set when you run 'agency task' to initialize the feature branch
687
- - Only commits since the branch diverged are rewritten (uses merge-base range)
688
- - Backpack files are reverted to their merge-base state (or removed if they didn't exist)
689
- - All commits from the base branch remain unchanged (shared history is preserved)
690
- - Original branch is never modified
691
- - If emit branch exists, it will be deleted and recreated
692
- - Command will refuse to create emit branch from an emit branch unless --force is used
693
-
694
- Important: If using a remote base branch (e.g., origin/main):
695
- - Always fetch before rebasing: git fetch origin && git rebase origin/main
696
- - Or use: git pull --rebase origin main
697
- - This ensures your local origin/main ref is up to date
698
- - If origin/main is stale when you rebase and emit, the emit branch may not be
699
- properly based on the remote, requiring manual rebasing of the emit branch
700
- `