@markjaquith/agency 0.7.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,11 +10,26 @@ bun install -g @markjaquith/agency
10
10
 
11
11
  ## Primary Commands
12
12
 
13
- ### `agency task [branch-name]`
13
+ ### `agency task <branch-name>`
14
14
 
15
- Initialize `AGENTS.md` and `TASK.md` files using the template you've set for this repo. Commits smuggled files and lands you on that branch.
15
+ Create a new feature branch from the latest `origin/main` and initialize `AGENTS.md` and `TASK.md` files using the template you've set for this repo. Commits smuggled files and lands you on that branch.
16
16
 
17
- ### `agency task edit`
17
+ **Options:**
18
+
19
+ - `--from <branch>` - Branch from a specific branch instead of `origin/main`
20
+ - `--from-current` - Initialize on current branch instead of creating a new one
21
+ - `--continue` - Continue a task by copying agency files to a new branch (after PR merge)
22
+
23
+ **Examples:**
24
+
25
+ ```bash
26
+ agency task my-feature # Create 'my-feature' from latest origin/main
27
+ agency task my-feature --from dev # Create 'my-feature' from 'dev' branch
28
+ agency task --from-current # Initialize on current branch (no new branch)
29
+ agency task --continue my-feature-v2 # Continue task on new branch after PR merge
30
+ ```
31
+
32
+ ### `agency edit`
18
33
 
19
34
  Open `TASK.md` in the system editor for editing. Nice if you have to paste in large amounts of context.
20
35
 
package/cli.ts CHANGED
@@ -426,8 +426,8 @@ Global Options:
426
426
 
427
427
  Examples:
428
428
  agency init # Initialize with template (run first)
429
- agency task # Initialize on current feature branch
430
- agency task my-feature # Create 'my-feature' branch and initialize
429
+ agency task my-feature # Create 'my-feature' branch from origin/main
430
+ agency task --from-current # Initialize on current feature branch
431
431
  agency emit # Emit a branch (prompts for base branch)
432
432
  agency switch # Toggle between source and emitted branch
433
433
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "0.7.3",
3
+ "version": "1.0.0",
4
4
  "description": "Manages personal agents files",
5
5
  "license": "MIT",
6
6
  "author": "Mark Jaquith",
@@ -60,7 +60,7 @@ describe("emit command", () => {
60
60
  // Initialize AGENTS.md and commit in one go
61
61
  await initAgency(tempDir, "test")
62
62
 
63
- await runTestEffect(task({ silent: true }))
63
+ await runTestEffect(task({ silent: true, fromCurrent: true }))
64
64
  await addAndCommit(tempDir, "AGENTS.md", "Add AGENTS.md")
65
65
 
66
66
  // Set up origin/main for git-filter-repo
@@ -64,7 +64,7 @@ describe("merge command", () => {
64
64
  // Initialize AGENTS.md on feature branch
65
65
  await initAgency(tempDir, "test")
66
66
 
67
- await runTestEffect(task({ silent: true }))
67
+ await runTestEffect(task({ silent: true, fromCurrent: true }))
68
68
 
69
69
  // Ensure agency.json has baseBranch set (task should auto-detect it, but ensure it's there)
70
70
  const agencyJsonPath = join(tempDir, "agency.json")
@@ -8,7 +8,10 @@ import {
8
8
  initAgency,
9
9
  readFile,
10
10
  runTestEffect,
11
+ createFile,
12
+ runGitCommand,
11
13
  } from "../test-utils"
14
+ import { chmod } from "fs/promises"
12
15
 
13
16
  describe("edit command", () => {
14
17
  let tempDir: string
@@ -138,4 +141,95 @@ describe("edit command", () => {
138
141
  "Editor exited with code",
139
142
  )
140
143
  })
144
+
145
+ test("commits TASK.md with 'chore: agency edit' when file is modified", async () => {
146
+ await initGitRepo(tempDir)
147
+ process.chdir(tempDir)
148
+
149
+ // Initialize to create TASK.md
150
+ await initAgency(tempDir, "test-task")
151
+ await runTestEffect(task({ silent: true, emit: "test-feature" }))
152
+
153
+ // Get initial commit count
154
+ const result = Bun.spawnSync({
155
+ cmd: ["git", "rev-list", "--count", "HEAD"],
156
+ cwd: tempDir,
157
+ stdout: "pipe",
158
+ })
159
+ const initialCommits = new TextDecoder().decode(result.stdout).trim()
160
+
161
+ // Use a script that modifies TASK.md
162
+ const scriptPath = join(tempDir, "edit-script.sh")
163
+ await createFile(
164
+ tempDir,
165
+ "edit-script.sh",
166
+ '#!/bin/bash\necho "Updated task" >> "$1"\n',
167
+ )
168
+ await chmod(scriptPath, 0o755)
169
+ process.env.EDITOR = scriptPath
170
+
171
+ // Run edit command
172
+ await runTestEffect(taskEdit({ silent: true }))
173
+
174
+ // Check that a new commit was created
175
+ const finalResult = Bun.spawnSync({
176
+ cmd: ["git", "rev-list", "--count", "HEAD"],
177
+ cwd: tempDir,
178
+ stdout: "pipe",
179
+ })
180
+ const finalCommits = new TextDecoder().decode(finalResult.stdout).trim()
181
+ expect(Number.parseInt(finalCommits)).toBe(
182
+ Number.parseInt(initialCommits) + 1,
183
+ )
184
+
185
+ // Check the commit message
186
+ const msgResult = Bun.spawnSync({
187
+ cmd: ["git", "log", "-1", "--format=%s"],
188
+ cwd: tempDir,
189
+ stdout: "pipe",
190
+ })
191
+ const commitMessage = new TextDecoder().decode(msgResult.stdout).trim()
192
+ expect(commitMessage).toBe("chore: agency edit")
193
+
194
+ // Check that only TASK.md was committed
195
+ const filesResult = Bun.spawnSync({
196
+ cmd: ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
197
+ cwd: tempDir,
198
+ stdout: "pipe",
199
+ })
200
+ const filesInCommit = new TextDecoder().decode(filesResult.stdout).trim()
201
+ expect(filesInCommit).toBe("TASK.md")
202
+ })
203
+
204
+ test("does not commit when TASK.md is not modified", async () => {
205
+ await initGitRepo(tempDir)
206
+ process.chdir(tempDir)
207
+
208
+ // Initialize to create TASK.md
209
+ await initAgency(tempDir, "test-task")
210
+ await runTestEffect(task({ silent: true, emit: "test-feature" }))
211
+
212
+ // Get initial commit count
213
+ const result = Bun.spawnSync({
214
+ cmd: ["git", "rev-list", "--count", "HEAD"],
215
+ cwd: tempDir,
216
+ stdout: "pipe",
217
+ })
218
+ const initialCommits = new TextDecoder().decode(result.stdout).trim()
219
+
220
+ // Use a mock editor that doesn't modify the file
221
+ process.env.EDITOR = "true"
222
+
223
+ // Run edit command
224
+ await runTestEffect(taskEdit({ silent: true }))
225
+
226
+ // Check that no new commit was created
227
+ const finalResult = Bun.spawnSync({
228
+ cmd: ["git", "rev-list", "--count", "HEAD"],
229
+ cwd: tempDir,
230
+ stdout: "pipe",
231
+ })
232
+ const finalCommits = new TextDecoder().decode(finalResult.stdout).trim()
233
+ expect(Number.parseInt(finalCommits)).toBe(Number.parseInt(initialCommits))
234
+ })
141
235
  })
@@ -676,13 +676,13 @@ describe("task command", () => {
676
676
  })
677
677
 
678
678
  describe("branch handling", () => {
679
- test("fails when on main branch without branch name", async () => {
679
+ test("fails when no branch name provided (silent mode)", async () => {
680
680
  await initGitRepo(tempDir)
681
681
  process.chdir(tempDir)
682
682
 
683
683
  await initAgency(tempDir, "test")
684
684
  await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
685
- "main branch",
685
+ "Branch name is required",
686
686
  )
687
687
  })
688
688
 
@@ -415,7 +415,23 @@ export const task = (options: TaskOptions = {}) =>
415
415
  )
416
416
  }
417
417
  } else {
418
- // Default: determine main upstream branch (preferring remote)
418
+ // Default: fetch and use latest main upstream branch
419
+ // First, determine the remote to fetch from
420
+ const remote =
421
+ (yield* git.getRemoteConfig(targetPath)) ||
422
+ (yield* git.findDefaultRemote(targetPath))
423
+
424
+ if (remote) {
425
+ verboseLog(`Fetching from remote: ${remote}`)
426
+ yield* git.fetch(targetPath, remote).pipe(
427
+ Effect.catchAll((err) => {
428
+ verboseLog(`Failed to fetch from ${remote}: ${err}`)
429
+ return Effect.void
430
+ }),
431
+ )
432
+ }
433
+
434
+ // Now resolve the main branch (preferring remote)
419
435
  baseBranchToBranchFrom =
420
436
  (yield* git.resolveMainBranch(targetPath)) || undefined
421
437
 
@@ -431,7 +447,8 @@ export const task = (options: TaskOptions = {}) =>
431
447
 
432
448
  // Check if the base branch is an agency source branch
433
449
  // If so, we need to emit it first and use the emit branch instead
434
- if (baseBranchToBranchFrom) {
450
+ // Skip this check if using --from-current (we want to stay on current branch, not branch from it)
451
+ if (baseBranchToBranchFrom && !options.fromCurrent) {
435
452
  const cleanFromBase = extractCleanBranch(
436
453
  baseBranchToBranchFrom,
437
454
  config.sourceBranchPattern,
@@ -473,17 +490,14 @@ export const task = (options: TaskOptions = {}) =>
473
490
  // Determine branch name logic
474
491
  let branchName = options.emit || options.branch
475
492
 
476
- // If on main branch, using --from, or on an agency source branch without a branch name, prompt for it (unless in silent mode)
477
- if ((!isFeature || options.from || hasAgencyJson) && !branchName) {
493
+ // Determine if we need a new branch name:
494
+ // - With --from-current on a feature branch without agency.json: can stay on current branch
495
+ // - All other cases: require a new branch name
496
+ const canStayOnCurrentBranch =
497
+ options.fromCurrent && isFeature && !hasAgencyJson
498
+
499
+ if (!branchName && !canStayOnCurrentBranch) {
478
500
  if (silent) {
479
- if (options.from) {
480
- return yield* Effect.fail(
481
- new Error(
482
- `Branch name is required when using --from flag.\n` +
483
- `Use: 'agency task <branch-name> --from ${options.from}'`,
484
- ),
485
- )
486
- }
487
501
  if (hasAgencyJson) {
488
502
  return yield* Effect.fail(
489
503
  new Error(
@@ -495,10 +509,9 @@ export const task = (options: TaskOptions = {}) =>
495
509
  }
496
510
  return yield* Effect.fail(
497
511
  new Error(
498
- `You're currently on ${highlight.branch(currentBranch)}, which appears to be your main branch.\n` +
499
- `To initialize on a feature branch, either:\n` +
500
- ` 1. Switch to an existing feature branch first, then run 'agency task'\n` +
501
- ` 2. Provide a new branch name: 'agency task <branch-name>'`,
512
+ `Branch name is required.\n` +
513
+ `Use: 'agency task <branch-name>'\n` +
514
+ `Or use --from-current to initialize on the current branch.`,
502
515
  ),
503
516
  )
504
517
  }
@@ -910,6 +923,7 @@ const taskEditEffect = (options: TaskEditOptions = {}) =>
910
923
  const { log, verboseLog } = createLoggers(options)
911
924
 
912
925
  const fs = yield* FileSystemService
926
+ const git = yield* GitService
913
927
 
914
928
  const gitRoot = yield* ensureGitRepo()
915
929
 
@@ -943,6 +957,26 @@ const taskEditEffect = (options: TaskEditOptions = {}) =>
943
957
  }
944
958
 
945
959
  log(done("TASK.md edited"))
960
+
961
+ // Check if TASK.md has uncommitted changes
962
+ const hasChanges = yield* git.hasUncommittedChanges(gitRoot, "TASK.md")
963
+ verboseLog(`TASK.md has uncommitted changes: ${hasChanges}`)
964
+
965
+ if (hasChanges) {
966
+ // Commit the changes
967
+ yield* Effect.gen(function* () {
968
+ yield* git.gitAdd(["TASK.md"], gitRoot)
969
+ yield* git.gitCommit("chore: agency edit", gitRoot, {
970
+ noVerify: true,
971
+ })
972
+ log(done("Committed TASK.md changes"))
973
+ }).pipe(
974
+ Effect.catchAll((err) => {
975
+ verboseLog(`Failed to commit TASK.md: ${err}`)
976
+ return Effect.void
977
+ }),
978
+ )
979
+ }
946
980
  })
947
981
 
948
982
  export const editHelp = `
@@ -962,28 +996,22 @@ Example:
962
996
  `
963
997
 
964
998
  export const help = `
965
- Usage: agency task [branch-name] [options]
999
+ Usage: agency task <branch-name> [options]
966
1000
 
967
1001
  Initialize template files (AGENTS.md, TASK.md, opencode.json) in a git repository.
968
1002
 
969
1003
  IMPORTANT:
970
1004
  - You must run 'agency init' first to select a template
971
- - This command must be run on a feature branch, not the main branch
972
-
973
- If you're on the main branch, you must either:
974
- 1. Switch to an existing feature branch first, then run 'agency task'
975
- 2. Provide a branch name: 'agency task <branch-name>'
976
-
977
- Initializes files at the root of the current git repository.
1005
+ - A branch name is required (creates a new branch from the latest origin/main)
978
1006
 
979
1007
  Arguments:
980
- branch-name Create and switch to this branch before initializing
1008
+ branch-name Name for the new feature branch (required)
981
1009
 
982
1010
  Options:
983
1011
  --emit Branch name to create (alternative to positional arg)
984
1012
  --branch (Deprecated: use --emit) Branch name to create
985
1013
  --from <branch> Branch to branch from instead of main upstream branch
986
- --from-current Branch from the current branch
1014
+ --from-current Initialize on current branch instead of creating a new one
987
1015
  --continue Continue a task by copying agency files to a new branch
988
1016
 
989
1017
  Continue Mode (--continue):
@@ -999,39 +1027,33 @@ Continue Mode (--continue):
999
1027
  4. The emitBranch in agency.json is updated for the new branch
1000
1028
 
1001
1029
  Base Branch Selection:
1002
- By default, 'agency task' branches from the main upstream branch (e.g., origin/main).
1003
- You can override this behavior with:
1030
+ By default, 'agency task' fetches from the remote and branches from the latest
1031
+ main upstream branch (e.g., origin/main). You can override this behavior with:
1004
1032
 
1005
1033
  - --from <branch>: Branch from a specific branch
1006
- - --from-current: Branch from your current branch
1034
+ - --from-current: Initialize on your current branch (no new branch created)
1007
1035
 
1008
1036
  If the base branch is an agency source branch (e.g., agency/branch-A), the command
1009
1037
  will automatically use its emit branch instead. This allows you to layer work on top
1010
1038
  of other feature branches while maintaining clean branch history.
1011
1039
 
1012
1040
  Examples:
1013
- agency task # Branch from main upstream branch
1014
- agency task --from agency/branch-B # Branch from agency/branch-B's emit branch
1015
- agency task --from-current # Branch from current branch's emit branch
1041
+ agency task my-feature # Create 'my-feature' from latest origin/main
1016
1042
  agency task my-feature --from develop # Create 'my-feature' from 'develop'
1043
+ agency task --from-current # Initialize on current branch (no new branch)
1017
1044
  agency task --continue my-feature-v2 # Continue task on new branch after PR merge
1018
1045
 
1019
1046
  Template Workflow:
1020
1047
  1. Run 'agency init' to select template (saved to .git/config)
1021
- 2. Run 'agency task' to create template files on feature branch
1048
+ 2. Run 'agency task <branch-name>' to create feature branch with template files
1022
1049
  3. Use 'agency template save <file>' to update template with local changes
1023
1050
  4. Template directory only created when you save files to it
1024
1051
 
1025
1052
  Branch Creation:
1026
1053
  When creating a new branch without --from or --from-current:
1027
- 1. Auto-detects main upstream branch (origin/main, origin/master, etc.)
1028
- 2. Falls back to configured main branch in .git/config (agency.mainBranch)
1029
- 3. In --silent mode, a base branch must already be configured
1030
-
1031
- When using --from with an agency source branch:
1032
- 1. Verifies the emit branch exists for the source branch
1033
- 2. Uses the emit branch as the actual base to avoid agency files
1034
- 3. Fails if emit branch doesn't exist (run 'agency emit' first)
1054
+ 1. Fetches from the configured remote (or origin)
1055
+ 2. Auto-detects main upstream branch (origin/main, origin/master, etc.)
1056
+ 3. Creates new branch from the latest remote main branch
1035
1057
 
1036
1058
  Notes:
1037
1059
  - Files are created at the git repository root, not the current directory
@@ -850,5 +850,21 @@ export class GitService extends Effect.Service<GitService>()("GitService", {
850
850
  Effect.map((result) => (result.exitCode === 0 ? result.stdout : null)),
851
851
  Effect.catchAll(() => Effect.succeed(null)),
852
852
  ),
853
+
854
+ /**
855
+ * Check if a file has uncommitted changes (staged or unstaged).
856
+ * Uses `git diff HEAD` to check for any changes to the file.
857
+ * @param gitRoot - The git repository root
858
+ * @param filePath - Path to the file relative to git root
859
+ * @returns true if the file has changes, false otherwise
860
+ */
861
+ hasUncommittedChanges: (gitRoot: string, filePath: string) =>
862
+ pipe(
863
+ runGitCommand(["git", "diff", "HEAD", "--", filePath], gitRoot),
864
+ Effect.map(
865
+ (result) => result.exitCode === 0 && result.stdout.length > 0,
866
+ ),
867
+ Effect.catchAll(() => Effect.succeed(false)),
868
+ ),
853
869
  }),
854
870
  }) {}
package/src/types.ts CHANGED
@@ -55,7 +55,7 @@ Agency is a CLI tool for managing \`AGENTS.md\`, \`TASK.md\`, and \`opencode.jso
55
55
 
56
56
  ## Key Commands
57
57
 
58
- - \`agency task\` - Initialize template files on a feature branch
58
+ - \`agency task <branch-name>\` - Create feature branch and initialize template files
59
59
  - \`agency edit\` - Open TASK.md in system editor
60
60
  - \`agency template save\` - Save current file versions back to a template
61
61
  - \`agency template use\` - Switch to a different template