@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,473 @@
1
+ /**
2
+ * Utilities for working with source and emit branch names and patterns
3
+ */
4
+
5
+ import { Effect, pipe } from "effect"
6
+ import { FileSystemService } from "../services/FileSystemService"
7
+ import { GitService } from "../services/GitService"
8
+ import {
9
+ AgencyMetadataService,
10
+ AgencyMetadataServiceLive,
11
+ } from "../services/AgencyMetadataService"
12
+ import { AgencyMetadata } from "../schemas"
13
+ import { Schema } from "@effect/schema"
14
+
15
+ /**
16
+ * Generate a source branch name by applying a pattern to a clean branch name.
17
+ * If pattern contains %branch%, it replaces it with the branch name.
18
+ * Otherwise, treats the pattern as a prefix.
19
+ *
20
+ * @example
21
+ * makeSourceBranchName("main", "agency/%branch%") // "agency/main"
22
+ * makeSourceBranchName("feature-foo", "wip/%branch%") // "wip/feature-foo"
23
+ * makeSourceBranchName("main", "agency/") // "agency/main"
24
+ */
25
+ export function makeSourceBranchName(
26
+ cleanBranch: string,
27
+ pattern: string,
28
+ ): string {
29
+ if (pattern.includes("%branch%")) {
30
+ return pattern.replace("%branch%", cleanBranch)
31
+ }
32
+
33
+ // If no %branch% placeholder, treat pattern as prefix
34
+ return pattern + cleanBranch
35
+ }
36
+
37
+ /**
38
+ * Extract the clean branch name from a source branch name using a pattern.
39
+ * Returns null if the source branch name doesn't match the pattern.
40
+ *
41
+ * @example
42
+ * extractCleanBranch("agency/main", "agency/%branch%") // "main"
43
+ * extractCleanBranch("wip/feature-foo", "wip/%branch%") // "feature-foo"
44
+ * extractCleanBranch("agency/main", "agency/") // "main"
45
+ * extractCleanBranch("main", "agency/%branch%") // null
46
+ */
47
+ export function extractCleanBranch(
48
+ sourceBranchName: string,
49
+ pattern: string,
50
+ ): string | null {
51
+ if (pattern.includes("%branch%")) {
52
+ // Split pattern into prefix and suffix around %branch%
53
+ const parts = pattern.split("%branch%")
54
+ if (parts.length !== 2) return null
55
+
56
+ const prefix = parts[0]!
57
+ const suffix = parts[1]!
58
+
59
+ // Check if source branch name matches the pattern
60
+ if (
61
+ !sourceBranchName.startsWith(prefix) ||
62
+ !sourceBranchName.endsWith(suffix)
63
+ ) {
64
+ return null
65
+ }
66
+
67
+ // Extract the clean branch name by removing prefix and suffix
68
+ const cleanBranch = sourceBranchName.slice(
69
+ prefix.length,
70
+ sourceBranchName.length - suffix.length,
71
+ )
72
+
73
+ // Ensure we extracted something (not empty string)
74
+ return cleanBranch.length > 0 ? cleanBranch : null
75
+ } else {
76
+ // Pattern is a prefix - check if branch starts with it
77
+ if (!sourceBranchName.startsWith(pattern)) {
78
+ return null
79
+ }
80
+
81
+ const cleanBranch = sourceBranchName.slice(pattern.length)
82
+ return cleanBranch.length > 0 ? cleanBranch : null
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Generate an emit branch name from a clean branch name and emit pattern.
88
+ * If pattern is "%branch%", returns the clean branch name unchanged.
89
+ * Otherwise, applies the pattern the same way as makeSourceBranchName.
90
+ *
91
+ * @example
92
+ * makeEmitBranchName("main", "%branch%") // "main"
93
+ * makeEmitBranchName("feature-foo", "%branch%--PR") // "feature-foo--PR"
94
+ * makeEmitBranchName("feature-foo", "PR/%branch%") // "PR/feature-foo"
95
+ */
96
+ export function makeEmitBranchName(
97
+ cleanBranch: string,
98
+ emitPattern: string,
99
+ ): string {
100
+ // Special case: "%branch%" means use clean branch name as-is
101
+ if (emitPattern === "%branch%") {
102
+ return cleanBranch
103
+ }
104
+
105
+ if (emitPattern.includes("%branch%")) {
106
+ return emitPattern.replace("%branch%", cleanBranch)
107
+ }
108
+
109
+ // If no %branch% placeholder, treat pattern as suffix
110
+ return cleanBranch + emitPattern
111
+ }
112
+
113
+ /**
114
+ * Extract the clean branch name from an emit branch name using an emit pattern.
115
+ * Returns null if the emit branch name doesn't match the pattern.
116
+ *
117
+ * @example
118
+ * extractCleanFromEmit("main", "%branch%") // "main"
119
+ * extractCleanFromEmit("feature-foo--PR", "%branch%--PR") // "feature-foo"
120
+ * extractCleanFromEmit("PR/feature-foo", "PR/%branch%") // "feature-foo"
121
+ * extractCleanFromEmit("main", "%branch%--PR") // null
122
+ */
123
+ export function extractCleanFromEmit(
124
+ emitBranchName: string,
125
+ emitPattern: string,
126
+ ): string | null {
127
+ // Special case: "%branch%" means emit branch is the clean branch name
128
+ if (emitPattern === "%branch%") {
129
+ return emitBranchName
130
+ }
131
+
132
+ if (emitPattern.includes("%branch%")) {
133
+ // Split pattern into prefix and suffix around %branch%
134
+ const parts = emitPattern.split("%branch%")
135
+ if (parts.length !== 2) return null
136
+
137
+ const prefix = parts[0]!
138
+ const suffix = parts[1]!
139
+
140
+ // Check if emit branch name matches the pattern
141
+ if (
142
+ !emitBranchName.startsWith(prefix) ||
143
+ !emitBranchName.endsWith(suffix)
144
+ ) {
145
+ return null
146
+ }
147
+
148
+ // Extract the clean branch name by removing prefix and suffix
149
+ const cleanBranch = emitBranchName.slice(
150
+ prefix.length,
151
+ emitBranchName.length - suffix.length,
152
+ )
153
+
154
+ // Ensure we extracted something (not empty string)
155
+ return cleanBranch.length > 0 ? cleanBranch : null
156
+ } else {
157
+ // Pattern is a suffix - check if branch ends with it
158
+ if (!emitBranchName.endsWith(emitPattern)) {
159
+ return null
160
+ }
161
+
162
+ const cleanBranch = emitBranchName.slice(0, -emitPattern.length)
163
+ return cleanBranch.length > 0 ? cleanBranch : null
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Result of resolving a branch pair (source and emit branches).
169
+ */
170
+ export interface BranchPair {
171
+ /** The source branch name (with source pattern applied) */
172
+ sourceBranch: string
173
+ /** The emit branch name (clean or with emit pattern) */
174
+ emitBranch: string
175
+ /** Whether the current branch is the emit branch */
176
+ isOnEmitBranch: boolean
177
+ }
178
+
179
+ /**
180
+ * Resolve the source and emit branch names from a current branch and patterns.
181
+ * This determines whether we're on an emit branch or source branch and provides
182
+ * both branch names.
183
+ *
184
+ * @example
185
+ * resolveBranchPair("agency/main", "agency/%branch%", "%branch%")
186
+ * // { sourceBranch: "agency/main", emitBranch: "main", isOnEmitBranch: false }
187
+ *
188
+ * resolveBranchPair("main", "agency/%branch%", "%branch%")
189
+ * // { sourceBranch: "agency/main", emitBranch: "main", isOnEmitBranch: true }
190
+ */
191
+ function resolveBranchPair(
192
+ currentBranch: string,
193
+ sourcePattern: string,
194
+ emitPattern: string,
195
+ ): BranchPair {
196
+ // First, try to extract clean branch from source pattern
197
+ const cleanFromSource = extractCleanBranch(currentBranch, sourcePattern)
198
+
199
+ if (cleanFromSource) {
200
+ // Current branch is a source branch (matches source pattern)
201
+ return {
202
+ sourceBranch: currentBranch,
203
+ emitBranch: makeEmitBranchName(cleanFromSource, emitPattern),
204
+ isOnEmitBranch: false,
205
+ }
206
+ }
207
+
208
+ // If emit pattern is not just "%branch%" (which would match anything),
209
+ // check if current branch matches the emit pattern
210
+ if (emitPattern !== "%branch%") {
211
+ const cleanFromEmit = extractCleanFromEmit(currentBranch, emitPattern)
212
+
213
+ if (cleanFromEmit) {
214
+ // Current branch is an emit branch (matches emit pattern)
215
+ return {
216
+ sourceBranch: makeSourceBranchName(cleanFromEmit, sourcePattern),
217
+ emitBranch: currentBranch,
218
+ isOnEmitBranch: true,
219
+ }
220
+ }
221
+ }
222
+
223
+ // If neither pattern matches (or emit pattern is "%branch%"), this is a
224
+ // "legacy" branch that doesn't follow the new naming convention.
225
+ // Treat it as a source branch where the branch name itself is the clean name.
226
+ return {
227
+ sourceBranch: currentBranch,
228
+ emitBranch: makeEmitBranchName(currentBranch, emitPattern),
229
+ isOnEmitBranch: false,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Effect-based utilities for resolving branch pairs using agency.json
235
+ */
236
+
237
+ /**
238
+ * Try to read agency.json from the git root on a specific branch.
239
+ * Returns the metadata if found, null otherwise.
240
+ */
241
+ const readAgencyJsonFromBranch = (
242
+ gitRoot: string,
243
+ branch: string,
244
+ ): Effect.Effect<
245
+ AgencyMetadata | null,
246
+ never,
247
+ GitService | FileSystemService
248
+ > =>
249
+ Effect.gen(function* () {
250
+ const metadataService = yield* AgencyMetadataService
251
+ return yield* metadataService.readFromBranch(gitRoot, branch)
252
+ }).pipe(Effect.provide(AgencyMetadataServiceLive))
253
+
254
+ /**
255
+ * Get the agency.json metadata from the current branch (if it exists).
256
+ */
257
+ const getCurrentBranchAgencyJson = (
258
+ gitRoot: string,
259
+ ): Effect.Effect<
260
+ AgencyMetadata | null,
261
+ never,
262
+ FileSystemService | GitService
263
+ > =>
264
+ Effect.gen(function* () {
265
+ const metadataService = yield* AgencyMetadataService
266
+ return yield* metadataService.readFromDisk(gitRoot)
267
+ }).pipe(Effect.provide(AgencyMetadataServiceLive))
268
+
269
+ /**
270
+ * Find the source branch by searching all branches for an agency.json
271
+ * with a matching emitBranch value.
272
+ */
273
+ const findSourceBranchByEmitBranch = (
274
+ gitRoot: string,
275
+ currentBranch: string,
276
+ ): Effect.Effect<string | null, never, GitService | FileSystemService> =>
277
+ Effect.gen(function* () {
278
+ const git = yield* GitService
279
+
280
+ // Get all local branches
281
+ const branchesResult = yield* pipe(
282
+ git.runGitCommand(
283
+ ["git", "branch", "--format=%(refname:short)"],
284
+ gitRoot,
285
+ {
286
+ captureOutput: true,
287
+ },
288
+ ),
289
+ Effect.catchAll(() => Effect.succeed({ exitCode: 1, stdout: "" })),
290
+ )
291
+
292
+ if (branchesResult.exitCode !== 0 || !branchesResult.stdout) {
293
+ return null
294
+ }
295
+
296
+ const branches = branchesResult.stdout
297
+ .split("\n")
298
+ .map((b) => b.trim())
299
+ .filter((b) => b.length > 0 && b !== currentBranch)
300
+
301
+ // Search each branch for agency.json with matching emitBranch
302
+ for (const branch of branches) {
303
+ const metadata = yield* readAgencyJsonFromBranch(gitRoot, branch)
304
+
305
+ if (metadata?.emitBranch === currentBranch) {
306
+ return branch
307
+ }
308
+ }
309
+
310
+ return null
311
+ })
312
+
313
+ /**
314
+ * Strategy 1: Try to resolve branch pair from current branch's agency.json.
315
+ * If current branch has agency.json with emitBranch that differs from current branch,
316
+ * we're on a source branch. If emitBranch equals current branch, we're on the emit branch.
317
+ */
318
+ const tryResolveFromCurrentAgencyJson = (
319
+ gitRoot: string,
320
+ currentBranch: string,
321
+ ): Effect.Effect<BranchPair | null, never, GitService | FileSystemService> =>
322
+ Effect.gen(function* () {
323
+ const currentMetadata = yield* getCurrentBranchAgencyJson(gitRoot)
324
+
325
+ if (currentMetadata?.emitBranch) {
326
+ // If emitBranch equals current branch, we're actually ON the emit branch,
327
+ // not the source branch. This can happen when skipFilter is used in tests
328
+ // and the agency.json is copied to the emit branch with emitBranch intact.
329
+ if (currentMetadata.emitBranch === currentBranch) {
330
+ // Return null to let Strategy 2 find the actual source branch
331
+ return null
332
+ }
333
+ return {
334
+ sourceBranch: currentBranch,
335
+ emitBranch: currentMetadata.emitBranch,
336
+ isOnEmitBranch: false,
337
+ }
338
+ }
339
+
340
+ return null
341
+ })
342
+
343
+ /**
344
+ * Strategy 2: Search other branches for agency.json with matching emitBranch.
345
+ * If found, we're on an emit branch.
346
+ */
347
+ const tryResolveFromOtherBranchAgencyJson = (
348
+ gitRoot: string,
349
+ currentBranch: string,
350
+ ): Effect.Effect<BranchPair | null, never, GitService | FileSystemService> =>
351
+ Effect.gen(function* () {
352
+ const sourceBranch = yield* findSourceBranchByEmitBranch(
353
+ gitRoot,
354
+ currentBranch,
355
+ )
356
+
357
+ if (sourceBranch) {
358
+ return {
359
+ sourceBranch,
360
+ emitBranch: currentBranch,
361
+ isOnEmitBranch: true,
362
+ }
363
+ }
364
+
365
+ return null
366
+ })
367
+
368
+ /**
369
+ * Strategy 3: For clean emit patterns ("%branch%"), check if a patterned source branch exists.
370
+ * If so, we're on an emit branch.
371
+ */
372
+ const tryResolveFromPatternedSourceBranch = (
373
+ gitRoot: string,
374
+ currentBranch: string,
375
+ sourcePattern: string,
376
+ emitPattern: string,
377
+ ): Effect.Effect<BranchPair | null, never, GitService | FileSystemService> =>
378
+ Effect.gen(function* () {
379
+ // Only applies when emit pattern is "%branch%" (clean emit branches)
380
+ if (emitPattern !== "%branch%") {
381
+ return null
382
+ }
383
+
384
+ const git = yield* GitService
385
+ const possibleSourceBranch = makeSourceBranchName(
386
+ currentBranch,
387
+ sourcePattern,
388
+ )
389
+
390
+ const sourceExists = yield* pipe(
391
+ git.branchExists(gitRoot, possibleSourceBranch),
392
+ Effect.catchAll(() => Effect.succeed(false)),
393
+ )
394
+
395
+ if (sourceExists) {
396
+ return {
397
+ sourceBranch: possibleSourceBranch,
398
+ emitBranch: currentBranch,
399
+ isOnEmitBranch: true,
400
+ }
401
+ }
402
+
403
+ return null
404
+ })
405
+
406
+ /**
407
+ * Resolve branch pair using agency.json as the first source of truth,
408
+ * falling back to pattern-based resolution.
409
+ *
410
+ * Resolution strategies (in priority order):
411
+ * 1. Current branch has agency.json with emitBranch -> we're on source branch
412
+ * 2. Another branch has agency.json pointing to current branch -> we're on emit branch
413
+ * 3. Clean emit pattern and patterned source branch exists -> we're on emit branch
414
+ * 4. Fall back to pattern-based resolution
415
+ */
416
+ export const resolveBranchPairWithAgencyJson = (
417
+ gitRoot: string,
418
+ currentBranch: string,
419
+ sourcePattern: string,
420
+ emitPattern: string,
421
+ ): Effect.Effect<BranchPair, never, GitService | FileSystemService> =>
422
+ Effect.gen(function* () {
423
+ // Strategy 1: Check current branch's agency.json
424
+ const fromCurrentAgencyJson = yield* tryResolveFromCurrentAgencyJson(
425
+ gitRoot,
426
+ currentBranch,
427
+ )
428
+ if (fromCurrentAgencyJson) {
429
+ return fromCurrentAgencyJson
430
+ }
431
+
432
+ // Strategy 2: Search other branches for matching agency.json
433
+ const fromOtherBranchAgencyJson =
434
+ yield* tryResolveFromOtherBranchAgencyJson(gitRoot, currentBranch)
435
+ if (fromOtherBranchAgencyJson) {
436
+ return fromOtherBranchAgencyJson
437
+ }
438
+
439
+ // Strategy 3: Check for patterned source branch (clean emit patterns only)
440
+ const fromPatternedSource = yield* tryResolveFromPatternedSourceBranch(
441
+ gitRoot,
442
+ currentBranch,
443
+ sourcePattern,
444
+ emitPattern,
445
+ )
446
+ if (fromPatternedSource) {
447
+ return fromPatternedSource
448
+ }
449
+
450
+ // Strategy 4: Fall back to pattern-based resolution
451
+ return resolveBranchPair(currentBranch, sourcePattern, emitPattern)
452
+ })
453
+
454
+ // Legacy function names for backward compatibility
455
+ // These will be updated as we migrate the codebase
456
+
457
+ /**
458
+ * @deprecated Use makeEmitBranchName instead. This function now creates emit branches,
459
+ * not PR branches with suffixes.
460
+ */
461
+ export function makePrBranchName(branchName: string, pattern: string): string {
462
+ return makeEmitBranchName(branchName, pattern)
463
+ }
464
+
465
+ /**
466
+ * @deprecated Use extractCleanFromEmit instead. Extracts clean branch from emit branch.
467
+ */
468
+ export function extractSourceBranch(
469
+ emitBranchName: string,
470
+ pattern: string,
471
+ ): string | null {
472
+ return extractCleanFromEmit(emitBranchName, pattern)
473
+ }
@@ -0,0 +1,110 @@
1
+ import { Effect, Data } from "effect"
2
+
3
+ /**
4
+ * Result of a process execution
5
+ */
6
+ interface ProcessResult {
7
+ readonly stdout: string
8
+ readonly stderr: string
9
+ readonly exitCode: number
10
+ }
11
+
12
+ /**
13
+ * Options for spawning a process
14
+ */
15
+ interface SpawnOptions {
16
+ readonly cwd?: string
17
+ readonly stdout?: "pipe" | "inherit"
18
+ readonly stderr?: "pipe" | "inherit"
19
+ readonly env?: Record<string, string>
20
+ }
21
+
22
+ /**
23
+ * Generic error for process execution failures
24
+ */
25
+ export class ProcessError extends Data.TaggedError("ProcessError")<{
26
+ command: string
27
+ exitCode: number
28
+ stderr: string
29
+ }> {}
30
+
31
+ /**
32
+ * Spawn a process with proper error handling and typed results.
33
+ * This is a low-level utility that returns raw process results.
34
+ * Use higher-level wrappers for specific error types.
35
+ */
36
+ export const spawnProcess = (
37
+ args: readonly string[],
38
+ options?: SpawnOptions,
39
+ ): Effect.Effect<ProcessResult, ProcessError> =>
40
+ Effect.tryPromise({
41
+ try: async () => {
42
+ const proc = Bun.spawn([...args], {
43
+ cwd: options?.cwd ?? process.cwd(),
44
+ stdout: options?.stdout ?? "pipe",
45
+ stderr: options?.stderr ?? "pipe",
46
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
47
+ })
48
+
49
+ await proc.exited
50
+
51
+ const stdout =
52
+ options?.stdout === "inherit"
53
+ ? ""
54
+ : await new Response(proc.stdout).text()
55
+ const stderr =
56
+ options?.stderr === "inherit"
57
+ ? ""
58
+ : await new Response(proc.stderr).text()
59
+
60
+ return {
61
+ stdout: stdout.trim(),
62
+ stderr: stderr.trim(),
63
+ exitCode: proc.exitCode ?? 0,
64
+ }
65
+ },
66
+ catch: (error) =>
67
+ new ProcessError({
68
+ command: args.join(" "),
69
+ exitCode: -1,
70
+ stderr: error instanceof Error ? error.message : String(error),
71
+ }),
72
+ })
73
+
74
+ /**
75
+ * Helper to check exit code and return stdout on success
76
+ */
77
+ export const checkExitCodeAndReturnStdout =
78
+ <E>(errorMapper: (result: ProcessResult) => E) =>
79
+ (result: ProcessResult): Effect.Effect<string, E> =>
80
+ result.exitCode === 0
81
+ ? Effect.succeed(result.stdout)
82
+ : Effect.fail(errorMapper(result))
83
+
84
+ /**
85
+ * Helper to check exit code and return void on success
86
+ */
87
+ export const checkExitCodeAndReturnVoid =
88
+ <E>(errorMapper: (result: ProcessResult) => E) =>
89
+ (result: ProcessResult): Effect.Effect<void, E> =>
90
+ result.exitCode === 0 ? Effect.void : Effect.fail(errorMapper(result))
91
+
92
+ /**
93
+ * Helper to create an error mapper function for a specific error type
94
+ * This is useful for wrapping spawnProcess with domain-specific error types
95
+ */
96
+ export const createErrorMapper =
97
+ <E extends { command: string; exitCode: number; stderr: string }>(
98
+ ErrorConstructor: new (args: {
99
+ command: string
100
+ exitCode: number
101
+ stderr: string
102
+ }) => E,
103
+ ) =>
104
+ (args: readonly string[]) =>
105
+ (result: ProcessResult): E =>
106
+ new ErrorConstructor({
107
+ command: args.join(" "),
108
+ exitCode: result.exitCode,
109
+ stderr: result.stderr,
110
+ })
@@ -0,0 +1,82 @@
1
+ import ora from "ora"
2
+ import { Effect } from "effect"
3
+
4
+ /**
5
+ * Check if we're running in a test environment
6
+ */
7
+ const isTestEnvironment = (): boolean => {
8
+ return process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test"
9
+ }
10
+
11
+ /**
12
+ * Configuration for a spinner operation
13
+ */
14
+ interface SpinnerConfig {
15
+ /** The message to show while the spinner is running */
16
+ text: string
17
+ /** The message to show when the operation succeeds */
18
+ successText?: string
19
+ /** The message to show when the operation fails */
20
+ failText?: string
21
+ /** Whether the spinner is enabled (defaults to true) */
22
+ enabled?: boolean
23
+ }
24
+
25
+ /**
26
+ * Wraps an Effect operation with a spinner that shows progress
27
+ * and updates with success/failure messages.
28
+ *
29
+ * @param effect The Effect operation to run
30
+ * @param config Configuration for the spinner
31
+ * @returns The result of the Effect operation
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const result = yield* withSpinner(
36
+ * someOperation(),
37
+ * {
38
+ * text: "Processing...",
39
+ * successText: "Processing complete",
40
+ * failText: "Processing failed"
41
+ * }
42
+ * )
43
+ * ```
44
+ */
45
+ export const withSpinner = <A, E, R>(
46
+ effect: Effect.Effect<A, E, R>,
47
+ config: SpinnerConfig,
48
+ ): Effect.Effect<A, E, R> => {
49
+ const { text, successText, failText, enabled = true } = config
50
+
51
+ // Disable spinner in test environment or when explicitly disabled
52
+ if (!enabled || isTestEnvironment()) {
53
+ return effect
54
+ }
55
+
56
+ return Effect.gen(function* () {
57
+ const spinner = ora({
58
+ text,
59
+ spinner: "dots",
60
+ color: "cyan",
61
+ }).start()
62
+
63
+ try {
64
+ const result = yield* effect
65
+
66
+ if (successText) {
67
+ spinner.succeed(successText)
68
+ } else {
69
+ spinner.stop()
70
+ }
71
+
72
+ return result
73
+ } catch (error) {
74
+ if (failText) {
75
+ spinner.fail(failText)
76
+ } else {
77
+ spinner.stop()
78
+ }
79
+ throw error
80
+ }
81
+ })
82
+ }
@@ -0,0 +1,20 @@
1
+ # Agent Instructions
2
+
3
+ ## TASK.md
4
+
5
+ The `TASK.md` file describes the task being performed and should be kept updated as work progresses. This file serves as a living record of:
6
+
7
+ - What is being built or fixed
8
+ - Current progress and status
9
+ - Remaining work items
10
+ - Any important context or decisions
11
+
12
+ All work on this repository should begin by reading and understanding `TASK.md`. Whenever any significant progress is made, `TASK.md` should be updated to reflect the current state of work.
13
+
14
+ **Note:** When you receive the prompt "Start the task", `TASK.md` is already in your context. DO NOT re-read it — instead, proceed directly with the work described in the task.
15
+
16
+ ## Commit Messages
17
+
18
+ When creating commit messages, do not reference changes to `TASK.md`, `AGENTS.md`, or any files tracked in `agency.json` (such as `opencode.json`). These are project management and configuration files that should not be mentioned in commit messages. Focus commit messages on actual code changes, features, fixes, and refactoring.
19
+
20
+ **Important:** Even when the only changes in a commit are to tracked files like `TASK.md`, you should still commit those changes. These updates should be co-located with the code changes they describe. Simply omit mentioning the tracked files in the commit message and focus the message on the actual code changes being made.
@@ -0,0 +1,11 @@
1
+ # Repo Instructions
2
+
3
+ Follow these instructions when working in this repository.
4
+
5
+ You should have already read @AGENCY.md to understand how agency is use to work on features.
6
+
7
+ When you have read this file, move on to reading @TODO.md to understand the specific tasks.
8
+
9
+ ## Rules
10
+
11
+ {human: fill this in}
@@ -0,0 +1,3 @@
1
+ # Claude Code Instructions
2
+
3
+ This project uses the agency CLI for managing development tasks and templates.
@@ -0,0 +1,5 @@
1
+ {task}
2
+
3
+ ## Tasks
4
+
5
+ - [ ] Populate this list with tasks