@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,346 @@
1
+ import { Effect, Either } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { GitService } from "../services/GitService"
4
+ import { ConfigService } from "../services/ConfigService"
5
+ import {
6
+ extractSourceBranch,
7
+ makePrBranchName,
8
+ resolveBranchPairWithAgencyJson,
9
+ } from "../utils/pr-branch"
10
+ import { FileSystemService } from "../services/FileSystemService"
11
+ import { emit } from "./emit"
12
+ import highlight, { done } from "../utils/colors"
13
+ import {
14
+ createLoggers,
15
+ ensureGitRepo,
16
+ getRemoteName,
17
+ withBranchProtection,
18
+ } from "../utils/effect"
19
+ import { withSpinner } from "../utils/spinner"
20
+ import { spawnProcess } from "../utils/process"
21
+
22
+ interface PushOptions extends BaseCommandOptions {
23
+ baseBranch?: string
24
+ branch?: string
25
+ force?: boolean
26
+ pr?: boolean
27
+ skipFilter?: boolean
28
+ }
29
+
30
+ export const push = (options: PushOptions = {}) =>
31
+ Effect.gen(function* () {
32
+ const gitRoot = yield* ensureGitRepo()
33
+
34
+ // Wrap the entire push operation with branch protection
35
+ // This ensures we return to the original branch on Ctrl-C interrupt
36
+ yield* withBranchProtection(gitRoot, pushCore(gitRoot, options))
37
+ })
38
+
39
+ const pushCore = (gitRoot: string, options: PushOptions) =>
40
+ Effect.gen(function* () {
41
+ const { verbose = false } = options
42
+ const { log, verboseLog } = createLoggers(options)
43
+
44
+ const git = yield* GitService
45
+ const configService = yield* ConfigService
46
+
47
+ // Load config to check emit branch pattern
48
+ const config = yield* configService.loadConfig()
49
+
50
+ // Get current branch
51
+ let sourceBranch = yield* git.getCurrentBranch(gitRoot)
52
+
53
+ // Check if we're already on an emit branch using proper branch resolution
54
+ const fs = yield* FileSystemService
55
+ const branchInfo = yield* resolveBranchPairWithAgencyJson(
56
+ gitRoot,
57
+ sourceBranch,
58
+ config.sourceBranchPattern,
59
+ config.emitBranch,
60
+ )
61
+
62
+ // If we're on an emit branch, switch to the source branch first
63
+ if (branchInfo.isOnEmitBranch) {
64
+ const actualSourceBranch = branchInfo.sourceBranch
65
+ // Check if the source branch exists
66
+ const sourceExists = yield* git.branchExists(gitRoot, actualSourceBranch)
67
+ if (sourceExists) {
68
+ verboseLog(
69
+ `Currently on emit branch ${highlight.branch(sourceBranch)}, switching to source branch ${highlight.branch(actualSourceBranch)}`,
70
+ )
71
+ yield* git.checkoutBranch(gitRoot, actualSourceBranch)
72
+ sourceBranch = actualSourceBranch
73
+ }
74
+ }
75
+
76
+ verboseLog(`Starting push workflow from ${highlight.branch(sourceBranch)}`)
77
+
78
+ // Step 1: Create emit branch (agency emit)
79
+ verboseLog("Step 1: Emitting...")
80
+ // Use emit command
81
+ const prEffectWithOptions = emit({
82
+ baseBranch: options.baseBranch,
83
+ branch: options.branch,
84
+ silent: true, // Suppress emit command output, we'll provide our own
85
+ force: options.force,
86
+ verbose: options.verbose,
87
+ skipFilter: options.skipFilter,
88
+ })
89
+
90
+ const prResult = yield* Effect.either(prEffectWithOptions)
91
+ if (Either.isLeft(prResult)) {
92
+ const error = prResult.left
93
+ return yield* Effect.fail(
94
+ new Error(
95
+ `Failed to create emit branch: ${error instanceof Error ? error.message : String(error)}`,
96
+ ),
97
+ )
98
+ }
99
+
100
+ // Compute the emit branch name (emit() command now stays on source branch)
101
+ // Use the branchInfo we already computed earlier
102
+ const emitBranchName = options.branch || branchInfo.emitBranch
103
+ log(done(`Emitted ${highlight.branch(emitBranchName)}`))
104
+
105
+ // Step 2: Push to remote (git push)
106
+ const remote = yield* getRemoteName(gitRoot)
107
+ verboseLog(
108
+ `Step 2: Pushing ${highlight.branch(emitBranchName)} to ${highlight.remote(remote)}...`,
109
+ )
110
+
111
+ const pushEither = yield* Effect.either(
112
+ withSpinner(
113
+ pushBranchToRemoteEffect(gitRoot, emitBranchName, remote, {
114
+ force: options.force,
115
+ verbose: options.verbose,
116
+ }),
117
+ {
118
+ text: options.force
119
+ ? `Pushing to ${highlight.remote(remote)} (forced)`
120
+ : `Pushing to ${highlight.remote(remote)}`,
121
+ enabled: !options.silent && !options.verbose,
122
+ },
123
+ ),
124
+ )
125
+ if (Either.isLeft(pushEither)) {
126
+ const error = pushEither.left
127
+ // If push failed, switch back to source branch before rethrowing
128
+ verboseLog(
129
+ "Push failed, switching back to source branch before reporting error...",
130
+ )
131
+ yield* git.checkoutBranch(gitRoot, sourceBranch)
132
+ return yield* Effect.fail(error)
133
+ }
134
+
135
+ const usedForce = pushEither.right
136
+
137
+ if (usedForce) {
138
+ log(done(`Pushed to ${highlight.remote(remote)} (forced)`))
139
+ } else {
140
+ log(done(`Pushed to ${highlight.remote(remote)}`))
141
+ }
142
+
143
+ // Step 3 (optional): Open GitHub PR if --pr flag is set
144
+ if (options.pr) {
145
+ verboseLog("Step 3: Opening GitHub PR...")
146
+
147
+ const ghEither = yield* Effect.either(
148
+ openGitHubPR(gitRoot, emitBranchName, {
149
+ verbose: options.verbose,
150
+ }),
151
+ )
152
+
153
+ if (Either.isLeft(ghEither)) {
154
+ const error = ghEither.left
155
+ // Don't fail the entire command if gh fails, just warn
156
+ console.error(
157
+ `⚠ Failed to open GitHub PR: ${error instanceof Error ? error.message : String(error)}`,
158
+ )
159
+ } else {
160
+ log(done("Opened GitHub PR in browser"))
161
+ }
162
+ }
163
+
164
+ // Verify we're still on the source branch (emit() now stays on source branch)
165
+ const finalBranch = yield* git.getCurrentBranch(gitRoot)
166
+ if (finalBranch !== sourceBranch) {
167
+ // This shouldn't happen with the new emit() behavior, but check anyway
168
+ verboseLog(
169
+ `Switching back to source branch ${highlight.branch(sourceBranch)}...`,
170
+ )
171
+ yield* git.checkoutBranch(gitRoot, sourceBranch)
172
+ }
173
+ })
174
+
175
+ // Helper: Push branch to remote with optional force and retry logic
176
+ const pushBranchToRemoteEffect = (
177
+ gitRoot: string,
178
+ branchName: string,
179
+ remote: string,
180
+ options: {
181
+ readonly force?: boolean
182
+ readonly verbose?: boolean
183
+ },
184
+ ) =>
185
+ Effect.gen(function* () {
186
+ const { force = false, verbose = false } = options
187
+
188
+ // Try pushing without force first
189
+ const pushResult = yield* spawnProcess(
190
+ ["git", "push", "-u", remote, branchName],
191
+ {
192
+ cwd: gitRoot,
193
+ stdout: verbose ? "inherit" : "pipe",
194
+ stderr: "pipe",
195
+ },
196
+ ).pipe(
197
+ // Don't fail immediately - we need to check the error type
198
+ Effect.catchAll((error) =>
199
+ Effect.succeed({
200
+ exitCode: error.exitCode,
201
+ stdout: "",
202
+ stderr: error.stderr,
203
+ }),
204
+ ),
205
+ )
206
+
207
+ let usedForce = false
208
+
209
+ // If push failed, check if we should retry with --force
210
+ if (pushResult.exitCode !== 0) {
211
+ const stderr = pushResult.stderr
212
+
213
+ // Check if this is a force-push-needed error
214
+ const needsForce =
215
+ stderr.includes("rejected") ||
216
+ stderr.includes("non-fast-forward") ||
217
+ stderr.includes("fetch first") ||
218
+ stderr.includes("Updates were rejected")
219
+
220
+ if (needsForce && force) {
221
+ // User provided --force flag, retry with force
222
+ const forceResult = yield* spawnProcess(
223
+ ["git", "push", "-u", "--force", remote, branchName],
224
+ {
225
+ cwd: gitRoot,
226
+ stdout: verbose ? "inherit" : "pipe",
227
+ stderr: "pipe",
228
+ },
229
+ ).pipe(
230
+ Effect.catchAll((error) =>
231
+ Effect.succeed({
232
+ exitCode: error.exitCode,
233
+ stdout: "",
234
+ stderr: error.stderr,
235
+ }),
236
+ ),
237
+ )
238
+
239
+ if (forceResult.exitCode !== 0) {
240
+ return yield* Effect.fail(
241
+ new Error(
242
+ `Failed to force push branch to remote: ${forceResult.stderr}`,
243
+ ),
244
+ )
245
+ }
246
+
247
+ usedForce = true
248
+ } else if (needsForce && !force) {
249
+ // User didn't provide --force but it's needed
250
+ return yield* Effect.fail(
251
+ new Error(
252
+ `Failed to push branch to remote. The branch has diverged from the remote.\n` +
253
+ `Run 'agency push --force' to force push the branch.`,
254
+ ),
255
+ )
256
+ } else {
257
+ // Some other error
258
+ return yield* Effect.fail(
259
+ new Error(`Failed to push branch to remote: ${stderr}`),
260
+ )
261
+ }
262
+ }
263
+
264
+ return usedForce
265
+ })
266
+
267
+ // Helper: Open GitHub PR using gh CLI
268
+ const openGitHubPR = (
269
+ gitRoot: string,
270
+ branchName: string,
271
+ options: {
272
+ readonly verbose?: boolean
273
+ },
274
+ ) =>
275
+ Effect.gen(function* () {
276
+ const { verbose = false } = options
277
+
278
+ // Run gh pr create --web with --head to specify the emit branch
279
+ const ghResult = yield* spawnProcess(
280
+ ["gh", "pr", "create", "--web", "--head", branchName],
281
+ {
282
+ cwd: gitRoot,
283
+ stdout: verbose ? "inherit" : "pipe",
284
+ stderr: "pipe",
285
+ },
286
+ ).pipe(
287
+ Effect.catchAll((error) =>
288
+ Effect.succeed({
289
+ exitCode: error.exitCode,
290
+ stdout: "",
291
+ stderr: error.stderr,
292
+ }),
293
+ ),
294
+ )
295
+
296
+ if (ghResult.exitCode !== 0) {
297
+ return yield* Effect.fail(
298
+ new Error(`gh CLI command failed: ${ghResult.stderr.trim()}`),
299
+ )
300
+ }
301
+ })
302
+
303
+ export const help = `
304
+ Usage: agency push [base-branch] [options]
305
+
306
+ Create a emit branch, push it to remote, and return to the source branch.
307
+
308
+ This command is a convenience wrapper that runs operations in sequence:
309
+ 1. agency emit [base-branch] - Create emit branch with backpack files reverted
310
+ 2. git push -u origin <pr-branch> - Push emit branch to remote
311
+ 3. gh pr create --web (optional with --pr) - Open GitHub PR in browser
312
+ 4. git checkout <source-branch> - Switch back to source branch
313
+
314
+ The command ensures you end up back on your source branch after pushing
315
+ the emit branch, making it easy to continue working locally while having
316
+ a clean emit branch ready on the remote.
317
+
318
+ Base Branch Selection:
319
+ Same as 'agency emit' - see 'agency emit --help' for details
320
+
321
+ Prerequisites:
322
+ - git-filter-repo must be installed: brew install git-filter-repo
323
+ - Remote 'origin' must be configured
324
+
325
+ Arguments:
326
+ base-branch Base branch to compare against (e.g., origin/main)
327
+ If not provided, will use saved config or auto-detect
328
+
329
+ Options:
330
+ -b, --branch Custom name for emit branch (defaults to pattern from config)
331
+ -f, --force Force push to remote if branch has diverged
332
+ --pr Open GitHub PR in browser after pushing (requires gh CLI)
333
+
334
+ Examples:
335
+ agency push # Create PR, push, return to source
336
+ agency push origin/main # Explicitly use origin/main as base
337
+ agency push --force # Force push if branch has diverged
338
+ agency push --pr # Push and open GitHub PR in browser
339
+
340
+ Notes:
341
+ - Must be run from a source branch (not a emit branch)
342
+ - Creates or recreates the emit branch
343
+ - Pushes with -u flag to set up tracking
344
+ - Automatically returns to source branch after pushing
345
+ - If any step fails, the command stops and reports the error
346
+ `
@@ -0,0 +1,247 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { save } from "./save"
4
+ import { task } from "./task"
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ initGitRepo,
9
+ initAgency,
10
+ readFile,
11
+ runTestEffect,
12
+ } from "../test-utils"
13
+
14
+ describe("save command", () => {
15
+ let tempDir: string
16
+ let originalCwd: string
17
+ let originalConfigDir: string | undefined
18
+
19
+ beforeEach(async () => {
20
+ tempDir = await createTempDir()
21
+ originalCwd = process.cwd()
22
+ originalConfigDir = process.env.AGENCY_CONFIG_DIR
23
+ // Use a temp config dir to avoid interference from user's actual config
24
+ process.env.AGENCY_CONFIG_DIR = await createTempDir()
25
+ })
26
+
27
+ afterEach(async () => {
28
+ process.chdir(originalCwd)
29
+ if (originalConfigDir !== undefined) {
30
+ process.env.AGENCY_CONFIG_DIR = originalConfigDir
31
+ } else {
32
+ delete process.env.AGENCY_CONFIG_DIR
33
+ }
34
+ if (
35
+ process.env.AGENCY_CONFIG_DIR &&
36
+ process.env.AGENCY_CONFIG_DIR !== originalConfigDir
37
+ ) {
38
+ await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
39
+ }
40
+ await cleanupTempDir(tempDir)
41
+ })
42
+
43
+ test("saves specified files to template directory", async () => {
44
+ await initGitRepo(tempDir)
45
+ process.chdir(tempDir)
46
+
47
+ // Initialize with template
48
+ await initAgency(tempDir, "test-save")
49
+ await task({ silent: true, branch: "test-feature" })
50
+
51
+ // Modify the files
52
+ await Bun.write(join(tempDir, "AGENTS.md"), "# Modified content")
53
+
54
+ // Save specific files to template
55
+ await runTestEffect(save({ files: ["AGENTS.md"], silent: true }))
56
+
57
+ // Check template files were updated
58
+ const configDir = process.env.AGENCY_CONFIG_DIR!
59
+ const templateAgents = await readFile(
60
+ join(configDir, "templates", "test-save", "AGENTS.md"),
61
+ )
62
+
63
+ expect(templateAgents).toBe("# Modified content")
64
+ })
65
+
66
+ test("throws error when no files specified", async () => {
67
+ await initGitRepo(tempDir)
68
+ process.chdir(tempDir)
69
+
70
+ // Initialize with template
71
+ await initAgency(tempDir, "test-no-files")
72
+ await task({
73
+ silent: true,
74
+ branch: "test-feature",
75
+ })
76
+
77
+ await expect(
78
+ runTestEffect(save({ files: [], silent: true })),
79
+ ).rejects.toThrow("No files specified")
80
+ })
81
+
82
+ test("throws error when no template configured", async () => {
83
+ await initGitRepo(tempDir)
84
+ process.chdir(tempDir)
85
+
86
+ await expect(
87
+ runTestEffect(save({ files: ["AGENTS.md"], silent: true })),
88
+ ).rejects.toThrow("Repository not initialized")
89
+ })
90
+
91
+ test("throws error when not in git repo", async () => {
92
+ process.chdir(tempDir)
93
+
94
+ await expect(
95
+ runTestEffect(save({ files: ["AGENTS.md"], silent: true })),
96
+ ).rejects.toThrow("Not in a git repository")
97
+ })
98
+
99
+ test("skips files that don't exist", async () => {
100
+ await initGitRepo(tempDir)
101
+ process.chdir(tempDir)
102
+
103
+ // Initialize with template
104
+ await initAgency(tempDir, "test-partial")
105
+ await task({
106
+ silent: true,
107
+ branch: "test-feature",
108
+ })
109
+
110
+ // Remove AGENTS.md (just to test skipping behavior)
111
+ const rmProc = Bun.spawn(["rm", join(tempDir, "AGENTS.md")], {
112
+ cwd: tempDir,
113
+ stdout: "pipe",
114
+ stderr: "pipe",
115
+ })
116
+ await rmProc.exited
117
+
118
+ // Modify a different file that exists
119
+ await Bun.write(join(tempDir, "test.txt"), "# Test content")
120
+
121
+ // Save should succeed but skip the missing file
122
+ await runTestEffect(
123
+ save({ files: ["AGENTS.md", "test.txt"], silent: true }),
124
+ )
125
+
126
+ // Check test.txt was updated
127
+ const configDir = process.env.AGENCY_CONFIG_DIR!
128
+ const testContent = await readFile(
129
+ join(configDir, "templates", "test-partial", "test.txt"),
130
+ )
131
+ expect(testContent).toBe("# Test content")
132
+ })
133
+
134
+ test("saves directories recursively", async () => {
135
+ await initGitRepo(tempDir)
136
+ process.chdir(tempDir)
137
+
138
+ // Initialize with template
139
+ await initAgency(tempDir, "test-dir")
140
+ await task({ silent: true, branch: "test-feature" })
141
+
142
+ // Create a directory with files
143
+ const docsDir = join(tempDir, "docs")
144
+ await Bun.write(join(docsDir, "README.md"), "# Documentation")
145
+ await Bun.write(join(docsDir, "guide.md"), "# Guide")
146
+
147
+ // Save the directory
148
+ await runTestEffect(save({ files: ["docs"], silent: true }))
149
+
150
+ // Check files were saved to template
151
+ const configDir = process.env.AGENCY_CONFIG_DIR!
152
+ const readmeContent = await readFile(
153
+ join(configDir, "templates", "test-dir", "docs", "README.md"),
154
+ )
155
+ const guideContent = await readFile(
156
+ join(configDir, "templates", "test-dir", "docs", "guide.md"),
157
+ )
158
+
159
+ expect(readmeContent).toBe("# Documentation")
160
+ expect(guideContent).toBe("# Guide")
161
+ })
162
+
163
+ test("refuses to save TASK.md", async () => {
164
+ await initGitRepo(tempDir)
165
+ process.chdir(tempDir)
166
+
167
+ // Initialize with template
168
+ await initAgency(tempDir, "test-task")
169
+ await task({ silent: true, branch: "test-feature" })
170
+
171
+ // Create a TASK.md with the {task} placeholder
172
+ await Bun.write(join(tempDir, "TASK.md"), "{task}\n\n## Notes")
173
+
174
+ // Save should fail regardless of content
175
+ await expect(
176
+ runTestEffect(save({ files: ["TASK.md"], silent: true })),
177
+ ).rejects.toThrow("TASK.md files cannot be saved to templates")
178
+ })
179
+
180
+ test("refuses to save TASK.md even without {task} placeholder", async () => {
181
+ await initGitRepo(tempDir)
182
+ process.chdir(tempDir)
183
+
184
+ // Initialize with template
185
+ await initAgency(tempDir, "test-task-invalid")
186
+ await task({
187
+ silent: true,
188
+ branch: "test-feature",
189
+ })
190
+
191
+ // Create a TASK.md without the {task} placeholder
192
+ await Bun.write(
193
+ join(tempDir, "TASK.md"),
194
+ "# Specific Task\n\nThis is a specific task, not a template",
195
+ )
196
+
197
+ // Save should fail
198
+ await expect(
199
+ runTestEffect(save({ files: ["TASK.md"], silent: true })),
200
+ ).rejects.toThrow("TASK.md files cannot be saved to templates")
201
+ })
202
+
203
+ test("refuses to save TASK.md in subdirectories", async () => {
204
+ await initGitRepo(tempDir)
205
+ process.chdir(tempDir)
206
+
207
+ // Initialize with template
208
+ await initAgency(tempDir, "test-task-subdir")
209
+ await task({
210
+ silent: true,
211
+ branch: "test-feature",
212
+ })
213
+
214
+ // Create a TASK.md in a subdirectory with the {task} placeholder
215
+ const subdir = join(tempDir, "projects")
216
+ await Bun.write(join(subdir, "TASK.md"), "{task}\n\n## Details")
217
+
218
+ // Save should fail regardless of content
219
+ await expect(
220
+ runTestEffect(save({ files: ["projects/TASK.md"], silent: true })),
221
+ ).rejects.toThrow("TASK.md files cannot be saved to templates")
222
+ })
223
+
224
+ test("refuses to save TASK.md in subdirectory even without {task} placeholder", async () => {
225
+ await initGitRepo(tempDir)
226
+ process.chdir(tempDir)
227
+
228
+ // Initialize with template
229
+ await initAgency(tempDir, "test-task-subdir-invalid")
230
+ await task({
231
+ silent: true,
232
+ branch: "test-feature",
233
+ })
234
+
235
+ // Create a TASK.md in a subdirectory without the {task} placeholder
236
+ const subdir = join(tempDir, "projects")
237
+ await Bun.write(
238
+ join(subdir, "TASK.md"),
239
+ "# Specific Project Task\n\nThis is specific",
240
+ )
241
+
242
+ // Save should fail
243
+ await expect(
244
+ runTestEffect(save({ files: ["projects/TASK.md"], silent: true })),
245
+ ).rejects.toThrow("TASK.md files cannot be saved to templates")
246
+ })
247
+ })