@markjaquith/agency 1.5.2 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Manages personal agents files",
5
5
  "license": "MIT",
6
6
  "author": "Mark Jaquith",
@@ -45,21 +45,21 @@
45
45
  "personal-agents"
46
46
  ],
47
47
  "devDependencies": {
48
- "@types/bun": "latest",
49
- "knip": "^5.70.1",
50
- "prettier": "^3.6.2"
48
+ "@types/bun": "^1.3.6",
49
+ "knip": "^5.82.1",
50
+ "prettier": "^3.8.1"
51
51
  },
52
52
  "peerDependencies": {
53
- "typescript": "^5"
53
+ "typescript": "^5.9.3"
54
54
  },
55
55
  "engines": {
56
56
  "bun": ">=1.0.0"
57
57
  },
58
58
  "dependencies": {
59
59
  "@effect/schema": "^0.75.5",
60
- "effect": "^3.19.6",
60
+ "effect": "^3.19.15",
61
61
  "jsonc-parser": "^3.3.1",
62
- "ora": "^8.1.1"
62
+ "ora": "^8.2.0"
63
63
  },
64
64
  "trustedDependencies": [
65
65
  "@triggi/native-exec"
@@ -224,7 +224,7 @@ describe("emit command - integration tests (requires git-filter-repo)", () => {
224
224
  // Create a new feature branch
225
225
  await createBranch(tempDir, "agency/claude-test")
226
226
 
227
- // Initialize agency on this branch (this will modify CLAUDE.md)
227
+ // Initialize agency on this branch - first commit has agency files (NOT including CLAUDE.md)
228
228
  await Bun.write(
229
229
  join(tempDir, "agency.json"),
230
230
  JSON.stringify({
@@ -235,22 +235,27 @@ describe("emit command - integration tests (requires git-filter-repo)", () => {
235
235
  }),
236
236
  )
237
237
  await Bun.write(join(tempDir, "AGENTS.md"), "# Test AGENTS\n")
238
+ await addAndCommit(
239
+ tempDir,
240
+ "agency.json AGENTS.md",
241
+ "Initialize agency files",
242
+ )
238
243
 
239
- // Simulate what agency task does - inject into CLAUDE.md
244
+ // Simulate what agency task now does - CLAUDE.md modification in a separate commit
245
+ // with AGENCY_REMOVE_COMMIT marker so the commit gets dropped during emit
240
246
  const originalClaude = await Bun.file(join(tempDir, "CLAUDE.md")).text()
241
247
  const modifiedClaude = `${originalClaude}\n# Agency References\n@AGENTS.md\n@TASK.md\n`
242
248
  await Bun.write(join(tempDir, "CLAUDE.md"), modifiedClaude)
243
-
244
249
  await addAndCommit(
245
250
  tempDir,
246
- "agency.json AGENTS.md CLAUDE.md",
247
- "Initialize agency files",
251
+ "CLAUDE.md",
252
+ "chore: agency edit CLAUDE.md\n\nAGENCY_REMOVE_COMMIT",
248
253
  )
249
254
 
250
255
  // Add a feature file
251
256
  await createCommit(tempDir, "Feature commit")
252
257
 
253
- // Create emit branch (this should filter CLAUDE.md)
258
+ // Create emit branch (this should drop the CLAUDE.md modification commit)
254
259
  await runTestEffect(emit({ silent: true, baseBranch: "main" }))
255
260
 
256
261
  // Should still be on source branch
@@ -274,4 +279,271 @@ describe("emit command - integration tests (requires git-filter-repo)", () => {
274
279
  expect(claudeContent).not.toContain("@AGENTS.md")
275
280
  expect(claudeContent).not.toContain("@TASK.md")
276
281
  })
282
+
283
+ describe("AGENCY_REMOVE_COMMIT edge cases", () => {
284
+ test("handles two contiguous AGENCY_REMOVE_COMMIT commits", async () => {
285
+ if (!hasGitFilterRepo) {
286
+ console.log("Skipping test: git-filter-repo not installed")
287
+ return
288
+ }
289
+
290
+ await checkoutBranch(tempDir, "main")
291
+
292
+ // Create two files on main that will be modified
293
+ await Bun.write(join(tempDir, "FILE1.md"), "Original content 1\n")
294
+ await Bun.write(join(tempDir, "FILE2.md"), "Original content 2\n")
295
+ await addAndCommit(tempDir, "FILE1.md FILE2.md", "Add files")
296
+
297
+ await createBranch(tempDir, "agency/contiguous-test")
298
+
299
+ // Create agency.json
300
+ await Bun.write(
301
+ join(tempDir, "agency.json"),
302
+ JSON.stringify({
303
+ version: 1,
304
+ injectedFiles: [],
305
+ template: "test",
306
+ createdAt: new Date().toISOString(),
307
+ }),
308
+ )
309
+ await addAndCommit(tempDir, "agency.json", "Add agency.json")
310
+
311
+ // Two contiguous AGENCY_REMOVE_COMMIT commits
312
+ await Bun.write(join(tempDir, "FILE1.md"), "Modified content 1\n")
313
+ await addAndCommit(
314
+ tempDir,
315
+ "FILE1.md",
316
+ "chore: edit FILE1\n\nAGENCY_REMOVE_COMMIT",
317
+ )
318
+
319
+ await Bun.write(join(tempDir, "FILE2.md"), "Modified content 2\n")
320
+ await addAndCommit(
321
+ tempDir,
322
+ "FILE2.md",
323
+ "chore: edit FILE2\n\nAGENCY_REMOVE_COMMIT",
324
+ )
325
+
326
+ // Add a feature commit after
327
+ await createCommit(tempDir, "Feature commit")
328
+
329
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
330
+ await checkoutBranch(tempDir, "contiguous-test")
331
+
332
+ // Both files should be reverted to original
333
+ const file1 = await Bun.file(join(tempDir, "FILE1.md")).text()
334
+ const file2 = await Bun.file(join(tempDir, "FILE2.md")).text()
335
+ expect(file1).toBe("Original content 1\n")
336
+ expect(file2).toBe("Original content 2\n")
337
+
338
+ // Feature commit should still exist
339
+ const files = await getGitOutput(tempDir, ["ls-files"])
340
+ expect(files).toContain("test.txt")
341
+ })
342
+
343
+ test("handles two non-contiguous AGENCY_REMOVE_COMMIT commits", async () => {
344
+ if (!hasGitFilterRepo) {
345
+ console.log("Skipping test: git-filter-repo not installed")
346
+ return
347
+ }
348
+
349
+ await checkoutBranch(tempDir, "main")
350
+
351
+ await Bun.write(join(tempDir, "FILE1.md"), "Original content 1\n")
352
+ await Bun.write(join(tempDir, "FILE2.md"), "Original content 2\n")
353
+ await addAndCommit(tempDir, "FILE1.md FILE2.md", "Add files")
354
+
355
+ await createBranch(tempDir, "agency/non-contiguous-test")
356
+
357
+ await Bun.write(
358
+ join(tempDir, "agency.json"),
359
+ JSON.stringify({
360
+ version: 1,
361
+ injectedFiles: [],
362
+ template: "test",
363
+ createdAt: new Date().toISOString(),
364
+ }),
365
+ )
366
+ await addAndCommit(tempDir, "agency.json", "Add agency.json")
367
+
368
+ // First AGENCY_REMOVE_COMMIT
369
+ await Bun.write(join(tempDir, "FILE1.md"), "Modified content 1\n")
370
+ await addAndCommit(
371
+ tempDir,
372
+ "FILE1.md",
373
+ "chore: edit FILE1\n\nAGENCY_REMOVE_COMMIT",
374
+ )
375
+
376
+ // Regular commit in between
377
+ await Bun.write(join(tempDir, "feature1.txt"), "feature 1\n")
378
+ await addAndCommit(tempDir, "feature1.txt", "Add feature 1")
379
+
380
+ // Second AGENCY_REMOVE_COMMIT
381
+ await Bun.write(join(tempDir, "FILE2.md"), "Modified content 2\n")
382
+ await addAndCommit(
383
+ tempDir,
384
+ "FILE2.md",
385
+ "chore: edit FILE2\n\nAGENCY_REMOVE_COMMIT",
386
+ )
387
+
388
+ // Another regular commit
389
+ await Bun.write(join(tempDir, "feature2.txt"), "feature 2\n")
390
+ await addAndCommit(tempDir, "feature2.txt", "Add feature 2")
391
+
392
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
393
+ await checkoutBranch(tempDir, "non-contiguous-test")
394
+
395
+ // Both files should be reverted to original
396
+ const file1 = await Bun.file(join(tempDir, "FILE1.md")).text()
397
+ const file2 = await Bun.file(join(tempDir, "FILE2.md")).text()
398
+ expect(file1).toBe("Original content 1\n")
399
+ expect(file2).toBe("Original content 2\n")
400
+
401
+ // Feature commits should still exist
402
+ const files = await getGitOutput(tempDir, ["ls-files"])
403
+ expect(files).toContain("feature1.txt")
404
+ expect(files).toContain("feature2.txt")
405
+ })
406
+
407
+ test("handles AGENCY_REMOVE_COMMIT as first commit after fork point", async () => {
408
+ if (!hasGitFilterRepo) {
409
+ console.log("Skipping test: git-filter-repo not installed")
410
+ return
411
+ }
412
+
413
+ await checkoutBranch(tempDir, "main")
414
+
415
+ await Bun.write(join(tempDir, "EXISTING.md"), "Original content\n")
416
+ await addAndCommit(tempDir, "EXISTING.md", "Add existing file")
417
+
418
+ await createBranch(tempDir, "agency/first-commit-test")
419
+
420
+ // First commit is AGENCY_REMOVE_COMMIT (modifying existing file)
421
+ await Bun.write(join(tempDir, "EXISTING.md"), "Modified content\n")
422
+ await addAndCommit(
423
+ tempDir,
424
+ "EXISTING.md",
425
+ "chore: edit EXISTING\n\nAGENCY_REMOVE_COMMIT",
426
+ )
427
+
428
+ // Then agency.json
429
+ await Bun.write(
430
+ join(tempDir, "agency.json"),
431
+ JSON.stringify({
432
+ version: 1,
433
+ injectedFiles: [],
434
+ template: "test",
435
+ createdAt: new Date().toISOString(),
436
+ }),
437
+ )
438
+ await addAndCommit(tempDir, "agency.json", "Add agency.json")
439
+
440
+ // Feature commit
441
+ await createCommit(tempDir, "Feature commit")
442
+
443
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
444
+ await checkoutBranch(tempDir, "first-commit-test")
445
+
446
+ // File should be reverted to original
447
+ const content = await Bun.file(join(tempDir, "EXISTING.md")).text()
448
+ expect(content).toBe("Original content\n")
449
+
450
+ // Feature commit should exist
451
+ const files = await getGitOutput(tempDir, ["ls-files"])
452
+ expect(files).toContain("test.txt")
453
+ })
454
+
455
+ test("handles AGENCY_REMOVE_COMMIT as last commit", async () => {
456
+ if (!hasGitFilterRepo) {
457
+ console.log("Skipping test: git-filter-repo not installed")
458
+ return
459
+ }
460
+
461
+ await checkoutBranch(tempDir, "main")
462
+
463
+ await Bun.write(join(tempDir, "EXISTING.md"), "Original content\n")
464
+ await addAndCommit(tempDir, "EXISTING.md", "Add existing file")
465
+
466
+ await createBranch(tempDir, "agency/last-commit-test")
467
+
468
+ // agency.json first
469
+ await Bun.write(
470
+ join(tempDir, "agency.json"),
471
+ JSON.stringify({
472
+ version: 1,
473
+ injectedFiles: [],
474
+ template: "test",
475
+ createdAt: new Date().toISOString(),
476
+ }),
477
+ )
478
+ await addAndCommit(tempDir, "agency.json", "Add agency.json")
479
+
480
+ // Feature commit
481
+ await Bun.write(join(tempDir, "feature.txt"), "feature\n")
482
+ await addAndCommit(tempDir, "feature.txt", "Add feature")
483
+
484
+ // Last commit is AGENCY_REMOVE_COMMIT
485
+ await Bun.write(join(tempDir, "EXISTING.md"), "Modified content\n")
486
+ await addAndCommit(
487
+ tempDir,
488
+ "EXISTING.md",
489
+ "chore: edit EXISTING\n\nAGENCY_REMOVE_COMMIT",
490
+ )
491
+
492
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
493
+ await checkoutBranch(tempDir, "last-commit-test")
494
+
495
+ // File should be reverted to original
496
+ const content = await Bun.file(join(tempDir, "EXISTING.md")).text()
497
+ expect(content).toBe("Original content\n")
498
+
499
+ // Feature commit should exist
500
+ const files = await getGitOutput(tempDir, ["ls-files"])
501
+ expect(files).toContain("feature.txt")
502
+ })
503
+
504
+ test("handles AGENCY_REMOVE_COMMIT as only commit (besides agency.json)", async () => {
505
+ if (!hasGitFilterRepo) {
506
+ console.log("Skipping test: git-filter-repo not installed")
507
+ return
508
+ }
509
+
510
+ await checkoutBranch(tempDir, "main")
511
+
512
+ await Bun.write(join(tempDir, "EXISTING.md"), "Original content\n")
513
+ await addAndCommit(tempDir, "EXISTING.md", "Add existing file")
514
+
515
+ await createBranch(tempDir, "agency/only-commit-test")
516
+
517
+ // agency.json
518
+ await Bun.write(
519
+ join(tempDir, "agency.json"),
520
+ JSON.stringify({
521
+ version: 1,
522
+ injectedFiles: [],
523
+ template: "test",
524
+ createdAt: new Date().toISOString(),
525
+ }),
526
+ )
527
+ await addAndCommit(tempDir, "agency.json", "Add agency.json")
528
+
529
+ // Only other commit is AGENCY_REMOVE_COMMIT
530
+ await Bun.write(join(tempDir, "EXISTING.md"), "Modified content\n")
531
+ await addAndCommit(
532
+ tempDir,
533
+ "EXISTING.md",
534
+ "chore: edit EXISTING\n\nAGENCY_REMOVE_COMMIT",
535
+ )
536
+
537
+ await runTestEffect(emit({ silent: true, baseBranch: "main" }))
538
+ await checkoutBranch(tempDir, "only-commit-test")
539
+
540
+ // File should be reverted to original
541
+ const content = await Bun.file(join(tempDir, "EXISTING.md")).text()
542
+ expect(content).toBe("Original content\n")
543
+
544
+ // Emit branch should exist and have the file
545
+ const files = await getGitOutput(tempDir, ["ls-files"])
546
+ expect(files).toContain("EXISTING.md")
547
+ })
548
+ })
277
549
  })
@@ -305,12 +305,13 @@ describe("emit command", () => {
305
305
  const lastCall = getLastCapturedFilterRepoCall()
306
306
  expect(lastCall).toBeDefined()
307
307
 
308
- // Should include paths for base files (TASK.md, AGENCY.md, CLAUDE.md, agency.json)
308
+ // Should include paths for base files (TASK.md, AGENCY.md, agency.json)
309
+ // Note: CLAUDE.md is NOT in base files - it's only filtered if it was created
310
+ // by agency (in which case it would be in injectedFiles)
309
311
  // and injected files (AGENTS.md)
310
312
  expect(lastCall!.args).toContain("--path")
311
313
  expect(lastCall!.args).toContain("TASK.md")
312
314
  expect(lastCall!.args).toContain("AGENCY.md")
313
- expect(lastCall!.args).toContain("CLAUDE.md")
314
315
  expect(lastCall!.args).toContain("agency.json")
315
316
  expect(lastCall!.args).toContain("AGENTS.md")
316
317
 
@@ -336,12 +337,12 @@ describe("emit command", () => {
336
337
  // Create CLAUDE.md as a symlink to AGENTS.md
337
338
  await symlink("AGENTS.md", join(tempDir, "CLAUDE.md"))
338
339
 
339
- // Create agency.json
340
+ // Create agency.json with CLAUDE.md in injectedFiles (since it's no longer in baseFiles)
340
341
  await Bun.write(
341
342
  join(tempDir, "agency.json"),
342
343
  JSON.stringify({
343
344
  version: 1,
344
- injectedFiles: [],
345
+ injectedFiles: ["CLAUDE.md"],
345
346
  template: "test",
346
347
  createdAt: new Date().toISOString(),
347
348
  }),
@@ -197,10 +197,17 @@ const emitCore = (gitRoot: string, options: EmitOptions) =>
197
197
  })
198
198
  verboseLog(`Files to filter: ${filesToFilter.join(", ")}`)
199
199
 
200
- // Run git-filter-repo
200
+ // Run git-filter-repo with:
201
+ // 1. Path filtering to remove backpack files
202
+ // 2. Commit callback to drop file changes from commits marked with AGENCY_REMOVE_COMMIT
203
+ // (clearing file_changes makes the commit empty and it gets pruned, while preserving tree state)
201
204
  const filterRepoArgs = [
202
205
  ...filesToFilter.flatMap((f) => ["--path", f]),
203
206
  "--invert-paths",
207
+ "--commit-callback",
208
+ // Clear file changes from commits with AGENCY_REMOVE_COMMIT marker
209
+ // This makes the commit empty (which gets pruned) while preserving the tree state
210
+ `if b"AGENCY_REMOVE_COMMIT" in commit.message: commit.file_changes = []`,
204
211
  "--force",
205
212
  "--refs",
206
213
  `${forkPoint}..${emitBranchName}`,
@@ -731,12 +731,18 @@ export const task = (options: TaskOptions = {}) =>
731
731
  }
732
732
 
733
733
  // Handle CLAUDE.md injection
734
+ // If CLAUDE.md is created (new file), include it in main commit and add to injectedFiles
735
+ // If CLAUDE.md is modified (existing file), commit separately with AGENCY_REMOVE_COMMIT marker
736
+ // so it can be completely removed during emission
734
737
  const claudeResult = yield* claudeService.injectAgencySection(targetPath)
738
+ let claudeModifiedExisting = false
735
739
  if (claudeResult.created) {
736
740
  createdFiles.push("CLAUDE.md")
741
+ injectedFiles.push("CLAUDE.md")
737
742
  log(done(`Created ${highlight.file("CLAUDE.md")}`))
738
743
  } else if (claudeResult.modified) {
739
- createdFiles.push("CLAUDE.md")
744
+ // Mark that we need a separate commit for CLAUDE.md modification
745
+ claudeModifiedExisting = true
740
746
  log(done(`Updated ${highlight.file("CLAUDE.md")}`))
741
747
  }
742
748
 
@@ -814,6 +820,27 @@ export const task = (options: TaskOptions = {}) =>
814
820
  }),
815
821
  )
816
822
  }
823
+
824
+ // Create separate commit for CLAUDE.md modification with AGENCY_REMOVE_COMMIT marker
825
+ // This commit will be completely removed during emission
826
+ if (claudeModifiedExisting) {
827
+ yield* Effect.gen(function* () {
828
+ yield* git.gitAdd(["CLAUDE.md"], targetPath)
829
+ // The AGENCY_REMOVE_COMMIT marker in the commit body tells emit to drop this commit entirely
830
+ const commitMessage = `chore: agency edit CLAUDE.md\n\nAGENCY_REMOVE_COMMIT`
831
+ yield* git.gitCommit(commitMessage, targetPath, {
832
+ noVerify: true,
833
+ })
834
+ verboseLog(
835
+ "Created CLAUDE.md modification commit (will be removed on emit)",
836
+ )
837
+ }).pipe(
838
+ Effect.catchAll((err) => {
839
+ verboseLog(`Failed to commit CLAUDE.md modification: ${err}`)
840
+ return Effect.void
841
+ }),
842
+ )
843
+ }
817
844
  })
818
845
 
819
846
  // Helper: Create feature branch with interactive prompts
@@ -49,7 +49,7 @@ export class AgencyMetadataService extends Context.Tag("AgencyMetadataService")<
49
49
 
50
50
  /**
51
51
  * Get list of files to filter during PR/merge operations.
52
- * Always includes TASK.md, AGENCY.md, CLAUDE.md, and agency.json, plus any injectedFiles from metadata.
52
+ * Always includes TASK.md, AGENCY.md, and agency.json, plus any injectedFiles from metadata.
53
53
  */
54
54
  readonly getFilesToFilter: (
55
55
  gitRoot: string,
@@ -204,7 +204,7 @@ export const AgencyMetadataServiceLive = Layer.succeed(
204
204
  const metadataPath = join(gitRoot, "agency.json")
205
205
 
206
206
  const exists = yield* fs.exists(metadataPath)
207
- const baseFiles = ["TASK.md", "AGENCY.md", "CLAUDE.md", "agency.json"]
207
+ const baseFiles = ["TASK.md", "AGENCY.md", "agency.json"]
208
208
 
209
209
  if (!exists) {
210
210
  // Resolve symlinks even for base files
@@ -236,7 +236,7 @@ export const AgencyMetadataServiceLive = Layer.succeed(
236
236
  return yield* resolveSymlinkTargets(fs, gitRoot, expandedFiles)
237
237
  }).pipe(
238
238
  Effect.catchAll(() =>
239
- Effect.succeed(["TASK.md", "AGENCY.md", "CLAUDE.md", "agency.json"]),
239
+ Effect.succeed(["TASK.md", "AGENCY.md", "agency.json"]),
240
240
  ),
241
241
  ),
242
242
 
@@ -23,7 +23,7 @@ interface SpawnOptions {
23
23
  /**
24
24
  * Generic error for process execution failures
25
25
  */
26
- export class ProcessError extends Data.TaggedError("ProcessError")<{
26
+ class ProcessError extends Data.TaggedError("ProcessError")<{
27
27
  command: string
28
28
  exitCode: number
29
29
  stderr: string