@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/cli.ts +569 -0
- package/index.ts +1 -0
- package/package.json +65 -0
- package/src/commands/base.test.ts +198 -0
- package/src/commands/base.ts +198 -0
- package/src/commands/clean.test.ts +299 -0
- package/src/commands/clean.ts +320 -0
- package/src/commands/emit.test.ts +412 -0
- package/src/commands/emit.ts +521 -0
- package/src/commands/emitted.test.ts +226 -0
- package/src/commands/emitted.ts +57 -0
- package/src/commands/init.test.ts +311 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/merge.test.ts +365 -0
- package/src/commands/merge.ts +253 -0
- package/src/commands/pull.test.ts +385 -0
- package/src/commands/pull.ts +205 -0
- package/src/commands/push.test.ts +394 -0
- package/src/commands/push.ts +346 -0
- package/src/commands/save.test.ts +247 -0
- package/src/commands/save.ts +162 -0
- package/src/commands/source.test.ts +195 -0
- package/src/commands/source.ts +72 -0
- package/src/commands/status.test.ts +489 -0
- package/src/commands/status.ts +258 -0
- package/src/commands/switch.test.ts +194 -0
- package/src/commands/switch.ts +84 -0
- package/src/commands/task-branching.test.ts +334 -0
- package/src/commands/task-edit.test.ts +141 -0
- package/src/commands/task-main.test.ts +872 -0
- package/src/commands/task.ts +712 -0
- package/src/commands/tasks.test.ts +335 -0
- package/src/commands/tasks.ts +155 -0
- package/src/commands/template-delete.test.ts +178 -0
- package/src/commands/template-delete.ts +98 -0
- package/src/commands/template-list.test.ts +135 -0
- package/src/commands/template-list.ts +87 -0
- package/src/commands/template-view.test.ts +158 -0
- package/src/commands/template-view.ts +86 -0
- package/src/commands/template.test.ts +32 -0
- package/src/commands/template.ts +96 -0
- package/src/commands/use.test.ts +87 -0
- package/src/commands/use.ts +97 -0
- package/src/commands/work.test.ts +462 -0
- package/src/commands/work.ts +193 -0
- package/src/errors.ts +17 -0
- package/src/schemas.ts +33 -0
- package/src/services/AgencyMetadataService.ts +287 -0
- package/src/services/ClaudeService.test.ts +184 -0
- package/src/services/ClaudeService.ts +91 -0
- package/src/services/ConfigService.ts +115 -0
- package/src/services/FileSystemService.ts +222 -0
- package/src/services/GitService.ts +751 -0
- package/src/services/OpencodeService.ts +263 -0
- package/src/services/PromptService.ts +183 -0
- package/src/services/TemplateService.ts +75 -0
- package/src/test-utils.ts +362 -0
- package/src/types/native-exec.d.ts +8 -0
- package/src/types.ts +216 -0
- package/src/utils/colors.ts +178 -0
- package/src/utils/command.ts +17 -0
- package/src/utils/effect.ts +281 -0
- package/src/utils/exec.ts +48 -0
- package/src/utils/paths.ts +51 -0
- package/src/utils/pr-branch.test.ts +372 -0
- package/src/utils/pr-branch.ts +473 -0
- package/src/utils/process.ts +110 -0
- package/src/utils/spinner.ts +82 -0
- package/templates/AGENCY.md +20 -0
- package/templates/AGENTS.md +11 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/TASK.md +5 -0
- 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}
|