@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,193 @@
1
+ import { join } from "node:path"
2
+ import { Effect } from "effect"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { FileSystemService } from "../services/FileSystemService"
5
+ import { createLoggers, ensureGitRepo } from "../utils/effect"
6
+ import { spawnProcess } from "../utils/process"
7
+ import { execvp } from "../utils/exec"
8
+
9
+ interface WorkOptions extends BaseCommandOptions {
10
+ /**
11
+ * Force use of OpenCode CLI
12
+ */
13
+ opencode?: boolean
14
+ /**
15
+ * Force use of Claude Code CLI
16
+ */
17
+ claude?: boolean
18
+ /**
19
+ * Additional arguments to pass to the CLI tool
20
+ */
21
+ extraArgs?: string[]
22
+ /**
23
+ * Internal option to disable exec for testing.
24
+ * When true, uses spawn instead of exec so tests can complete.
25
+ */
26
+ _noExec?: boolean
27
+ }
28
+
29
+ export const work = (options: WorkOptions = {}) =>
30
+ Effect.gen(function* () {
31
+ const { verboseLog } = createLoggers(options)
32
+
33
+ const fs = yield* FileSystemService
34
+
35
+ const gitRoot = yield* ensureGitRepo()
36
+
37
+ // Check if TASK.md exists
38
+ const taskPath = join(gitRoot, "TASK.md")
39
+ const taskExists = yield* fs.exists(taskPath)
40
+ if (!taskExists) {
41
+ return yield* Effect.fail(
42
+ new Error("TASK.md not found. Run 'agency task' first to create it."),
43
+ )
44
+ }
45
+
46
+ verboseLog(`Found TASK.md at: ${taskPath}`)
47
+
48
+ // Change to git root before executing
49
+ process.chdir(gitRoot)
50
+
51
+ // Check for conflicting flags
52
+ if (options.opencode && options.claude) {
53
+ return yield* Effect.fail(
54
+ new Error(
55
+ "Cannot use both --opencode and --claude flags together. Choose one.",
56
+ ),
57
+ )
58
+ }
59
+
60
+ // Check which CLI tool is available
61
+ const hasOpencode = yield* Effect.tryPromise({
62
+ try: async () => {
63
+ const result = Bun.spawnSync(["which", "opencode"], {
64
+ stdout: "ignore",
65
+ stderr: "ignore",
66
+ })
67
+ return result.exitCode === 0
68
+ },
69
+ catch: () => false,
70
+ })
71
+
72
+ const hasClaude = yield* Effect.tryPromise({
73
+ try: async () => {
74
+ const result = Bun.spawnSync(["which", "claude"], {
75
+ stdout: "ignore",
76
+ stderr: "ignore",
77
+ })
78
+ return result.exitCode === 0
79
+ },
80
+ catch: () => false,
81
+ })
82
+
83
+ // Determine which CLI to use based on flags or auto-detection
84
+ let useOpencode: boolean
85
+ if (options.opencode) {
86
+ if (!hasOpencode) {
87
+ return yield* Effect.fail(
88
+ new Error(
89
+ "opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
90
+ ),
91
+ )
92
+ }
93
+ useOpencode = true
94
+ verboseLog("Using opencode (explicitly requested)")
95
+ } else if (options.claude) {
96
+ if (!hasClaude) {
97
+ return yield* Effect.fail(
98
+ new Error(
99
+ "claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
100
+ ),
101
+ )
102
+ }
103
+ useOpencode = false
104
+ verboseLog("Using claude (explicitly requested)")
105
+ } else {
106
+ // Auto-detect
107
+ if (!hasOpencode && !hasClaude) {
108
+ return yield* Effect.fail(
109
+ new Error(
110
+ "Neither opencode nor claude CLI tool found. Please install OpenCode or Claude Code.",
111
+ ),
112
+ )
113
+ }
114
+ useOpencode = hasOpencode
115
+ verboseLog(`Using ${useOpencode ? "opencode" : "claude"} (auto-detected)`)
116
+ }
117
+
118
+ const cliName = useOpencode ? "opencode" : "claude"
119
+ const baseArgs = useOpencode
120
+ ? [cliName, "-p", "Start the task"]
121
+ : [cliName, "Start the task"]
122
+
123
+ // Append extra args if provided
124
+ const cliArgs =
125
+ options.extraArgs && options.extraArgs.length > 0
126
+ ? [...baseArgs, ...options.extraArgs]
127
+ : baseArgs
128
+
129
+ if (options.extraArgs && options.extraArgs.length > 0) {
130
+ verboseLog(
131
+ `Running ${cliName} with extra args: ${options.extraArgs.join(" ")}`,
132
+ )
133
+ } else {
134
+ verboseLog(`Running ${cliName} with task prompt...`)
135
+ }
136
+
137
+ // For testing, we need to use spawn instead of exec since exec never returns
138
+ if (options._noExec) {
139
+ const result = yield* spawnProcess(cliArgs, {
140
+ cwd: gitRoot,
141
+ stdout: "inherit",
142
+ stderr: "inherit",
143
+ }).pipe(
144
+ Effect.catchAll((error) =>
145
+ Effect.fail(
146
+ new Error(`${cliName} exited with code ${error.exitCode}`),
147
+ ),
148
+ ),
149
+ )
150
+
151
+ if (result.exitCode !== 0) {
152
+ return yield* Effect.fail(
153
+ new Error(`${cliName} exited with code ${result.exitCode}`),
154
+ )
155
+ }
156
+ } else {
157
+ // Use execvp to replace the current process with the CLI tool
158
+ // This will never return - the process is completely replaced
159
+ execvp(cliName, cliArgs)
160
+ }
161
+ })
162
+
163
+ export const help = `
164
+ Usage: agency work [options] [-- extra-args...]
165
+
166
+ Start working on the task described in TASK.md using OpenCode or Claude Code.
167
+
168
+ This command replaces the current process with OpenCode (if available) or
169
+ Claude Code (if OpenCode is not available), launching it with a prompt to
170
+ get started on the task described in your TASK.md file.
171
+
172
+ Options:
173
+ --opencode Force use of OpenCode CLI
174
+ --claude Force use of Claude Code CLI
175
+
176
+ Pass-through Arguments:
177
+ Use -- to pass additional arguments to the underlying CLI tool.
178
+ Everything after -- will be forwarded to opencode or claude.
179
+
180
+ Examples:
181
+ agency work # Auto-detect (prefers opencode)
182
+ agency work --opencode # Explicitly use OpenCode
183
+ agency work --claude # Explicitly use Claude Code
184
+ agency work -- --model claude-sonnet-4-20250514 # Pass custom args to CLI
185
+
186
+ Notes:
187
+ - Requires TASK.md to exist (run 'agency task' first)
188
+ - Requires either opencode or claude to be installed and available in PATH
189
+ - By default, prefers opencode if both are available
190
+ - Use --opencode or --claude to override auto-detection
191
+ - Arguments after -- are passed directly to the underlying tool
192
+ - Replaces the current process (agency exits and the tool takes over)
193
+ `
package/src/errors.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Data } from "effect"
2
+
3
+ /**
4
+ * Error thrown when a command requires the repository to be initialized
5
+ * but agency.template is not set in git config
6
+ */
7
+ export class RepositoryNotInitializedError extends Data.TaggedError(
8
+ "RepositoryNotInitializedError",
9
+ )<{
10
+ readonly message: string
11
+ }> {
12
+ constructor(
13
+ message: string = "Repository not initialized. Run 'agency init' first to select a template.",
14
+ ) {
15
+ super({ message })
16
+ }
17
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { Schema } from "@effect/schema"
2
+
3
+ /**
4
+ * Schema for managed files in the agency system
5
+ */
6
+ export class ManagedFile extends Schema.Class<ManagedFile>("ManagedFile")({
7
+ name: Schema.String,
8
+ defaultContent: Schema.optional(Schema.String),
9
+ }) {}
10
+
11
+ /**
12
+ * Schema for agency metadata stored in agency.json
13
+ */
14
+ export class AgencyMetadata extends Schema.Class<AgencyMetadata>(
15
+ "AgencyMetadata",
16
+ )({
17
+ version: Schema.Literal(1),
18
+ injectedFiles: Schema.Array(Schema.String),
19
+ baseBranch: Schema.optional(Schema.String),
20
+ template: Schema.String,
21
+ createdAt: Schema.DateTimeUtc,
22
+ emitBranch: Schema.optional(Schema.String),
23
+ }) {}
24
+
25
+ /**
26
+ * Schema for agency configuration stored in ~/.config/agency/agency.json
27
+ */
28
+ export class AgencyConfig extends Schema.Class<AgencyConfig>("AgencyConfig")({
29
+ sourceBranchPattern: Schema.String.pipe(
30
+ Schema.annotations({ default: "agency/%branch%" }),
31
+ ),
32
+ emitBranch: Schema.String.pipe(Schema.annotations({ default: "%branch%" })),
33
+ }) {}
@@ -0,0 +1,287 @@
1
+ import { join } from "node:path"
2
+ import { Context, Data, Effect, Layer } from "effect"
3
+ import { Schema } from "@effect/schema"
4
+ import { AgencyMetadata } from "../schemas"
5
+ import { FileSystemService } from "./FileSystemService"
6
+ import { GitService } from "./GitService"
7
+
8
+ /**
9
+ * Error type for AgencyMetadata operations
10
+ */
11
+ export class AgencyMetadataError extends Data.TaggedError(
12
+ "AgencyMetadataError",
13
+ )<{
14
+ message: string
15
+ cause?: unknown
16
+ }> {}
17
+
18
+ /**
19
+ * Service for managing agency.json metadata operations
20
+ */
21
+ export class AgencyMetadataService extends Context.Tag("AgencyMetadataService")<
22
+ AgencyMetadataService,
23
+ {
24
+ /**
25
+ * Read agency.json from disk in the repository root.
26
+ * Returns null if the file doesn't exist or is invalid.
27
+ */
28
+ readonly readFromDisk: (
29
+ gitRoot: string,
30
+ ) => Effect.Effect<AgencyMetadata | null, never, FileSystemService>
31
+
32
+ /**
33
+ * Read agency.json from a specific git branch using git show.
34
+ * Returns null if the file doesn't exist or is invalid.
35
+ */
36
+ readonly readFromBranch: (
37
+ gitRoot: string,
38
+ branch: string,
39
+ ) => Effect.Effect<AgencyMetadata | null, never, GitService>
40
+
41
+ /**
42
+ * Write agency.json to disk in the repository root.
43
+ */
44
+ readonly write: (
45
+ gitRoot: string,
46
+ metadata: AgencyMetadata,
47
+ ) => Effect.Effect<void, AgencyMetadataError, FileSystemService>
48
+
49
+ /**
50
+ * Get list of files to filter during PR/merge operations.
51
+ * Always includes TASK.md, AGENCY.md, and agency.json, plus any injectedFiles from metadata.
52
+ */
53
+ readonly getFilesToFilter: (
54
+ gitRoot: string,
55
+ ) => Effect.Effect<string[], never, FileSystemService>
56
+
57
+ /**
58
+ * Get the configured base branch from agency.json metadata.
59
+ * Returns null if no metadata exists or no base branch is configured.
60
+ */
61
+ readonly getBaseBranch: (
62
+ gitRoot: string,
63
+ ) => Effect.Effect<string | null, never, FileSystemService>
64
+
65
+ /**
66
+ * Set the base branch in agency.json metadata.
67
+ */
68
+ readonly setBaseBranch: (
69
+ gitRoot: string,
70
+ baseBranch: string,
71
+ ) => Effect.Effect<void, AgencyMetadataError, FileSystemService>
72
+
73
+ /**
74
+ * Parse and validate agency.json content from a JSON string.
75
+ * Returns null if the content is invalid.
76
+ */
77
+ readonly parse: (content: string) => Effect.Effect<AgencyMetadata | null>
78
+ }
79
+ >() {}
80
+
81
+ /**
82
+ * Parse and validate agency.json content from a JSON string.
83
+ * Returns null if the content is invalid.
84
+ */
85
+ const parseAgencyMetadata = (content: string) =>
86
+ Effect.gen(function* () {
87
+ const data = yield* Effect.try({
88
+ try: () => JSON.parse(content),
89
+ catch: () => new Error("Failed to parse agency.json"),
90
+ })
91
+
92
+ // Validate version
93
+ if (typeof data.version !== "number" || data.version !== 1) {
94
+ return null
95
+ }
96
+
97
+ // Parse and validate using Effect schema
98
+ const metadata = yield* Effect.try({
99
+ try: () => Schema.decodeUnknownSync(AgencyMetadata)(data),
100
+ catch: () => new Error("Invalid agency.json format"),
101
+ })
102
+
103
+ return metadata
104
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
105
+
106
+ /**
107
+ * Implementation of AgencyMetadataService
108
+ */
109
+ export const AgencyMetadataServiceLive = Layer.succeed(
110
+ AgencyMetadataService,
111
+ AgencyMetadataService.of({
112
+ readFromDisk: (gitRoot: string) =>
113
+ Effect.gen(function* () {
114
+ const fs = yield* FileSystemService
115
+ const metadataPath = join(gitRoot, "agency.json")
116
+
117
+ const exists = yield* fs.exists(metadataPath)
118
+ if (!exists) {
119
+ return null
120
+ }
121
+
122
+ const content = yield* fs.readFile(metadataPath)
123
+ return yield* parseAgencyMetadata(content)
124
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
125
+
126
+ readFromBranch: (gitRoot: string, branch: string) =>
127
+ Effect.gen(function* () {
128
+ const git = yield* GitService
129
+
130
+ // Try to read agency.json from the branch using git show
131
+ const result = yield* git.runGitCommand(
132
+ ["git", "show", `${branch}:agency.json`],
133
+ gitRoot,
134
+ { captureOutput: true },
135
+ )
136
+
137
+ if (result.exitCode !== 0 || !result.stdout) {
138
+ return null
139
+ }
140
+
141
+ return yield* parseAgencyMetadata(result.stdout)
142
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
143
+
144
+ write: (gitRoot: string, metadata: AgencyMetadata) =>
145
+ Effect.gen(function* () {
146
+ const fs = yield* FileSystemService
147
+ const metadataPath = join(gitRoot, "agency.json")
148
+
149
+ const content = JSON.stringify(metadata, null, 2) + "\n"
150
+ yield* fs.writeFile(metadataPath, content).pipe(
151
+ Effect.mapError(
152
+ (error) =>
153
+ new AgencyMetadataError({
154
+ message: `Failed to write agency.json: ${error}`,
155
+ cause: error,
156
+ }),
157
+ ),
158
+ )
159
+ }),
160
+
161
+ getFilesToFilter: (gitRoot: string) =>
162
+ Effect.gen(function* () {
163
+ const fs = yield* FileSystemService
164
+ const metadataPath = join(gitRoot, "agency.json")
165
+
166
+ const exists = yield* fs.exists(metadataPath)
167
+ const baseFiles = ["TASK.md", "AGENCY.md", "agency.json"]
168
+
169
+ if (!exists) {
170
+ return baseFiles
171
+ }
172
+
173
+ const content = yield* fs
174
+ .readFile(metadataPath)
175
+ .pipe(Effect.catchAll(() => Effect.succeed("")))
176
+
177
+ if (!content) {
178
+ return baseFiles
179
+ }
180
+
181
+ const metadata = yield* parseAgencyMetadata(content)
182
+
183
+ if (!metadata) {
184
+ return baseFiles
185
+ }
186
+
187
+ return [...baseFiles, ...metadata.injectedFiles]
188
+ }).pipe(
189
+ Effect.catchAll(() =>
190
+ Effect.succeed(["TASK.md", "AGENCY.md", "agency.json"]),
191
+ ),
192
+ ),
193
+
194
+ getBaseBranch: (gitRoot: string) =>
195
+ Effect.gen(function* () {
196
+ const fs = yield* FileSystemService
197
+ const metadataPath = join(gitRoot, "agency.json")
198
+
199
+ const exists = yield* fs.exists(metadataPath)
200
+ if (!exists) {
201
+ return null
202
+ }
203
+
204
+ const content = yield* fs
205
+ .readFile(metadataPath)
206
+ .pipe(Effect.catchAll(() => Effect.succeed("")))
207
+
208
+ if (!content) {
209
+ return null
210
+ }
211
+
212
+ const metadata = yield* parseAgencyMetadata(content)
213
+ return metadata?.baseBranch || null
214
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))),
215
+
216
+ setBaseBranch: (gitRoot: string, baseBranch: string) =>
217
+ Effect.gen(function* () {
218
+ const fs = yield* FileSystemService
219
+ const metadataPath = join(gitRoot, "agency.json")
220
+
221
+ const exists = yield* fs.exists(metadataPath).pipe(
222
+ Effect.mapError(
223
+ (error) =>
224
+ new AgencyMetadataError({
225
+ message: `Failed to check agency.json: ${error}`,
226
+ cause: error,
227
+ }),
228
+ ),
229
+ )
230
+
231
+ if (!exists) {
232
+ return yield* Effect.fail(
233
+ new AgencyMetadataError({
234
+ message:
235
+ "agency.json not found. Please run 'agency task' first to initialize backpack files.",
236
+ }),
237
+ )
238
+ }
239
+
240
+ const content = yield* fs.readFile(metadataPath).pipe(
241
+ Effect.mapError(
242
+ (error) =>
243
+ new AgencyMetadataError({
244
+ message: `Failed to read agency.json: ${error}`,
245
+ cause: error,
246
+ }),
247
+ ),
248
+ )
249
+
250
+ const metadata = yield* parseAgencyMetadata(content).pipe(
251
+ Effect.flatMap((m) =>
252
+ m
253
+ ? Effect.succeed(m)
254
+ : Effect.fail(
255
+ new AgencyMetadataError({
256
+ message:
257
+ "agency.json is invalid. Please run 'agency task' first to initialize backpack files.",
258
+ }),
259
+ ),
260
+ ),
261
+ )
262
+
263
+ // Create a new metadata instance with the updated baseBranch
264
+ const updatedMetadata = new AgencyMetadata({
265
+ version: metadata.version,
266
+ injectedFiles: metadata.injectedFiles,
267
+ template: metadata.template,
268
+ createdAt: metadata.createdAt,
269
+ baseBranch,
270
+ emitBranch: metadata.emitBranch,
271
+ })
272
+
273
+ const outputContent = JSON.stringify(updatedMetadata, null, 2) + "\n"
274
+ return yield* fs.writeFile(metadataPath, outputContent).pipe(
275
+ Effect.mapError(
276
+ (error) =>
277
+ new AgencyMetadataError({
278
+ message: `Failed to write agency.json: ${error}`,
279
+ cause: error,
280
+ }),
281
+ ),
282
+ )
283
+ }),
284
+
285
+ parse: parseAgencyMetadata,
286
+ }),
287
+ )
@@ -0,0 +1,184 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { join } from "path"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ runTestEffect,
9
+ fileExists,
10
+ readFile,
11
+ createFile,
12
+ } from "../test-utils"
13
+ import { ClaudeService } from "./ClaudeService"
14
+
15
+ describe("ClaudeService", () => {
16
+ let tempDir: string
17
+
18
+ beforeEach(async () => {
19
+ tempDir = await createTempDir()
20
+ await initGitRepo(tempDir)
21
+ })
22
+
23
+ afterEach(async () => {
24
+ await cleanupTempDir(tempDir)
25
+ })
26
+
27
+ describe("claudeFileExists", () => {
28
+ test("returns false when CLAUDE.md does not exist", async () => {
29
+ const result = await runTestEffect(
30
+ Effect.gen(function* () {
31
+ const claudeService = yield* ClaudeService
32
+ return yield* claudeService.claudeFileExists(tempDir)
33
+ }),
34
+ )
35
+
36
+ expect(result).toBe(false)
37
+ })
38
+
39
+ test("returns true when CLAUDE.md exists", async () => {
40
+ await createFile(tempDir, "CLAUDE.md", "# Claude Code\n")
41
+
42
+ const result = await runTestEffect(
43
+ Effect.gen(function* () {
44
+ const claudeService = yield* ClaudeService
45
+ return yield* claudeService.claudeFileExists(tempDir)
46
+ }),
47
+ )
48
+
49
+ expect(result).toBe(true)
50
+ })
51
+ })
52
+
53
+ describe("hasAgencySection", () => {
54
+ test("returns false when content does not have agency section", async () => {
55
+ const content = "# Claude Code\n\nSome instructions\n"
56
+
57
+ const result = await runTestEffect(
58
+ Effect.gen(function* () {
59
+ const claudeService = yield* ClaudeService
60
+ return yield* claudeService.hasAgencySection(content)
61
+ }),
62
+ )
63
+
64
+ expect(result).toBe(false)
65
+ })
66
+
67
+ test("returns true when content has both @AGENCY.md and @TASK.md", async () => {
68
+ const content = `# Claude Code
69
+
70
+ Some instructions
71
+
72
+ ## Agency
73
+
74
+ @AGENCY.md
75
+ @TASK.md
76
+ `
77
+
78
+ const result = await runTestEffect(
79
+ Effect.gen(function* () {
80
+ const claudeService = yield* ClaudeService
81
+ return yield* claudeService.hasAgencySection(content)
82
+ }),
83
+ )
84
+
85
+ expect(result).toBe(true)
86
+ })
87
+ })
88
+
89
+ describe("injectAgencySection", () => {
90
+ test("creates CLAUDE.md with agency section when file does not exist", async () => {
91
+ const result = await runTestEffect(
92
+ Effect.gen(function* () {
93
+ const claudeService = yield* ClaudeService
94
+ return yield* claudeService.injectAgencySection(tempDir)
95
+ }),
96
+ )
97
+
98
+ expect(result.created).toBe(true)
99
+ expect(result.modified).toBe(true)
100
+
101
+ const claudePath = join(tempDir, "CLAUDE.md")
102
+ expect(await fileExists(claudePath)).toBe(true)
103
+
104
+ const content = await readFile(claudePath)
105
+ expect(content).toContain("@AGENCY.md")
106
+ expect(content).toContain("@TASK.md")
107
+ expect(content.indexOf("@AGENCY.md")).toBeLessThan(
108
+ content.indexOf("@TASK.md"),
109
+ )
110
+ })
111
+
112
+ test("appends agency section when file exists without references", async () => {
113
+ const initialContent = "# Claude Code\n\nSome existing instructions\n"
114
+ await createFile(tempDir, "CLAUDE.md", initialContent)
115
+
116
+ const result = await runTestEffect(
117
+ Effect.gen(function* () {
118
+ const claudeService = yield* ClaudeService
119
+ return yield* claudeService.injectAgencySection(tempDir)
120
+ }),
121
+ )
122
+
123
+ expect(result.created).toBe(false)
124
+ expect(result.modified).toBe(true)
125
+
126
+ const content = await readFile(join(tempDir, "CLAUDE.md"))
127
+ expect(content).toContain("Some existing instructions")
128
+ expect(content).toContain("@AGENCY.md")
129
+ expect(content).toContain("@TASK.md")
130
+ })
131
+
132
+ test("does not modify file when agency section already exists in correct order", async () => {
133
+ const contentWithSection = `# Claude Code
134
+
135
+ Some instructions
136
+
137
+ ## Agency
138
+
139
+ @AGENCY.md
140
+ @TASK.md
141
+ `
142
+ await createFile(tempDir, "CLAUDE.md", contentWithSection)
143
+
144
+ const result = await runTestEffect(
145
+ Effect.gen(function* () {
146
+ const claudeService = yield* ClaudeService
147
+ return yield* claudeService.injectAgencySection(tempDir)
148
+ }),
149
+ )
150
+
151
+ expect(result.created).toBe(false)
152
+ expect(result.modified).toBe(false)
153
+
154
+ const content = await readFile(join(tempDir, "CLAUDE.md"))
155
+ expect(content).toBe(contentWithSection)
156
+ })
157
+
158
+ test("re-adds references when they exist in wrong order", async () => {
159
+ const contentWithWrongOrder = `# Claude Code
160
+
161
+ Some instructions
162
+
163
+ @TASK.md
164
+ @AGENCY.md
165
+ `
166
+ await createFile(tempDir, "CLAUDE.md", contentWithWrongOrder)
167
+
168
+ const result = await runTestEffect(
169
+ Effect.gen(function* () {
170
+ const claudeService = yield* ClaudeService
171
+ return yield* claudeService.injectAgencySection(tempDir)
172
+ }),
173
+ )
174
+
175
+ expect(result.created).toBe(false)
176
+ expect(result.modified).toBe(true)
177
+
178
+ const content = await readFile(join(tempDir, "CLAUDE.md"))
179
+ // Should have the section appended with correct order
180
+ expect(content).toContain("@AGENCY.md")
181
+ expect(content).toContain("@TASK.md")
182
+ })
183
+ })
184
+ })