@markjaquith/agency 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/cli.ts +35 -22
- package/package.json +5 -1
- package/src/commands/emit.test.ts +1 -1
- package/src/commands/emit.ts +16 -5
- package/src/commands/push.test.ts +1 -1
- package/src/commands/push.ts +8 -5
- package/src/commands/rebase.test.ts +521 -0
- package/src/commands/rebase.ts +243 -0
- package/src/commands/save.test.ts +8 -8
- package/src/commands/task-branching.test.ts +312 -13
- package/src/commands/task-continue.test.ts +311 -0
- package/src/commands/task-edit.test.ts +4 -4
- package/src/commands/task-main.test.ts +57 -32
- package/src/commands/task.ts +371 -79
- package/src/services/AgencyMetadataService.ts +9 -1
- package/src/services/GitService.ts +61 -1
- package/src/utils/glob.test.ts +154 -0
- package/src/utils/glob.ts +78 -0
package/src/commands/task.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, join } from "path"
|
|
1
|
+
import { resolve, join, dirname } from "path"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
4
|
import { GitService } from "../services/GitService"
|
|
@@ -8,6 +8,10 @@ import { PromptService } from "../services/PromptService"
|
|
|
8
8
|
import { TemplateService } from "../services/TemplateService"
|
|
9
9
|
import { OpencodeService } from "../services/OpencodeService"
|
|
10
10
|
import { ClaudeService } from "../services/ClaudeService"
|
|
11
|
+
import {
|
|
12
|
+
AgencyMetadataService,
|
|
13
|
+
AgencyMetadataServiceLive,
|
|
14
|
+
} from "../services/AgencyMetadataService"
|
|
11
15
|
import { initializeManagedFiles, writeAgencyMetadata } from "../types"
|
|
12
16
|
import { RepositoryNotInitializedError } from "../errors"
|
|
13
17
|
import highlight, { done, info, plural } from "../utils/colors"
|
|
@@ -17,22 +21,291 @@ import {
|
|
|
17
21
|
extractCleanBranch,
|
|
18
22
|
makeSourceBranchName,
|
|
19
23
|
} from "../utils/pr-branch"
|
|
24
|
+
import { getTopLevelDir, dirToGlobPattern } from "../utils/glob"
|
|
20
25
|
|
|
21
26
|
interface TaskOptions extends BaseCommandOptions {
|
|
22
27
|
path?: string
|
|
23
28
|
task?: string
|
|
24
|
-
|
|
29
|
+
emit?: string
|
|
30
|
+
branch?: string // Deprecated: use emit instead
|
|
25
31
|
from?: string
|
|
26
32
|
fromCurrent?: boolean
|
|
33
|
+
continue?: boolean
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
interface TaskEditOptions extends BaseCommandOptions {}
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Continue a task by creating a new branch with the same agency files.
|
|
40
|
+
* This is useful after a PR is merged and you want to continue working on the task.
|
|
41
|
+
*/
|
|
42
|
+
const taskContinue = (options: TaskOptions) =>
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const { silent = false } = options
|
|
45
|
+
const { log, verboseLog } = createLoggers(options)
|
|
46
|
+
|
|
47
|
+
const git = yield* GitService
|
|
48
|
+
const configService = yield* ConfigService
|
|
49
|
+
const fs = yield* FileSystemService
|
|
50
|
+
const promptService = yield* PromptService
|
|
51
|
+
|
|
52
|
+
// Determine target path
|
|
53
|
+
let targetPath: string
|
|
54
|
+
|
|
55
|
+
if (options.path) {
|
|
56
|
+
targetPath = resolve(options.path)
|
|
57
|
+
const isRoot = yield* git.isGitRoot(targetPath)
|
|
58
|
+
if (!isRoot) {
|
|
59
|
+
return yield* Effect.fail(
|
|
60
|
+
new Error(
|
|
61
|
+
"The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
const isRepo = yield* git.isInsideGitRepo(process.cwd())
|
|
67
|
+
if (!isRepo) {
|
|
68
|
+
return yield* Effect.fail(
|
|
69
|
+
new Error(
|
|
70
|
+
"Not in a git repository. Please run this command inside a git repo.",
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
targetPath = yield* git.getGitRoot(process.cwd())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if initialized (has template in git config)
|
|
78
|
+
const templateName = yield* getTemplateName(targetPath)
|
|
79
|
+
if (!templateName) {
|
|
80
|
+
return yield* Effect.fail(new RepositoryNotInitializedError())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const currentBranch = yield* git.getCurrentBranch(targetPath)
|
|
84
|
+
verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
85
|
+
|
|
86
|
+
// Load agency config for branch patterns
|
|
87
|
+
const config = yield* configService.loadConfig()
|
|
88
|
+
|
|
89
|
+
// Verify we're on an agency source branch by checking if agency.json exists
|
|
90
|
+
const agencyJsonPath = resolve(targetPath, "agency.json")
|
|
91
|
+
const hasAgencyJson = yield* fs.exists(agencyJsonPath)
|
|
92
|
+
|
|
93
|
+
if (!hasAgencyJson) {
|
|
94
|
+
return yield* Effect.fail(
|
|
95
|
+
new Error(
|
|
96
|
+
`No agency.json found on current branch ${highlight.branch(currentBranch)}.\n` +
|
|
97
|
+
`The --continue flag requires you to be on an agency source branch with existing agency files.`,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Read the existing agency.json to get metadata
|
|
103
|
+
const existingMetadata = yield* Effect.gen(function* () {
|
|
104
|
+
const service = yield* AgencyMetadataService
|
|
105
|
+
return yield* service.readFromDisk(targetPath)
|
|
106
|
+
}).pipe(Effect.provide(AgencyMetadataServiceLive))
|
|
107
|
+
|
|
108
|
+
if (!existingMetadata) {
|
|
109
|
+
return yield* Effect.fail(
|
|
110
|
+
new Error(
|
|
111
|
+
`Failed to read agency.json on branch ${highlight.branch(currentBranch)}.`,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
verboseLog(
|
|
117
|
+
`Existing metadata: ${JSON.stringify(existingMetadata, null, 2)}`,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Get the list of agency files to copy
|
|
121
|
+
const filesToCopy = [
|
|
122
|
+
"agency.json",
|
|
123
|
+
"TASK.md",
|
|
124
|
+
"AGENCY.md",
|
|
125
|
+
...existingMetadata.injectedFiles,
|
|
126
|
+
]
|
|
127
|
+
verboseLog(`Files to copy: ${filesToCopy.join(", ")}`)
|
|
128
|
+
|
|
129
|
+
// Read all the existing files content before switching branches
|
|
130
|
+
const fileContents = new Map<string, string>()
|
|
131
|
+
for (const file of filesToCopy) {
|
|
132
|
+
const filePath = resolve(targetPath, file)
|
|
133
|
+
const exists = yield* fs.exists(filePath)
|
|
134
|
+
if (exists) {
|
|
135
|
+
const content = yield* fs.readFile(filePath)
|
|
136
|
+
fileContents.set(file, content)
|
|
137
|
+
verboseLog(`Read ${file} (${content.length} bytes)`)
|
|
138
|
+
} else {
|
|
139
|
+
verboseLog(`File ${file} not found, skipping`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Prompt for new branch name if not provided
|
|
144
|
+
let branchName = options.emit || options.branch
|
|
145
|
+
if (!branchName) {
|
|
146
|
+
if (silent) {
|
|
147
|
+
return yield* Effect.fail(
|
|
148
|
+
new Error(
|
|
149
|
+
"Branch name is required with --continue in silent mode. Use --emit to specify one.",
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
branchName = yield* promptService.prompt("New branch name: ")
|
|
154
|
+
if (!branchName) {
|
|
155
|
+
return yield* Effect.fail(
|
|
156
|
+
new Error("Branch name is required to continue the task."),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Apply source pattern to get the full source branch name
|
|
162
|
+
const sourceBranchName = makeSourceBranchName(
|
|
163
|
+
branchName,
|
|
164
|
+
config.sourceBranchPattern,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
// Check if the new branch already exists
|
|
168
|
+
const branchExists = yield* git.branchExists(targetPath, sourceBranchName)
|
|
169
|
+
if (branchExists) {
|
|
170
|
+
return yield* Effect.fail(
|
|
171
|
+
new Error(
|
|
172
|
+
`Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
|
|
173
|
+
`Choose a different branch name.`,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Determine base branch to branch from
|
|
179
|
+
let baseBranchToBranchFrom: string | undefined
|
|
180
|
+
|
|
181
|
+
if (options.from) {
|
|
182
|
+
baseBranchToBranchFrom = options.from
|
|
183
|
+
const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
|
|
184
|
+
if (!exists) {
|
|
185
|
+
return yield* Effect.fail(
|
|
186
|
+
new Error(
|
|
187
|
+
`Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
// Default: branch from main upstream branch (preferring remote)
|
|
193
|
+
baseBranchToBranchFrom =
|
|
194
|
+
(yield* git.resolveMainBranch(targetPath)) || undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!baseBranchToBranchFrom) {
|
|
198
|
+
return yield* Effect.fail(
|
|
199
|
+
new Error(
|
|
200
|
+
"Could not determine base branch. Use --from to specify one.",
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
verboseLog(
|
|
206
|
+
`Creating new branch ${highlight.branch(sourceBranchName)} from ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
// Create the new branch from the base branch
|
|
210
|
+
yield* git.createBranch(
|
|
211
|
+
sourceBranchName,
|
|
212
|
+
targetPath,
|
|
213
|
+
baseBranchToBranchFrom,
|
|
214
|
+
)
|
|
215
|
+
log(
|
|
216
|
+
done(
|
|
217
|
+
`Created and switched to branch ${highlight.branch(sourceBranchName)} based on ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
// Calculate the new emit branch name
|
|
222
|
+
const newEmitBranchName = makeEmitBranchName(branchName, config.emitBranch)
|
|
223
|
+
verboseLog(`New emit branch name: ${newEmitBranchName}`)
|
|
224
|
+
|
|
225
|
+
// Write all the files to the new branch
|
|
226
|
+
const createdFiles: string[] = []
|
|
227
|
+
|
|
228
|
+
for (const [file, content] of fileContents) {
|
|
229
|
+
const filePath = resolve(targetPath, file)
|
|
230
|
+
|
|
231
|
+
// Ensure parent directory exists
|
|
232
|
+
const parentDir = resolve(filePath, "..")
|
|
233
|
+
const parentExists = yield* fs.exists(parentDir)
|
|
234
|
+
if (!parentExists) {
|
|
235
|
+
yield* fs.runCommand(["mkdir", "-p", parentDir])
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let fileContent = content
|
|
239
|
+
|
|
240
|
+
// Update emitBranch in agency.json
|
|
241
|
+
if (file === "agency.json") {
|
|
242
|
+
const metadata = JSON.parse(content)
|
|
243
|
+
metadata.emitBranch = newEmitBranchName
|
|
244
|
+
metadata.createdAt = new Date().toISOString()
|
|
245
|
+
fileContent = JSON.stringify(metadata, null, 2) + "\n"
|
|
246
|
+
verboseLog(
|
|
247
|
+
`Updated agency.json with new emitBranch: ${newEmitBranchName}`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Update emitBranch in opencode.json (if it has one)
|
|
252
|
+
if (file === "opencode.json" || file.endsWith("/opencode.json")) {
|
|
253
|
+
try {
|
|
254
|
+
const opencodeConfig = JSON.parse(content)
|
|
255
|
+
if (opencodeConfig.emitBranch) {
|
|
256
|
+
opencodeConfig.emitBranch = newEmitBranchName
|
|
257
|
+
fileContent = JSON.stringify(opencodeConfig, null, "\t") + "\n"
|
|
258
|
+
verboseLog(
|
|
259
|
+
`Updated ${file} with new emitBranch: ${newEmitBranchName}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// If we can't parse it, just copy as-is
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
yield* fs.writeFile(filePath, fileContent)
|
|
268
|
+
createdFiles.push(file)
|
|
269
|
+
log(done(`Created ${highlight.file(file)}`))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Git add and commit the created files
|
|
273
|
+
if (createdFiles.length > 0) {
|
|
274
|
+
yield* Effect.gen(function* () {
|
|
275
|
+
yield* git.gitAdd(createdFiles, targetPath)
|
|
276
|
+
// Format: chore: agency task --continue (baseBranch) originalSource => newSource => newEmit
|
|
277
|
+
const commitMessage = `chore: agency task --continue (${baseBranchToBranchFrom}) ${currentBranch} → ${sourceBranchName} → ${newEmitBranchName}`
|
|
278
|
+
yield* git.gitCommit(commitMessage, targetPath, {
|
|
279
|
+
noVerify: true,
|
|
280
|
+
})
|
|
281
|
+
verboseLog(
|
|
282
|
+
`Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
|
|
283
|
+
)
|
|
284
|
+
}).pipe(
|
|
285
|
+
Effect.catchAll((err) => {
|
|
286
|
+
verboseLog(`Failed to commit: ${err}`)
|
|
287
|
+
return Effect.void
|
|
288
|
+
}),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
log(
|
|
293
|
+
info(
|
|
294
|
+
`Continued task with ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`,
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
31
299
|
export const task = (options: TaskOptions = {}) =>
|
|
32
300
|
Effect.gen(function* () {
|
|
33
301
|
const { silent = false, verbose = false } = options
|
|
34
302
|
const { log, verboseLog } = createLoggers(options)
|
|
35
303
|
|
|
304
|
+
// Handle --continue flag
|
|
305
|
+
if (options.continue) {
|
|
306
|
+
return yield* taskContinue(options)
|
|
307
|
+
}
|
|
308
|
+
|
|
36
309
|
const git = yield* GitService
|
|
37
310
|
const configService = yield* ConfigService
|
|
38
311
|
const fs = yield* FileSystemService
|
|
@@ -72,6 +345,9 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
72
345
|
|
|
73
346
|
const createdFiles: string[] = []
|
|
74
347
|
const injectedFiles: string[] = []
|
|
348
|
+
// Track directories that we create during this task
|
|
349
|
+
// These will be converted to glob patterns for filtering
|
|
350
|
+
const createdDirs = new Set<string>()
|
|
75
351
|
|
|
76
352
|
// Check if initialized (has template in git config)
|
|
77
353
|
const templateName = yield* getTemplateName(targetPath)
|
|
@@ -124,32 +400,9 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
124
400
|
)
|
|
125
401
|
}
|
|
126
402
|
} else {
|
|
127
|
-
// Default: determine main upstream branch
|
|
403
|
+
// Default: determine main upstream branch (preferring remote)
|
|
128
404
|
baseBranchToBranchFrom =
|
|
129
|
-
(yield* git.
|
|
130
|
-
(yield* git.findMainBranch(targetPath)) ||
|
|
131
|
-
undefined
|
|
132
|
-
|
|
133
|
-
// If still no base branch, try to auto-detect from remote
|
|
134
|
-
if (!baseBranchToBranchFrom) {
|
|
135
|
-
const remote = yield* git
|
|
136
|
-
.resolveRemote(targetPath)
|
|
137
|
-
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
138
|
-
|
|
139
|
-
const commonBases: string[] = []
|
|
140
|
-
if (remote) {
|
|
141
|
-
commonBases.push(`${remote}/main`, `${remote}/master`)
|
|
142
|
-
}
|
|
143
|
-
commonBases.push("main", "master")
|
|
144
|
-
|
|
145
|
-
for (const base of commonBases) {
|
|
146
|
-
const exists = yield* git.branchExists(targetPath, base)
|
|
147
|
-
if (exists) {
|
|
148
|
-
baseBranchToBranchFrom = base
|
|
149
|
-
break
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
405
|
+
(yield* git.resolveMainBranch(targetPath)) || undefined
|
|
153
406
|
|
|
154
407
|
if (baseBranchToBranchFrom) {
|
|
155
408
|
verboseLog(
|
|
@@ -158,10 +411,12 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
158
411
|
}
|
|
159
412
|
}
|
|
160
413
|
|
|
414
|
+
// Load config early for branch pattern operations
|
|
415
|
+
const config = yield* configService.loadConfig()
|
|
416
|
+
|
|
161
417
|
// Check if the base branch is an agency source branch
|
|
162
418
|
// If so, we need to emit it first and use the emit branch instead
|
|
163
419
|
if (baseBranchToBranchFrom) {
|
|
164
|
-
const config = yield* configService.loadConfig()
|
|
165
420
|
const cleanFromBase = extractCleanBranch(
|
|
166
421
|
baseBranchToBranchFrom,
|
|
167
422
|
config.sourceBranchPattern,
|
|
@@ -200,10 +455,20 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
200
455
|
}
|
|
201
456
|
}
|
|
202
457
|
|
|
203
|
-
//
|
|
204
|
-
let branchName = options.branch
|
|
205
|
-
|
|
458
|
+
// Determine branch name logic
|
|
459
|
+
let branchName = options.emit || options.branch
|
|
460
|
+
|
|
461
|
+
// If on main branch or using --from without a branch name, prompt for it (unless in silent mode)
|
|
462
|
+
if ((!isFeature || options.from) && !branchName) {
|
|
206
463
|
if (silent) {
|
|
464
|
+
if (options.from) {
|
|
465
|
+
return yield* Effect.fail(
|
|
466
|
+
new Error(
|
|
467
|
+
`Branch name is required when using --from flag.\n` +
|
|
468
|
+
`Use: 'agency task <branch-name> --from ${options.from}'`,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
}
|
|
207
472
|
return yield* Effect.fail(
|
|
208
473
|
new Error(
|
|
209
474
|
`You're currently on ${highlight.branch(currentBranch)}, which appears to be your main branch.\n` +
|
|
@@ -215,9 +480,7 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
215
480
|
}
|
|
216
481
|
branchName = yield* promptService.prompt("Branch name: ")
|
|
217
482
|
if (!branchName) {
|
|
218
|
-
return yield* Effect.fail(
|
|
219
|
-
new Error("Branch name is required when on main branch."),
|
|
220
|
-
)
|
|
483
|
+
return yield* Effect.fail(new Error("Branch name is required."))
|
|
221
484
|
}
|
|
222
485
|
verboseLog(`Branch name from prompt: ${branchName}`)
|
|
223
486
|
}
|
|
@@ -225,7 +488,6 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
225
488
|
// If we have a branch name, apply source pattern and check if branch exists
|
|
226
489
|
let sourceBranchName: string | undefined
|
|
227
490
|
if (branchName) {
|
|
228
|
-
const config = yield* configService.loadConfig()
|
|
229
491
|
sourceBranchName = makeSourceBranchName(
|
|
230
492
|
branchName,
|
|
231
493
|
config.sourceBranchPattern,
|
|
@@ -377,6 +639,18 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
377
639
|
continue
|
|
378
640
|
}
|
|
379
641
|
|
|
642
|
+
// Check if this file is in a subdirectory that doesn't exist yet
|
|
643
|
+
// If so, we'll track it for glob pattern creation
|
|
644
|
+
const topLevelDir = getTopLevelDir(fileName)
|
|
645
|
+
if (topLevelDir && source === "template") {
|
|
646
|
+
const dirPath = resolve(targetPath, topLevelDir)
|
|
647
|
+
const dirExists = yield* fs.exists(dirPath)
|
|
648
|
+
if (!dirExists) {
|
|
649
|
+
createdDirs.add(topLevelDir)
|
|
650
|
+
verboseLog(`Will create new directory: ${topLevelDir}`)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
380
654
|
let content: string
|
|
381
655
|
|
|
382
656
|
// Try to read from template first, fall back to default content
|
|
@@ -395,12 +669,24 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
395
669
|
verboseLog(`Replaced {task} placeholder with: ${taskDescription}`)
|
|
396
670
|
}
|
|
397
671
|
|
|
672
|
+
// Ensure parent directory exists before writing file
|
|
673
|
+
const parentDir = dirname(targetFilePath)
|
|
674
|
+
const parentExists = yield* fs.exists(parentDir)
|
|
675
|
+
if (!parentExists) {
|
|
676
|
+
yield* fs.createDirectory(parentDir)
|
|
677
|
+
}
|
|
678
|
+
|
|
398
679
|
yield* fs.writeFile(targetFilePath, content)
|
|
399
680
|
createdFiles.push(fileName)
|
|
400
681
|
|
|
401
682
|
// Track backpack files (excluding TASK.md and AGENCY.md which are always filtered)
|
|
683
|
+
// For files in new directories, the glob pattern will be added below
|
|
402
684
|
if (fileName !== "TASK.md" && fileName !== "AGENCY.md") {
|
|
403
|
-
|
|
685
|
+
// Only track individual files if they're NOT in a newly created directory
|
|
686
|
+
// (directories will be tracked as glob patterns)
|
|
687
|
+
if (!topLevelDir || !createdDirs.has(topLevelDir)) {
|
|
688
|
+
injectedFiles.push(fileName)
|
|
689
|
+
}
|
|
404
690
|
}
|
|
405
691
|
|
|
406
692
|
log(done(`Created ${highlight.file(fileName)}`))
|
|
@@ -423,34 +709,9 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
423
709
|
baseBranch =
|
|
424
710
|
(yield* git.getDefaultBaseBranchConfig(targetPath)) || undefined
|
|
425
711
|
|
|
426
|
-
// If no repo-level default, try to auto-detect
|
|
712
|
+
// If no repo-level default, try to auto-detect (preferring remote)
|
|
427
713
|
if (!baseBranch) {
|
|
428
|
-
|
|
429
|
-
baseBranch =
|
|
430
|
-
(yield* git.getMainBranchConfig(targetPath)) ||
|
|
431
|
-
(yield* git.findMainBranch(targetPath)) ||
|
|
432
|
-
undefined
|
|
433
|
-
|
|
434
|
-
// Try common base branches with dynamic remote
|
|
435
|
-
if (!baseBranch) {
|
|
436
|
-
const remote = yield* git
|
|
437
|
-
.resolveRemote(targetPath)
|
|
438
|
-
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
439
|
-
|
|
440
|
-
const commonBases: string[] = []
|
|
441
|
-
if (remote) {
|
|
442
|
-
commonBases.push(`${remote}/main`, `${remote}/master`)
|
|
443
|
-
}
|
|
444
|
-
commonBases.push("main", "master")
|
|
445
|
-
|
|
446
|
-
for (const base of commonBases) {
|
|
447
|
-
const exists = yield* git.branchExists(targetPath, base)
|
|
448
|
-
if (exists) {
|
|
449
|
-
baseBranch = base
|
|
450
|
-
break
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
714
|
+
baseBranch = (yield* git.resolveMainBranch(targetPath)) || undefined
|
|
454
715
|
}
|
|
455
716
|
|
|
456
717
|
if (baseBranch) {
|
|
@@ -459,12 +720,19 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
459
720
|
|
|
460
721
|
// Calculate emitBranch name from current branch
|
|
461
722
|
const finalBranch = yield* git.getCurrentBranch(targetPath)
|
|
462
|
-
const config = yield* configService.loadConfig()
|
|
463
723
|
// Extract clean branch name from source pattern, or use branch as-is for legacy branches
|
|
464
724
|
const cleanBranch =
|
|
465
725
|
extractCleanBranch(finalBranch, config.sourceBranchPattern) || finalBranch
|
|
466
726
|
const emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
|
|
467
727
|
|
|
728
|
+
// Convert created directories to glob patterns and add to injectedFiles
|
|
729
|
+
// This allows filtering all files in new directories during emit
|
|
730
|
+
for (const dir of createdDirs) {
|
|
731
|
+
const globPattern = dirToGlobPattern(dir)
|
|
732
|
+
injectedFiles.push(globPattern)
|
|
733
|
+
verboseLog(`Tracking directory as glob pattern: ${globPattern}`)
|
|
734
|
+
}
|
|
735
|
+
|
|
468
736
|
// Create agency.json metadata file
|
|
469
737
|
const metadata = {
|
|
470
738
|
version: 1 as const,
|
|
@@ -489,7 +757,11 @@ export const task = (options: TaskOptions = {}) =>
|
|
|
489
757
|
if (createdFiles.length > 0) {
|
|
490
758
|
yield* Effect.gen(function* () {
|
|
491
759
|
yield* git.gitAdd(createdFiles, targetPath)
|
|
492
|
-
|
|
760
|
+
// Format: chore: agency task (baseBranch) sourceBranch → emitBranch
|
|
761
|
+
const commitMessage = baseBranch
|
|
762
|
+
? `chore: agency task (${baseBranch}) ${finalBranch} → ${emitBranchName}`
|
|
763
|
+
: `chore: agency task ${finalBranch} → ${emitBranchName}`
|
|
764
|
+
yield* git.gitCommit(commitMessage, targetPath, {
|
|
493
765
|
noVerify: true,
|
|
494
766
|
})
|
|
495
767
|
verboseLog(
|
|
@@ -519,14 +791,11 @@ const createFeatureBranchEffect = (
|
|
|
519
791
|
const git = yield* GitService
|
|
520
792
|
const promptService = yield* PromptService
|
|
521
793
|
|
|
522
|
-
// Use provided base branch if available, otherwise
|
|
794
|
+
// Use provided base branch if available, otherwise resolve (preferring remote)
|
|
523
795
|
let baseBranch: string | undefined = providedBaseBranch
|
|
524
796
|
|
|
525
797
|
if (!baseBranch) {
|
|
526
|
-
baseBranch =
|
|
527
|
-
(yield* git.getMainBranchConfig(targetPath)) ||
|
|
528
|
-
(yield* git.findMainBranch(targetPath)) ||
|
|
529
|
-
undefined
|
|
798
|
+
baseBranch = (yield* git.resolveMainBranch(targetPath)) || undefined
|
|
530
799
|
}
|
|
531
800
|
|
|
532
801
|
// If no base branch is configured and not in silent mode, prompt for it
|
|
@@ -538,13 +807,6 @@ const createFeatureBranchEffect = (
|
|
|
538
807
|
suggestions,
|
|
539
808
|
)
|
|
540
809
|
verboseLog(`Selected base branch: ${baseBranch}`)
|
|
541
|
-
|
|
542
|
-
// Save the main branch config if it's not already set
|
|
543
|
-
const mainBranchConfig = yield* git.getMainBranchConfig(targetPath)
|
|
544
|
-
if (!mainBranchConfig) {
|
|
545
|
-
yield* git.setMainBranchConfig(baseBranch, targetPath)
|
|
546
|
-
log(done(`Set main branch to ${highlight.branch(baseBranch)}`))
|
|
547
|
-
}
|
|
548
810
|
} else {
|
|
549
811
|
return yield* Effect.fail(
|
|
550
812
|
new Error(
|
|
@@ -607,7 +869,6 @@ const taskEditEffect = (options: TaskEditOptions = {}) =>
|
|
|
607
869
|
Effect.gen(function* () {
|
|
608
870
|
const { log, verboseLog } = createLoggers(options)
|
|
609
871
|
|
|
610
|
-
const git = yield* GitService
|
|
611
872
|
const fs = yield* FileSystemService
|
|
612
873
|
|
|
613
874
|
const gitRoot = yield* ensureGitRepo()
|
|
@@ -644,6 +905,22 @@ const taskEditEffect = (options: TaskEditOptions = {}) =>
|
|
|
644
905
|
log(done("TASK.md edited"))
|
|
645
906
|
})
|
|
646
907
|
|
|
908
|
+
export const editHelp = `
|
|
909
|
+
Usage: agency edit [options]
|
|
910
|
+
|
|
911
|
+
Open TASK.md in the system editor for editing.
|
|
912
|
+
|
|
913
|
+
Notes:
|
|
914
|
+
- Requires TASK.md to exist (run 'agency task' first)
|
|
915
|
+
- Respects VISUAL and EDITOR environment variables
|
|
916
|
+
- On macOS, defaults to 'open' which uses the default app for .md files
|
|
917
|
+
- On other platforms, defaults to 'vim'
|
|
918
|
+
- The command waits for the editor to close before returning
|
|
919
|
+
|
|
920
|
+
Example:
|
|
921
|
+
agency edit # Open TASK.md in default editor
|
|
922
|
+
`
|
|
923
|
+
|
|
647
924
|
export const help = `
|
|
648
925
|
Usage: agency task [branch-name] [options]
|
|
649
926
|
|
|
@@ -663,9 +940,23 @@ Arguments:
|
|
|
663
940
|
branch-name Create and switch to this branch before initializing
|
|
664
941
|
|
|
665
942
|
Options:
|
|
666
|
-
|
|
943
|
+
--emit Branch name to create (alternative to positional arg)
|
|
944
|
+
--branch (Deprecated: use --emit) Branch name to create
|
|
667
945
|
--from <branch> Branch to branch from instead of main upstream branch
|
|
668
946
|
--from-current Branch from the current branch
|
|
947
|
+
--continue Continue a task by copying agency files to a new branch
|
|
948
|
+
|
|
949
|
+
Continue Mode (--continue):
|
|
950
|
+
After a PR is merged, use '--continue' to create a new branch that preserves
|
|
951
|
+
your agency files (TASK.md, AGENCY.md, opencode.json, agency.json, and all
|
|
952
|
+
backpacked files). This allows you to continue working on a task after its
|
|
953
|
+
PR has been merged to main.
|
|
954
|
+
|
|
955
|
+
The continue workflow:
|
|
956
|
+
1. Be on an agency source branch with agency files
|
|
957
|
+
2. Run 'agency task --continue <new-branch-name>'
|
|
958
|
+
3. A new branch is created from main with all your agency files
|
|
959
|
+
4. The emitBranch in agency.json is updated for the new branch
|
|
669
960
|
|
|
670
961
|
Base Branch Selection:
|
|
671
962
|
By default, 'agency task' branches from the main upstream branch (e.g., origin/main).
|
|
@@ -683,6 +974,7 @@ Examples:
|
|
|
683
974
|
agency task --from agency/branch-B # Branch from agency/branch-B's emit branch
|
|
684
975
|
agency task --from-current # Branch from current branch's emit branch
|
|
685
976
|
agency task my-feature --from develop # Create 'my-feature' from 'develop'
|
|
977
|
+
agency task --continue my-feature-v2 # Continue task on new branch after PR merge
|
|
686
978
|
|
|
687
979
|
Template Workflow:
|
|
688
980
|
1. Run 'agency init' to select template (saved to .git/config)
|
|
@@ -4,6 +4,7 @@ import { Schema } from "@effect/schema"
|
|
|
4
4
|
import { AgencyMetadata } from "../schemas"
|
|
5
5
|
import { FileSystemService } from "./FileSystemService"
|
|
6
6
|
import { GitService } from "./GitService"
|
|
7
|
+
import { expandGlobs } from "../utils/glob"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Error type for AgencyMetadata operations
|
|
@@ -184,7 +185,14 @@ export const AgencyMetadataServiceLive = Layer.succeed(
|
|
|
184
185
|
return baseFiles
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
|
|
188
|
+
// Expand any glob patterns in injectedFiles to actual file paths
|
|
189
|
+
const expandedFiles = yield* Effect.tryPromise({
|
|
190
|
+
try: () =>
|
|
191
|
+
expandGlobs([...baseFiles, ...metadata.injectedFiles], gitRoot),
|
|
192
|
+
catch: () => new Error("Failed to expand glob patterns"),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return expandedFiles
|
|
188
196
|
}).pipe(
|
|
189
197
|
Effect.catchAll(() =>
|
|
190
198
|
Effect.succeed(["TASK.md", "AGENCY.md", "agency.json"]),
|