@markjaquith/agency 1.7.0 → 1.8.1

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.
package/cli.ts CHANGED
@@ -30,6 +30,7 @@ import { TemplateService } from "./src/services/TemplateService"
30
30
  import { OpencodeService } from "./src/services/OpencodeService"
31
31
  import { ClaudeService } from "./src/services/ClaudeService"
32
32
  import { FilterRepoService } from "./src/services/FilterRepoService"
33
+ import { FormatterService } from "./src/services/FormatterService"
33
34
 
34
35
  // Create CLI layer with all services
35
36
  const CliLayer = Layer.mergeAll(
@@ -41,6 +42,7 @@ const CliLayer = Layer.mergeAll(
41
42
  OpencodeService.Default,
42
43
  ClaudeService.Default,
43
44
  FilterRepoService.Default,
45
+ FormatterService.Default,
44
46
  )
45
47
 
46
48
  /**
@@ -334,6 +336,7 @@ const commands: Record<string, Command> = {
334
336
  from: options.from,
335
337
  fromCurrent: options["from-current"],
336
338
  continue: options.continue,
339
+ squash: options.squash,
337
340
  }),
338
341
  )
339
342
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Manages personal agents files",
5
5
  "keywords": [
6
6
  "agents",
@@ -43,7 +43,7 @@ export const emit = (options: EmitOptions = {}) =>
43
43
  yield* withBranchProtection(gitRoot, emitCore(gitRoot, options))
44
44
  })
45
45
 
46
- const emitCore = (gitRoot: string, options: EmitOptions) =>
46
+ export const emitCore = (gitRoot: string, options: EmitOptions) =>
47
47
  Effect.gen(function* () {
48
48
  const { force = false, verbose = false, skipFilter = false } = options
49
49
  const { log, verboseLog } = createLoggers(options)
@@ -0,0 +1,266 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { task } from "./task"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ initAgency,
9
+ fileExists,
10
+ readFile,
11
+ runTestEffect,
12
+ getCurrentBranch,
13
+ getGitOutput,
14
+ } from "../test-utils"
15
+
16
+ describe("task --continue --squash", () => {
17
+ let tempDir: string
18
+ let originalCwd: string
19
+ let originalConfigDir: string | undefined
20
+
21
+ beforeEach(async () => {
22
+ tempDir = await createTempDir()
23
+ originalCwd = process.cwd()
24
+ originalConfigDir = process.env.AGENCY_CONFIG_DIR
25
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
26
+ })
27
+
28
+ afterEach(async () => {
29
+ process.chdir(originalCwd)
30
+ if (originalConfigDir !== undefined) {
31
+ process.env.AGENCY_CONFIG_DIR = originalConfigDir
32
+ } else {
33
+ delete process.env.AGENCY_CONFIG_DIR
34
+ }
35
+ if (
36
+ process.env.AGENCY_CONFIG_DIR &&
37
+ process.env.AGENCY_CONFIG_DIR !== originalConfigDir
38
+ ) {
39
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
40
+ }
41
+ await cleanupTempDir(tempDir)
42
+ })
43
+
44
+ test("fails when --squash is used without --continue", async () => {
45
+ await initGitRepo(tempDir)
46
+ process.chdir(tempDir)
47
+
48
+ await initAgency(tempDir, "test")
49
+
50
+ await expect(
51
+ runTestEffect(task({ silent: true, squash: true, emit: "some-branch" })),
52
+ ).rejects.toThrow("--squash flag can only be used with --continue")
53
+ })
54
+
55
+ test("squashes emitted commits into a single commit on new branch", async () => {
56
+ await initGitRepo(tempDir)
57
+ process.chdir(tempDir)
58
+
59
+ await initAgency(tempDir, "test")
60
+
61
+ // Create a task branch with agency files
62
+ await runTestEffect(
63
+ task({ silent: true, emit: "feature-v1", task: "Original task" }),
64
+ )
65
+
66
+ // Add two code commits to the branch
67
+ await Bun.write(join(tempDir, "feature-a.txt"), "feature A content")
68
+ await Bun.spawn(["git", "add", "feature-a.txt"], {
69
+ cwd: tempDir,
70
+ stdout: "pipe",
71
+ stderr: "pipe",
72
+ }).exited
73
+ await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add feature A"], {
74
+ cwd: tempDir,
75
+ stdout: "pipe",
76
+ stderr: "pipe",
77
+ }).exited
78
+
79
+ await Bun.write(join(tempDir, "feature-b.txt"), "feature B content")
80
+ await Bun.spawn(["git", "add", "feature-b.txt"], {
81
+ cwd: tempDir,
82
+ stdout: "pipe",
83
+ stderr: "pipe",
84
+ }).exited
85
+ await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add feature B"], {
86
+ cwd: tempDir,
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ }).exited
90
+
91
+ // Now continue with squash
92
+ await runTestEffect(
93
+ task({
94
+ silent: true,
95
+ continue: true,
96
+ squash: true,
97
+ emit: "feature-v2",
98
+ task: "New task for v2",
99
+ }),
100
+ )
101
+
102
+ // Verify we're on the new branch
103
+ const newBranch = await getCurrentBranch(tempDir)
104
+ expect(newBranch).toBe("agency--feature-v2")
105
+
106
+ // Verify both feature files exist (squash brought them in)
107
+ expect(await fileExists(join(tempDir, "feature-a.txt"))).toBe(true)
108
+ expect(await fileExists(join(tempDir, "feature-b.txt"))).toBe(true)
109
+
110
+ // Verify agency files exist
111
+ expect(await fileExists(join(tempDir, "agency.json"))).toBe(true)
112
+ expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
113
+ expect(await fileExists(join(tempDir, "AGENCY.md"))).toBe(true)
114
+
115
+ // Verify TASK.md has FRESH content (not the old "Original task")
116
+ const taskContent = await readFile(join(tempDir, "TASK.md"))
117
+ expect(taskContent).toContain("New task for v2")
118
+ expect(taskContent).not.toContain("Original task")
119
+
120
+ // Verify agency.json has updated emitBranch
121
+ const agencyJson = JSON.parse(await readFile(join(tempDir, "agency.json")))
122
+ expect(agencyJson.emitBranch).toBe("feature-v2")
123
+
124
+ // Verify commit history: should be initial commit + squash commit + agency files commit
125
+ // (not the individual "Add feature A" and "Add feature B" commits)
126
+ const logOutput = await getGitOutput(tempDir, [
127
+ "log",
128
+ "--oneline",
129
+ "--format=%s",
130
+ ])
131
+ const commits = logOutput.trim().split("\n")
132
+
133
+ // Should NOT contain individual feature commits
134
+ expect(logOutput).not.toContain("Add feature A")
135
+ expect(logOutput).not.toContain("Add feature B")
136
+
137
+ // Should contain a squash commit
138
+ expect(logOutput).toContain("squash: prior work from feature-v1")
139
+ })
140
+
141
+ test("creates fresh TASK.md with default placeholder when no task provided in silent mode", async () => {
142
+ await initGitRepo(tempDir)
143
+ process.chdir(tempDir)
144
+
145
+ await initAgency(tempDir, "test")
146
+
147
+ // Create a task branch
148
+ await runTestEffect(
149
+ task({ silent: true, emit: "feature-v1", task: "Old task" }),
150
+ )
151
+
152
+ // Add a code commit
153
+ await Bun.write(join(tempDir, "code.txt"), "code content")
154
+ await Bun.spawn(["git", "add", "code.txt"], {
155
+ cwd: tempDir,
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ }).exited
159
+ await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add code"], {
160
+ cwd: tempDir,
161
+ stdout: "pipe",
162
+ stderr: "pipe",
163
+ }).exited
164
+
165
+ // Continue with squash but no --task option (silent mode)
166
+ await runTestEffect(
167
+ task({
168
+ silent: true,
169
+ continue: true,
170
+ squash: true,
171
+ emit: "feature-v2",
172
+ }),
173
+ )
174
+
175
+ // TASK.md should have the default placeholder, not the old task
176
+ const taskContent = await readFile(join(tempDir, "TASK.md"))
177
+ expect(taskContent).toContain("{task}")
178
+ expect(taskContent).not.toContain("Old task")
179
+ })
180
+
181
+ test("handles empty squash when old branch has no code commits", async () => {
182
+ await initGitRepo(tempDir)
183
+ process.chdir(tempDir)
184
+
185
+ await initAgency(tempDir, "test")
186
+
187
+ // Create a task branch WITHOUT any code commits
188
+ await runTestEffect(
189
+ task({ silent: true, emit: "feature-v1", task: "Just agency files" }),
190
+ )
191
+
192
+ // Continue with squash - should succeed even with no code changes
193
+ await runTestEffect(
194
+ task({
195
+ silent: true,
196
+ continue: true,
197
+ squash: true,
198
+ emit: "feature-v2",
199
+ task: "New task",
200
+ }),
201
+ )
202
+
203
+ // Verify we're on the new branch
204
+ const newBranch = await getCurrentBranch(tempDir)
205
+ expect(newBranch).toBe("agency--feature-v2")
206
+
207
+ // Verify agency files exist
208
+ expect(await fileExists(join(tempDir, "agency.json"))).toBe(true)
209
+ expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
210
+
211
+ // Verify fresh TASK.md
212
+ const taskContent = await readFile(join(tempDir, "TASK.md"))
213
+ expect(taskContent).toContain("New task")
214
+ })
215
+
216
+ test("preserves other agency files while creating fresh TASK.md", async () => {
217
+ await initGitRepo(tempDir)
218
+ process.chdir(tempDir)
219
+
220
+ await initAgency(tempDir, "test")
221
+
222
+ // Create a task branch
223
+ await runTestEffect(
224
+ task({ silent: true, emit: "feature-v1", task: "Old task" }),
225
+ )
226
+
227
+ // Read the original AGENCY.md content
228
+ const originalAgencyMd = await readFile(join(tempDir, "AGENCY.md"))
229
+
230
+ // Add a code commit
231
+ await Bun.write(join(tempDir, "code.txt"), "code")
232
+ await Bun.spawn(["git", "add", "code.txt"], {
233
+ cwd: tempDir,
234
+ stdout: "pipe",
235
+ stderr: "pipe",
236
+ }).exited
237
+ await Bun.spawn(["git", "commit", "--no-verify", "-m", "Add code"], {
238
+ cwd: tempDir,
239
+ stdout: "pipe",
240
+ stderr: "pipe",
241
+ }).exited
242
+
243
+ // Continue with squash
244
+ await runTestEffect(
245
+ task({
246
+ silent: true,
247
+ continue: true,
248
+ squash: true,
249
+ emit: "feature-v2",
250
+ task: "New task",
251
+ }),
252
+ )
253
+
254
+ // AGENCY.md should be carried forward (not fresh)
255
+ const newAgencyMd = await readFile(join(tempDir, "AGENCY.md"))
256
+ expect(newAgencyMd).toBe(originalAgencyMd)
257
+
258
+ // TASK.md should be fresh
259
+ const taskContent = await readFile(join(tempDir, "TASK.md"))
260
+ expect(taskContent).toContain("New task")
261
+ expect(taskContent).not.toContain("Old task")
262
+
263
+ // opencode.json should be carried forward
264
+ expect(await fileExists(join(tempDir, "opencode.json"))).toBe(true)
265
+ })
266
+ })
@@ -21,6 +21,8 @@ import {
21
21
  } from "../utils/pr-branch"
22
22
  import { getTopLevelDir, dirToGlobPattern } from "../utils/glob"
23
23
  import { AGENCY_REMOVE_COMMIT } from "../constants"
24
+ import { emitCore } from "./emit"
25
+ import { withBranchProtection } from "../utils/effect"
24
26
 
25
27
  interface TaskOptions extends BaseCommandOptions {
26
28
  path?: string
@@ -30,6 +32,7 @@ interface TaskOptions extends BaseCommandOptions {
30
32
  from?: string
31
33
  fromCurrent?: boolean
32
34
  continue?: boolean
35
+ squash?: boolean
33
36
  }
34
37
 
35
38
  interface TaskEditOptions extends BaseCommandOptions {}
@@ -37,10 +40,13 @@ interface TaskEditOptions extends BaseCommandOptions {}
37
40
  /**
38
41
  * Continue a task by creating a new branch with the same agency files.
39
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.
40
46
  */
41
47
  const taskContinue = (options: TaskOptions) =>
42
48
  Effect.gen(function* () {
43
- const { silent = false } = options
49
+ const { silent = false, squash = false } = options
44
50
  const { log, verboseLog } = createLoggers(options)
45
51
 
46
52
  const git = yield* GitService
@@ -128,6 +134,11 @@ const taskContinue = (options: TaskOptions) =>
128
134
  // Read all the existing files content before switching branches
129
135
  const fileContents = new Map<string, string>()
130
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
+ }
131
142
  const filePath = resolve(targetPath, file)
132
143
  const exists = yield* fs.exists(filePath)
133
144
  if (exists) {
@@ -139,6 +150,26 @@ const taskContinue = (options: TaskOptions) =>
139
150
  }
140
151
  }
141
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
+ ),
168
+ )
169
+ }
170
+ }
171
+ }
172
+
142
173
  // Prompt for new branch name if not provided
143
174
  let branchName = options.emit || options.branch
144
175
  if (!branchName) {
@@ -174,6 +205,43 @@ const taskContinue = (options: TaskOptions) =>
174
205
  )
175
206
  }
176
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
+
177
245
  // Determine base branch to branch from
178
246
  let baseBranchToBranchFrom: string | undefined
179
247
 
@@ -227,10 +295,70 @@ const taskContinue = (options: TaskOptions) =>
227
295
  )
228
296
  log(done(`Created and switched to ${highlight.branch(sourceBranchName)}`))
229
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
+ )
341
+ log(
342
+ done(
343
+ `Squashed prior work from ${highlight.branch(oldEmitBranchName)}`,
344
+ ),
345
+ )
346
+ }
347
+ }
348
+
230
349
  // Calculate the new emit branch name
231
350
  const newEmitBranchName = makeEmitBranchName(branchName, config.emitBranch)
232
351
  verboseLog(`New emit branch name: ${newEmitBranchName}`)
233
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
+
234
362
  // Write all the files to the new branch
235
363
  const createdFiles: string[] = []
236
364
 
@@ -287,7 +415,10 @@ const taskContinue = (options: TaskOptions) =>
287
415
  yield* Effect.gen(function* () {
288
416
  yield* git.gitAdd(createdFiles, targetPath)
289
417
  // Format: chore: agency task --continue (baseBranch) originalSource => newSource => newEmit
290
- const commitMessage = `chore: agency task --continue (${baseBranchToBranchFrom}) ${currentBranch} → ${sourceBranchName} → ${newEmitBranchName}`
418
+ const continueFlag = squash
419
+ ? "agency task --continue --squash"
420
+ : "agency task --continue"
421
+ const commitMessage = `chore: ${continueFlag} (${baseBranchToBranchFrom}) ${currentBranch} → ${sourceBranchName} → ${newEmitBranchName}`
291
422
  yield* git.gitCommit(commitMessage, targetPath, {
292
423
  noVerify: true,
293
424
  })
@@ -304,7 +435,9 @@ const taskContinue = (options: TaskOptions) =>
304
435
 
305
436
  log(
306
437
  info(
307
- `Continued task with ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`,
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)}`,
308
441
  ),
309
442
  )
310
443
  })
@@ -314,6 +447,13 @@ export const task = (options: TaskOptions = {}) =>
314
447
  const { silent = false, verbose = false } = options
315
448
  const { log, verboseLog } = createLoggers(options)
316
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
+
317
457
  // Handle --continue flag
318
458
  if (options.continue) {
319
459
  return yield* taskContinue(options)
@@ -1104,6 +1244,8 @@ Options:
1104
1244
  --from <branch> Branch to branch from instead of main upstream branch
1105
1245
  --from-current Initialize on current branch instead of creating a new one
1106
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)
1107
1249
 
1108
1250
  Continue Mode (--continue):
1109
1251
  After a PR is merged, use '--continue' to create a new branch that preserves
@@ -1117,6 +1259,20 @@ Continue Mode (--continue):
1117
1259
  3. A new branch is created from main with all your agency files
1118
1260
  4. The emitBranch in agency.json is updated for the new branch
1119
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
+
1120
1276
  Base Branch Selection:
1121
1277
  By default, 'agency task' fetches from the remote and branches from the latest
1122
1278
  main upstream branch (e.g., origin/main). You can override this behavior with:
@@ -1133,6 +1289,7 @@ Examples:
1133
1289
  agency task my-feature --from develop # Create 'my-feature' from 'develop'
1134
1290
  agency task --from-current # Initialize on current branch (no new branch)
1135
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
1136
1293
 
1137
1294
  Template Workflow:
1138
1295
  1. Run 'agency init' to select template (saved to .git/config)