@markjaquith/agency 1.11.0 → 2.0.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 +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +172 -419
- package/src/commands/work.ts +47 -166
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -518
- package/src/commands/push.ts +0 -434
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1183
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
package/src/commands/task.ts
CHANGED
|
@@ -1,1314 +1,112 @@
|
|
|
1
|
-
import { resolve, join, dirname } from "path"
|
|
2
1
|
import { Effect } from "effect"
|
|
3
2
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { PromptService } from "../services/PromptService"
|
|
8
|
-
import { TemplateService } from "../services/TemplateService"
|
|
9
|
-
import { OpencodeService } from "../services/OpencodeService"
|
|
10
|
-
import { ClaudeService } from "../services/ClaudeService"
|
|
11
|
-
import { AgencyMetadataService } from "../services/AgencyMetadataService"
|
|
12
|
-
import { FormatterService } from "../services/FormatterService"
|
|
13
|
-
import { initializeManagedFiles, writeAgencyMetadata } from "../types"
|
|
14
|
-
import { RepositoryNotInitializedError } from "../errors"
|
|
15
|
-
import highlight, { done, info, plural } from "../utils/colors"
|
|
16
|
-
import { createLoggers, ensureGitRepo, getTemplateName } from "../utils/effect"
|
|
17
|
-
import {
|
|
18
|
-
makeEmitBranchName,
|
|
19
|
-
extractCleanBranch,
|
|
20
|
-
makeSourceBranchName,
|
|
21
|
-
} from "../utils/pr-branch"
|
|
22
|
-
import { getTopLevelDir, dirToGlobPattern } from "../utils/glob"
|
|
23
|
-
import { AGENCY_REMOVE_COMMIT } from "../constants"
|
|
24
|
-
import { emitCore } from "./emit"
|
|
25
|
-
import { withBranchProtection } from "../utils/effect"
|
|
3
|
+
import { TaskService } from "../services/TaskService"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
26
6
|
|
|
27
7
|
interface TaskOptions extends BaseCommandOptions {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
8
|
+
readonly subcommand?: string
|
|
9
|
+
readonly args: readonly string[]
|
|
10
|
+
readonly ticketUrl?: string
|
|
11
|
+
readonly description?: string
|
|
12
|
+
readonly epic?: string
|
|
13
|
+
readonly repo?: string
|
|
14
|
+
readonly references?: readonly string[]
|
|
15
|
+
readonly branch?: string
|
|
16
|
+
readonly base?: string
|
|
17
|
+
readonly multiPhase?: boolean
|
|
18
|
+
readonly json?: boolean
|
|
36
19
|
}
|
|
37
20
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Continue a task by creating a new branch with the same agency files.
|
|
42
|
-
* This is useful after a PR is merged and you want to continue working on the task.
|
|
43
|
-
*
|
|
44
|
-
* With --squash: emits the old branch, squash-merges the emitted commits into the new
|
|
45
|
-
* branch as a single commit, and creates a fresh TASK.md with a new task description.
|
|
46
|
-
*/
|
|
47
|
-
const taskContinue = (options: TaskOptions) =>
|
|
21
|
+
export const task = (options: TaskOptions) =>
|
|
48
22
|
Effect.gen(function* () {
|
|
49
|
-
const
|
|
50
|
-
const { log
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (options.path) {
|
|
61
|
-
targetPath = resolve(options.path)
|
|
62
|
-
const isRoot = yield* git.isGitRoot(targetPath)
|
|
63
|
-
if (!isRoot) {
|
|
64
|
-
return yield* Effect.fail(
|
|
65
|
-
new Error(
|
|
66
|
-
"The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
|
|
67
|
-
),
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
} else {
|
|
71
|
-
const isRepo = yield* git.isInsideGitRepo(process.cwd())
|
|
72
|
-
if (!isRepo) {
|
|
73
|
-
return yield* Effect.fail(
|
|
74
|
-
new Error(
|
|
75
|
-
"Not in a git repository. Please run this command inside a git repo.",
|
|
76
|
-
),
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
targetPath = yield* git.getGitRoot(process.cwd())
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Check if initialized (has template in git config)
|
|
83
|
-
const templateName = yield* getTemplateName(targetPath)
|
|
84
|
-
if (!templateName) {
|
|
85
|
-
return yield* Effect.fail(new RepositoryNotInitializedError())
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const currentBranch = yield* git.getCurrentBranch(targetPath)
|
|
89
|
-
verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
90
|
-
|
|
91
|
-
// Load agency config for branch patterns
|
|
92
|
-
const config = yield* configService.loadConfig()
|
|
93
|
-
|
|
94
|
-
// Verify we're on an agency source branch by checking if agency.json exists
|
|
95
|
-
const agencyJsonPath = resolve(targetPath, "agency.json")
|
|
96
|
-
const hasAgencyJson = yield* fs.exists(agencyJsonPath)
|
|
97
|
-
|
|
98
|
-
if (!hasAgencyJson) {
|
|
99
|
-
return yield* Effect.fail(
|
|
100
|
-
new Error(
|
|
101
|
-
`No agency.json found on current branch ${highlight.branch(currentBranch)}.\n` +
|
|
102
|
-
`The --continue flag requires you to be on an agency source branch with existing agency files.`,
|
|
103
|
-
),
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Read the existing agency.json to get metadata
|
|
108
|
-
const existingMetadata = yield* Effect.gen(function* () {
|
|
109
|
-
const service = yield* AgencyMetadataService
|
|
110
|
-
return yield* service.readFromDisk(targetPath)
|
|
111
|
-
}).pipe(Effect.provide(AgencyMetadataService.Default))
|
|
112
|
-
|
|
113
|
-
if (!existingMetadata) {
|
|
114
|
-
return yield* Effect.fail(
|
|
115
|
-
new Error(
|
|
116
|
-
`Failed to read agency.json on branch ${highlight.branch(currentBranch)}.`,
|
|
117
|
-
),
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
verboseLog(
|
|
122
|
-
`Existing metadata: ${JSON.stringify(existingMetadata, null, 2)}`,
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
// Get the list of agency files to copy
|
|
126
|
-
const filesToCopy = [
|
|
127
|
-
"agency.json",
|
|
128
|
-
"TASK.md",
|
|
129
|
-
"AGENCY.md",
|
|
130
|
-
...existingMetadata.injectedFiles,
|
|
131
|
-
]
|
|
132
|
-
verboseLog(`Files to copy: ${filesToCopy.join(", ")}`)
|
|
133
|
-
|
|
134
|
-
// Read all the existing files content before switching branches
|
|
135
|
-
const fileContents = new Map<string, string>()
|
|
136
|
-
for (const file of filesToCopy) {
|
|
137
|
-
// When squashing, skip reading TASK.md — we'll generate a fresh one
|
|
138
|
-
if (squash && file === "TASK.md") {
|
|
139
|
-
verboseLog("Skipping TASK.md read (will create fresh for --squash)")
|
|
140
|
-
continue
|
|
141
|
-
}
|
|
142
|
-
const filePath = resolve(targetPath, file)
|
|
143
|
-
const exists = yield* fs.exists(filePath)
|
|
144
|
-
if (exists) {
|
|
145
|
-
const content = yield* fs.readFile(filePath)
|
|
146
|
-
fileContents.set(file, content)
|
|
147
|
-
verboseLog(`Read ${file} (${content.length} bytes)`)
|
|
148
|
-
} else {
|
|
149
|
-
verboseLog(`File ${file} not found, skipping`)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// When squashing, prompt for fresh task description before switching branches
|
|
154
|
-
let freshTaskDescription: string | undefined
|
|
155
|
-
if (squash) {
|
|
156
|
-
if (options.task) {
|
|
157
|
-
freshTaskDescription = options.task
|
|
158
|
-
verboseLog(`Using task from option: ${freshTaskDescription}`)
|
|
159
|
-
} else if (!silent) {
|
|
160
|
-
freshTaskDescription = yield* promptService.prompt(
|
|
161
|
-
"New task description: ",
|
|
162
|
-
)
|
|
163
|
-
if (!freshTaskDescription) {
|
|
164
|
-
log(
|
|
165
|
-
info(
|
|
166
|
-
"Skipping task description (TASK.md will use default placeholder)",
|
|
167
|
-
),
|
|
23
|
+
const tasks = yield* TaskService
|
|
24
|
+
const { log } = createLoggers(options)
|
|
25
|
+
const cwd = options.cwd ?? process.cwd()
|
|
26
|
+
|
|
27
|
+
switch (options.subcommand) {
|
|
28
|
+
case "create": {
|
|
29
|
+
const id = options.args[0]
|
|
30
|
+
if (!id || !options.ticketUrl) {
|
|
31
|
+
return yield* Effect.fail(
|
|
32
|
+
new Error("Usage: agency task create <id> --ticket-url <url>"),
|
|
168
33
|
)
|
|
169
34
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return yield* Effect.fail(
|
|
186
|
-
new Error("Branch name is required to continue the task."),
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Apply source pattern to get the full source branch name
|
|
192
|
-
const sourceBranchName = makeSourceBranchName(
|
|
193
|
-
branchName,
|
|
194
|
-
config.sourceBranchPattern,
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
// Check if the new branch already exists
|
|
198
|
-
const branchExists = yield* git.branchExists(targetPath, sourceBranchName)
|
|
199
|
-
if (branchExists) {
|
|
200
|
-
return yield* Effect.fail(
|
|
201
|
-
new Error(
|
|
202
|
-
`Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
|
|
203
|
-
`Choose a different branch name.`,
|
|
204
|
-
),
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// When squashing, emit the old source branch first (before switching away)
|
|
209
|
-
// This creates a clean emit branch with backpack files stripped out
|
|
210
|
-
let oldEmitBranchName: string | undefined
|
|
211
|
-
if (squash) {
|
|
212
|
-
const cleanFromCurrent = extractCleanBranch(
|
|
213
|
-
currentBranch,
|
|
214
|
-
config.sourceBranchPattern,
|
|
215
|
-
)
|
|
216
|
-
oldEmitBranchName = cleanFromCurrent
|
|
217
|
-
? makeEmitBranchName(cleanFromCurrent, config.emitBranch)
|
|
218
|
-
: existingMetadata.emitBranch || undefined
|
|
219
|
-
|
|
220
|
-
if (!oldEmitBranchName) {
|
|
221
|
-
return yield* Effect.fail(
|
|
222
|
-
new Error(
|
|
223
|
-
`Could not determine emit branch name for ${highlight.branch(currentBranch)}.\n` +
|
|
224
|
-
`Ensure agency.json has an emitBranch field or the branch follows the source pattern.`,
|
|
225
|
-
),
|
|
226
|
-
)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
verboseLog(
|
|
230
|
-
`Emitting ${highlight.branch(currentBranch)} → ${highlight.branch(oldEmitBranchName)}`,
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
yield* withBranchProtection(
|
|
234
|
-
targetPath,
|
|
235
|
-
emitCore(targetPath, {
|
|
236
|
-
silent: true,
|
|
237
|
-
verbose: options.verbose,
|
|
238
|
-
emit: oldEmitBranchName,
|
|
239
|
-
}),
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
log(done(`Emitted ${highlight.branch(oldEmitBranchName)} for squash`))
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Determine base branch to branch from
|
|
246
|
-
let baseBranchToBranchFrom: string | undefined
|
|
247
|
-
|
|
248
|
-
if (options.from) {
|
|
249
|
-
baseBranchToBranchFrom = options.from
|
|
250
|
-
const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
|
|
251
|
-
if (!exists) {
|
|
252
|
-
return yield* Effect.fail(
|
|
253
|
-
new Error(
|
|
254
|
-
`Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
|
|
255
|
-
),
|
|
256
|
-
)
|
|
257
|
-
}
|
|
258
|
-
} else {
|
|
259
|
-
// Default: branch from main upstream branch (preferring remote)
|
|
260
|
-
baseBranchToBranchFrom =
|
|
261
|
-
(yield* git.resolveMainBranch(targetPath)) || undefined
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (!baseBranchToBranchFrom) {
|
|
265
|
-
return yield* Effect.fail(
|
|
266
|
-
new Error(
|
|
267
|
-
"Could not determine base branch. Use --from to specify one.",
|
|
268
|
-
),
|
|
269
|
-
)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
verboseLog(
|
|
273
|
-
`Creating new branch ${highlight.branch(sourceBranchName)} from ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
// Create the new branch from the base branch
|
|
277
|
-
yield* git.createBranch(
|
|
278
|
-
sourceBranchName,
|
|
279
|
-
targetPath,
|
|
280
|
-
baseBranchToBranchFrom,
|
|
281
|
-
)
|
|
282
|
-
// Calculate the emit branch name for display
|
|
283
|
-
const cleanBranchForDisplay =
|
|
284
|
-
extractCleanBranch(sourceBranchName, config.sourceBranchPattern) ||
|
|
285
|
-
sourceBranchName
|
|
286
|
-
const emitBranchForDisplay = makeEmitBranchName(
|
|
287
|
-
cleanBranchForDisplay,
|
|
288
|
-
config.emitBranch,
|
|
289
|
-
)
|
|
290
|
-
|
|
291
|
-
log(
|
|
292
|
-
info(
|
|
293
|
-
`(${highlight.branch(baseBranchToBranchFrom)}) ${highlight.branch(sourceBranchName)} → ${highlight.branch(emitBranchForDisplay)}`,
|
|
294
|
-
),
|
|
295
|
-
)
|
|
296
|
-
log(done(`Created and switched to ${highlight.branch(sourceBranchName)}`))
|
|
297
|
-
|
|
298
|
-
// When squashing, merge the emitted commits as a single squash commit
|
|
299
|
-
if (squash && oldEmitBranchName) {
|
|
300
|
-
verboseLog(
|
|
301
|
-
`Squash-merging ${highlight.branch(oldEmitBranchName)} into ${highlight.branch(sourceBranchName)}`,
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
// Squash merge the emitted (clean) branch
|
|
305
|
-
const mergeResult = yield* git
|
|
306
|
-
.merge(targetPath, oldEmitBranchName, { squash: true })
|
|
307
|
-
.pipe(
|
|
308
|
-
Effect.catchAll((err) => {
|
|
309
|
-
return Effect.fail(
|
|
310
|
-
new Error(
|
|
311
|
-
`Squash merge of ${oldEmitBranchName} failed.\n` +
|
|
312
|
-
`This typically means there are conflicts with the base branch.\n` +
|
|
313
|
-
`Try rebasing the old branch first with 'agency rebase', then retry.\n` +
|
|
314
|
-
`Details: ${err}`,
|
|
315
|
-
),
|
|
316
|
-
)
|
|
317
|
-
}),
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
// Check if the squash merge produced any staged changes
|
|
321
|
-
const statusResult = yield* git
|
|
322
|
-
.runGitCommand(["git", "diff", "--cached", "--quiet"], targetPath)
|
|
323
|
-
.pipe(
|
|
324
|
-
Effect.catchAll(() =>
|
|
325
|
-
Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }),
|
|
326
|
-
),
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
if (statusResult.exitCode === 0) {
|
|
330
|
-
// No changes — the old branch had no code commits (only agency files)
|
|
331
|
-
verboseLog(
|
|
332
|
-
"Squash merge produced no changes (old branch had no code commits), skipping squash commit",
|
|
333
|
-
)
|
|
334
|
-
} else {
|
|
335
|
-
// There are staged changes — commit them
|
|
336
|
-
yield* git.gitCommit(
|
|
337
|
-
`squash: prior work from ${oldEmitBranchName}`,
|
|
338
|
-
targetPath,
|
|
339
|
-
{ noVerify: true },
|
|
340
|
-
)
|
|
35
|
+
const record = yield* tasks.create(
|
|
36
|
+
{
|
|
37
|
+
id,
|
|
38
|
+
ticketUrl: options.ticketUrl,
|
|
39
|
+
description: options.description,
|
|
40
|
+
epic: options.epic,
|
|
41
|
+
multiPhase: options.multiPhase,
|
|
42
|
+
repo: options.repo,
|
|
43
|
+
repos: parseRepositoryReferences(options.references),
|
|
44
|
+
branch: options.branch,
|
|
45
|
+
base: options.base,
|
|
46
|
+
},
|
|
47
|
+
cwd,
|
|
48
|
+
)
|
|
49
|
+
const { content: _, ...output } = record
|
|
341
50
|
log(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
)
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Calculate the new emit branch name
|
|
350
|
-
const newEmitBranchName = makeEmitBranchName(branchName, config.emitBranch)
|
|
351
|
-
verboseLog(`New emit branch name: ${newEmitBranchName}`)
|
|
352
|
-
|
|
353
|
-
// When squashing, generate fresh TASK.md content
|
|
354
|
-
if (squash) {
|
|
355
|
-
const taskTemplate = `{task}\n\n## Tasks\n\n- [ ] Populate this list with tasks\n`
|
|
356
|
-
const taskContent = freshTaskDescription
|
|
357
|
-
? taskTemplate.replace("{task}", freshTaskDescription)
|
|
358
|
-
: taskTemplate
|
|
359
|
-
fileContents.set("TASK.md", taskContent)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Write all the files to the new branch
|
|
363
|
-
const createdFiles: string[] = []
|
|
364
|
-
|
|
365
|
-
for (const [file, content] of fileContents) {
|
|
366
|
-
const filePath = resolve(targetPath, file)
|
|
367
|
-
|
|
368
|
-
// Ensure parent directory exists
|
|
369
|
-
const parentDir = resolve(filePath, "..")
|
|
370
|
-
const parentExists = yield* fs.exists(parentDir)
|
|
371
|
-
if (!parentExists) {
|
|
372
|
-
yield* fs.runCommand(["mkdir", "-p", parentDir])
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
let fileContent = content
|
|
376
|
-
|
|
377
|
-
// Update emitBranch in agency.json
|
|
378
|
-
if (file === "agency.json") {
|
|
379
|
-
const metadata = JSON.parse(content)
|
|
380
|
-
metadata.emitBranch = newEmitBranchName
|
|
381
|
-
metadata.createdAt = new Date().toISOString()
|
|
382
|
-
fileContent = JSON.stringify(metadata, null, 2) + "\n"
|
|
383
|
-
verboseLog(
|
|
384
|
-
`Updated agency.json with new emitBranch: ${newEmitBranchName}`,
|
|
385
|
-
)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Update emitBranch in opencode.json (if it has one)
|
|
389
|
-
if (file === "opencode.json" || file.endsWith("/opencode.json")) {
|
|
390
|
-
try {
|
|
391
|
-
const opencodeConfig = JSON.parse(content)
|
|
392
|
-
if (opencodeConfig.emitBranch) {
|
|
393
|
-
opencodeConfig.emitBranch = newEmitBranchName
|
|
394
|
-
fileContent = JSON.stringify(opencodeConfig, null, "\t") + "\n"
|
|
395
|
-
verboseLog(
|
|
396
|
-
`Updated ${file} with new emitBranch: ${newEmitBranchName}`,
|
|
397
|
-
)
|
|
398
|
-
}
|
|
399
|
-
} catch {
|
|
400
|
-
// If we can't parse it, just copy as-is
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
yield* fs.writeFile(filePath, fileContent)
|
|
405
|
-
createdFiles.push(file)
|
|
406
|
-
log(done(`Created ${highlight.file(file)}`))
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Format created files using project's formatter (prettier/oxfmt)
|
|
410
|
-
const formatterService = yield* FormatterService
|
|
411
|
-
yield* formatterService.formatFiles(targetPath, createdFiles, verboseLog)
|
|
412
|
-
|
|
413
|
-
// Git add and commit the created files
|
|
414
|
-
if (createdFiles.length > 0) {
|
|
415
|
-
yield* Effect.gen(function* () {
|
|
416
|
-
yield* git.gitAdd(createdFiles, targetPath)
|
|
417
|
-
// Format: chore: agency task --continue (baseBranch) originalSource => newSource => newEmit
|
|
418
|
-
const continueFlag = squash
|
|
419
|
-
? "agency task --continue --squash"
|
|
420
|
-
: "agency task --continue"
|
|
421
|
-
const commitMessage = `chore: ${continueFlag} (${baseBranchToBranchFrom}) ${currentBranch} → ${sourceBranchName} → ${newEmitBranchName}`
|
|
422
|
-
yield* git.gitCommit(commitMessage, targetPath, {
|
|
423
|
-
noVerify: true,
|
|
424
|
-
})
|
|
425
|
-
verboseLog(
|
|
426
|
-
`Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
|
|
427
|
-
)
|
|
428
|
-
}).pipe(
|
|
429
|
-
Effect.catchAll((err) => {
|
|
430
|
-
verboseLog(`Failed to commit: ${err}`)
|
|
431
|
-
return Effect.void
|
|
432
|
-
}),
|
|
433
|
-
)
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
log(
|
|
437
|
-
info(
|
|
438
|
-
squash
|
|
439
|
-
? `Continued task with squash and ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`
|
|
440
|
-
: `Continued task with ${createdFiles.length} file${plural(createdFiles.length)} from ${highlight.branch(currentBranch)}`,
|
|
441
|
-
),
|
|
442
|
-
)
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
export const task = (options: TaskOptions = {}) =>
|
|
446
|
-
Effect.gen(function* () {
|
|
447
|
-
const { silent = false, verbose = false } = options
|
|
448
|
-
const { log, verboseLog } = createLoggers(options)
|
|
449
|
-
|
|
450
|
-
// Validate --squash requires --continue
|
|
451
|
-
if (options.squash && !options.continue) {
|
|
452
|
-
return yield* Effect.fail(
|
|
453
|
-
new Error("The --squash flag can only be used with --continue."),
|
|
454
|
-
)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Handle --continue flag
|
|
458
|
-
if (options.continue) {
|
|
459
|
-
return yield* taskContinue(options)
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const git = yield* GitService
|
|
463
|
-
const configService = yield* ConfigService
|
|
464
|
-
const fs = yield* FileSystemService
|
|
465
|
-
const promptService = yield* PromptService
|
|
466
|
-
const templateService = yield* TemplateService
|
|
467
|
-
const opencodeService = yield* OpencodeService
|
|
468
|
-
const claudeService = yield* ClaudeService
|
|
469
|
-
|
|
470
|
-
// Determine target path
|
|
471
|
-
let targetPath: string
|
|
472
|
-
|
|
473
|
-
if (options.path) {
|
|
474
|
-
// If path is provided, validate it's a git repository root
|
|
475
|
-
targetPath = resolve(options.path)
|
|
476
|
-
|
|
477
|
-
const isRoot = yield* git.isGitRoot(targetPath)
|
|
478
|
-
if (!isRoot) {
|
|
479
|
-
return yield* Effect.fail(
|
|
480
|
-
new Error(
|
|
481
|
-
"The specified path is not the root of a git repository. Please provide a path to the top-level directory of a git checkout.",
|
|
482
|
-
),
|
|
483
|
-
)
|
|
484
|
-
}
|
|
485
|
-
} else {
|
|
486
|
-
// If no path provided, use git root of current directory
|
|
487
|
-
const isRepo = yield* git.isInsideGitRepo(process.cwd())
|
|
488
|
-
if (!isRepo) {
|
|
489
|
-
return yield* Effect.fail(
|
|
490
|
-
new Error(
|
|
491
|
-
"Not in a git repository. Please run this command inside a git repo.",
|
|
492
|
-
),
|
|
493
|
-
)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
targetPath = yield* git.getGitRoot(process.cwd())
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const createdFiles: string[] = []
|
|
500
|
-
const injectedFiles: string[] = []
|
|
501
|
-
// Track directories that we create during this task
|
|
502
|
-
// These will be converted to glob patterns for filtering
|
|
503
|
-
const createdDirs = new Set<string>()
|
|
504
|
-
|
|
505
|
-
// Check if initialized (has template in git config)
|
|
506
|
-
const templateName = yield* getTemplateName(targetPath)
|
|
507
|
-
|
|
508
|
-
if (!templateName) {
|
|
509
|
-
return yield* Effect.fail(new RepositoryNotInitializedError())
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
verboseLog(`Using template: ${templateName}`)
|
|
513
|
-
|
|
514
|
-
// Define path to TASK.md for later checks
|
|
515
|
-
const taskMdPath = resolve(targetPath, "TASK.md")
|
|
516
|
-
|
|
517
|
-
// Check if we're on a feature branch
|
|
518
|
-
const currentBranch = yield* git.getCurrentBranch(targetPath)
|
|
519
|
-
verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
|
|
520
|
-
const isFeature = yield* git.isFeatureBranch(currentBranch, targetPath)
|
|
521
|
-
verboseLog(`Is feature branch: ${isFeature}`)
|
|
522
|
-
|
|
523
|
-
// Check if we're on an agency source branch (has agency.json with backpacked files)
|
|
524
|
-
const agencyJsonPath = resolve(targetPath, "agency.json")
|
|
525
|
-
const hasAgencyJson = yield* fs.exists(agencyJsonPath)
|
|
526
|
-
verboseLog(`Has agency.json: ${hasAgencyJson}`)
|
|
527
|
-
|
|
528
|
-
// Determine base branch to branch from
|
|
529
|
-
let baseBranchToBranchFrom: string | undefined
|
|
530
|
-
|
|
531
|
-
// Handle --from and --from-current flags
|
|
532
|
-
if (options.from && options.fromCurrent) {
|
|
533
|
-
return yield* Effect.fail(
|
|
534
|
-
new Error("Cannot use both --from and --from-current flags together."),
|
|
535
|
-
)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
if (options.fromCurrent) {
|
|
539
|
-
// Branch from current branch
|
|
540
|
-
baseBranchToBranchFrom = currentBranch
|
|
541
|
-
verboseLog(
|
|
542
|
-
`Using current branch as base: ${highlight.branch(currentBranch)}`,
|
|
543
|
-
)
|
|
544
|
-
} else if (options.from) {
|
|
545
|
-
// Branch from specified branch
|
|
546
|
-
baseBranchToBranchFrom = options.from
|
|
547
|
-
verboseLog(
|
|
548
|
-
`Using specified branch as base: ${highlight.branch(options.from)}`,
|
|
549
|
-
)
|
|
550
|
-
|
|
551
|
-
// Verify the specified branch exists
|
|
552
|
-
const exists = yield* git.branchExists(targetPath, baseBranchToBranchFrom)
|
|
553
|
-
if (!exists) {
|
|
554
|
-
return yield* Effect.fail(
|
|
555
|
-
new Error(
|
|
556
|
-
`Branch ${highlight.branch(baseBranchToBranchFrom)} does not exist.`,
|
|
557
|
-
),
|
|
51
|
+
options.json
|
|
52
|
+
? JSON.stringify(output, null, 2)
|
|
53
|
+
: `Created task '${record.id}'`,
|
|
558
54
|
)
|
|
55
|
+
return
|
|
559
56
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const remote =
|
|
564
|
-
(yield* git.getRemoteConfig(targetPath)) ||
|
|
565
|
-
(yield* git.findDefaultRemote(targetPath))
|
|
566
|
-
|
|
567
|
-
if (remote) {
|
|
568
|
-
// Get just the branch name (e.g., "main") to fetch only that branch
|
|
569
|
-
// This is much faster than fetching all branches in large repos
|
|
570
|
-
const mainBranchName = yield* git.getMainBranchName(targetPath)
|
|
571
|
-
|
|
572
|
-
if (mainBranchName) {
|
|
57
|
+
case "list": {
|
|
58
|
+
const records = yield* tasks.list(cwd)
|
|
59
|
+
if (options.json) {
|
|
573
60
|
log(
|
|
574
|
-
|
|
575
|
-
|
|
61
|
+
JSON.stringify(
|
|
62
|
+
records.map(({ content: _, ...record }) => record),
|
|
63
|
+
null,
|
|
64
|
+
2,
|
|
576
65
|
),
|
|
577
66
|
)
|
|
578
|
-
verboseLog(`Fetching ${mainBranchName} from remote: ${remote}`)
|
|
579
|
-
yield* git.fetch(targetPath, remote, mainBranchName).pipe(
|
|
580
|
-
Effect.catchAll((err) => {
|
|
581
|
-
verboseLog(
|
|
582
|
-
`Failed to fetch ${mainBranchName} from ${remote}: ${err}`,
|
|
583
|
-
)
|
|
584
|
-
return Effect.void
|
|
585
|
-
}),
|
|
586
|
-
)
|
|
587
67
|
} else {
|
|
588
|
-
|
|
589
|
-
log(info(`Fetching latest from ${highlight.branch(remote)}...`))
|
|
590
|
-
verboseLog(`Fetching all branches from remote: ${remote}`)
|
|
591
|
-
yield* git.fetch(targetPath, remote).pipe(
|
|
592
|
-
Effect.catchAll((err) => {
|
|
593
|
-
verboseLog(`Failed to fetch from ${remote}: ${err}`)
|
|
594
|
-
return Effect.void
|
|
595
|
-
}),
|
|
596
|
-
)
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Now resolve the main branch (preferring remote)
|
|
601
|
-
baseBranchToBranchFrom =
|
|
602
|
-
(yield* git.resolveMainBranch(targetPath)) || undefined
|
|
603
|
-
|
|
604
|
-
if (baseBranchToBranchFrom) {
|
|
605
|
-
verboseLog(
|
|
606
|
-
`Auto-detected base branch: ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
607
|
-
)
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Load config early for branch pattern operations
|
|
612
|
-
const config = yield* configService.loadConfig()
|
|
613
|
-
|
|
614
|
-
// Check if the base branch is an agency source branch
|
|
615
|
-
// If so, we need to emit it first and use the emit branch instead
|
|
616
|
-
// Skip this check if using --from-current (we want to stay on current branch, not branch from it)
|
|
617
|
-
if (baseBranchToBranchFrom && !options.fromCurrent) {
|
|
618
|
-
const cleanFromBase = extractCleanBranch(
|
|
619
|
-
baseBranchToBranchFrom,
|
|
620
|
-
config.sourceBranchPattern,
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
if (cleanFromBase) {
|
|
624
|
-
// This is an agency source branch - we need to use its emit branch
|
|
625
|
-
verboseLog(
|
|
626
|
-
`Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch`,
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
const emitBranchName = makeEmitBranchName(
|
|
630
|
-
cleanFromBase,
|
|
631
|
-
config.emitBranch,
|
|
632
|
-
)
|
|
633
|
-
|
|
634
|
-
// Check if emit branch exists
|
|
635
|
-
const emitExists = yield* git.branchExists(targetPath, emitBranchName)
|
|
636
|
-
|
|
637
|
-
if (!emitExists) {
|
|
638
|
-
return yield* Effect.fail(
|
|
639
|
-
new Error(
|
|
640
|
-
`Base branch ${highlight.branch(baseBranchToBranchFrom)} is an agency source branch, ` +
|
|
641
|
-
`but its emit branch ${highlight.branch(emitBranchName)} does not exist.\n` +
|
|
642
|
-
`Please run 'agency emit' on ${highlight.branch(baseBranchToBranchFrom)} first, ` +
|
|
643
|
-
`or choose a different base branch.`,
|
|
644
|
-
),
|
|
645
|
-
)
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Use the emit branch as the base
|
|
649
|
-
baseBranchToBranchFrom = emitBranchName
|
|
650
|
-
verboseLog(
|
|
651
|
-
`Using emit branch as base: ${highlight.branch(baseBranchToBranchFrom)}`,
|
|
652
|
-
)
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Determine branch name logic
|
|
657
|
-
let branchName = options.emit || options.branch
|
|
658
|
-
|
|
659
|
-
// Determine if we need a new branch name:
|
|
660
|
-
// - With --from-current on a feature branch without agency.json: can stay on current branch
|
|
661
|
-
// - All other cases: require a new branch name
|
|
662
|
-
const canStayOnCurrentBranch =
|
|
663
|
-
options.fromCurrent && isFeature && !hasAgencyJson
|
|
664
|
-
|
|
665
|
-
if (!branchName && !canStayOnCurrentBranch) {
|
|
666
|
-
if (silent) {
|
|
667
|
-
if (hasAgencyJson) {
|
|
668
|
-
return yield* Effect.fail(
|
|
669
|
-
new Error(
|
|
670
|
-
`You're currently on ${highlight.branch(currentBranch)}, which is an agency source branch.\n` +
|
|
671
|
-
`Branch name is required when re-importing backpacked files.\n` +
|
|
672
|
-
`Use: 'agency task <branch-name>' or 'agency task --continue <branch-name>'`,
|
|
673
|
-
),
|
|
674
|
-
)
|
|
675
|
-
}
|
|
676
|
-
return yield* Effect.fail(
|
|
677
|
-
new Error(
|
|
678
|
-
`Branch name is required.\n` +
|
|
679
|
-
`Use: 'agency task <branch-name>'\n` +
|
|
680
|
-
`Or use --from-current to initialize on the current branch.`,
|
|
681
|
-
),
|
|
682
|
-
)
|
|
683
|
-
}
|
|
684
|
-
branchName = yield* promptService.prompt("Branch name: ")
|
|
685
|
-
if (!branchName) {
|
|
686
|
-
return yield* Effect.fail(new Error("Branch name is required."))
|
|
687
|
-
}
|
|
688
|
-
verboseLog(`Branch name from prompt: ${branchName}`)
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// If we have a branch name, apply source pattern and check if branch exists
|
|
692
|
-
let sourceBranchName: string | undefined
|
|
693
|
-
if (branchName) {
|
|
694
|
-
sourceBranchName = makeSourceBranchName(
|
|
695
|
-
branchName,
|
|
696
|
-
config.sourceBranchPattern,
|
|
697
|
-
)
|
|
698
|
-
|
|
699
|
-
const exists = yield* git.branchExists(targetPath, sourceBranchName)
|
|
700
|
-
if (exists) {
|
|
701
|
-
return yield* Effect.fail(
|
|
702
|
-
new Error(
|
|
703
|
-
`Branch ${highlight.branch(sourceBranchName)} already exists.\n` +
|
|
704
|
-
`Either switch to it first or choose a different branch name.`,
|
|
705
|
-
),
|
|
706
|
-
)
|
|
707
|
-
}
|
|
708
|
-
verboseLog(
|
|
709
|
-
`Branch ${sourceBranchName} does not exist (from clean name: ${branchName}), will create it`,
|
|
710
|
-
)
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// If we're going to create a branch, check if TASK.md will be created and prompt for description first
|
|
714
|
-
let taskDescription: string | undefined
|
|
715
|
-
if (branchName) {
|
|
716
|
-
const taskMdExists = yield* fs.exists(taskMdPath)
|
|
717
|
-
if (!taskMdExists) {
|
|
718
|
-
if (options.task) {
|
|
719
|
-
taskDescription = options.task
|
|
720
|
-
verboseLog(`Using task from option: ${taskDescription}`)
|
|
721
|
-
} else if (!silent) {
|
|
722
|
-
taskDescription = yield* promptService.prompt("Task description: ")
|
|
723
|
-
if (!taskDescription) {
|
|
724
|
-
log(
|
|
725
|
-
info(
|
|
726
|
-
"Skipping task description (TASK.md will use default placeholder)",
|
|
727
|
-
),
|
|
728
|
-
)
|
|
729
|
-
taskDescription = undefined
|
|
730
|
-
}
|
|
68
|
+
for (const record of records) log(record.id)
|
|
731
69
|
}
|
|
70
|
+
return
|
|
732
71
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
)
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// Get managed files for later use
|
|
746
|
-
const managedFiles = yield* Effect.tryPromise({
|
|
747
|
-
try: () => initializeManagedFiles(),
|
|
748
|
-
catch: (error) =>
|
|
749
|
-
new Error(`Failed to initialize managed files: ${error}`),
|
|
750
|
-
})
|
|
751
|
-
|
|
752
|
-
// Get template directory (it may or may not exist yet)
|
|
753
|
-
const templateDir = yield* templateService.getTemplateDir(templateName)
|
|
754
|
-
|
|
755
|
-
// Prompt for task if TASK.md will be created (only if not already prompted earlier)
|
|
756
|
-
if (taskDescription === undefined) {
|
|
757
|
-
const taskMdExists = yield* fs.exists(taskMdPath)
|
|
758
|
-
if (!taskMdExists) {
|
|
759
|
-
if (options.task) {
|
|
760
|
-
taskDescription = options.task
|
|
761
|
-
verboseLog(`Using task from option: ${taskDescription}`)
|
|
762
|
-
} else if (!silent) {
|
|
763
|
-
taskDescription = yield* promptService.prompt("Task description: ")
|
|
764
|
-
if (!taskDescription) {
|
|
765
|
-
log(
|
|
766
|
-
info(
|
|
767
|
-
"Skipping task description (TASK.md will use default placeholder)",
|
|
768
|
-
),
|
|
769
|
-
)
|
|
770
|
-
taskDescription = undefined
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Build list of files to create, combining managed files with any additional template files
|
|
777
|
-
const filesToCreate = new Map<string, string>() // fileName -> content source
|
|
778
|
-
|
|
779
|
-
// Start with all managed files (these should always be created)
|
|
780
|
-
for (const managedFile of managedFiles) {
|
|
781
|
-
filesToCreate.set(managedFile.name, "default")
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Discover all files from the template directory
|
|
785
|
-
const templateFiles = yield* discoverTemplateFiles(templateDir, verboseLog)
|
|
786
|
-
for (const relativePath of templateFiles) {
|
|
787
|
-
filesToCreate.set(relativePath, "template")
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
verboseLog(
|
|
791
|
-
`Discovered ${templateFiles.length} files in template: ${templateFiles.join(", ")}`,
|
|
792
|
-
)
|
|
793
|
-
|
|
794
|
-
// Check if opencode.json or opencode.jsonc already exists before processing files
|
|
795
|
-
const existingOpencodeInfo = yield* opencodeService
|
|
796
|
-
.detectOpencodeFile(targetPath)
|
|
797
|
-
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
798
|
-
|
|
799
|
-
// Process each file to create
|
|
800
|
-
for (const [fileName, source] of filesToCreate) {
|
|
801
|
-
const targetFilePath = resolve(targetPath, fileName)
|
|
802
|
-
|
|
803
|
-
// Special handling for opencode.json if opencode.json/jsonc already exists
|
|
804
|
-
if (fileName === "opencode.json" && existingOpencodeInfo) {
|
|
805
|
-
// Merge with existing file instead of creating new one
|
|
806
|
-
verboseLog(
|
|
807
|
-
`Found existing ${existingOpencodeInfo.relativePath}, will merge instructions`,
|
|
72
|
+
case "show": {
|
|
73
|
+
const id = options.args[0]
|
|
74
|
+
if (!id) return yield* Effect.fail(new Error("Task ID is required"))
|
|
75
|
+
const record = yield* tasks.show(id, cwd)
|
|
76
|
+
const { content: _, ...output } = record
|
|
77
|
+
log(
|
|
78
|
+
options.json
|
|
79
|
+
? JSON.stringify(output, null, 2)
|
|
80
|
+
: record.content.trimEnd(),
|
|
808
81
|
)
|
|
809
|
-
|
|
810
|
-
// Get the instructions we want to add from our default content
|
|
811
|
-
const managedFile = managedFiles.find((f) => f.name === "opencode.json")
|
|
812
|
-
const defaultContent = managedFile?.defaultContent ?? "{}"
|
|
813
|
-
const defaultConfig = JSON.parse(defaultContent) as {
|
|
814
|
-
instructions?: string[]
|
|
815
|
-
}
|
|
816
|
-
const instructionsToAdd = defaultConfig.instructions || []
|
|
817
|
-
|
|
818
|
-
// Merge the instructions
|
|
819
|
-
const mergedFilePath = yield* opencodeService
|
|
820
|
-
.mergeOpencodeFile(targetPath, instructionsToAdd)
|
|
821
|
-
.pipe(
|
|
822
|
-
Effect.catchAll((error) => {
|
|
823
|
-
verboseLog(
|
|
824
|
-
`Failed to merge ${existingOpencodeInfo.relativePath}: ${error.message}`,
|
|
825
|
-
)
|
|
826
|
-
return Effect.succeed(existingOpencodeInfo.relativePath)
|
|
827
|
-
}),
|
|
828
|
-
)
|
|
829
|
-
|
|
830
|
-
// Track the file that was modified (use the actual relative path)
|
|
831
|
-
createdFiles.push(mergedFilePath)
|
|
832
|
-
injectedFiles.push(mergedFilePath)
|
|
833
|
-
|
|
834
|
-
log(done(`Merged ${highlight.file(mergedFilePath)}`))
|
|
835
|
-
continue
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// Check if file exists in repo - if so, skip injection
|
|
839
|
-
const exists = yield* fs.exists(targetFilePath)
|
|
840
|
-
if (exists) {
|
|
841
|
-
log(info(`Skipped ${highlight.file(fileName)} (exists in repo)`))
|
|
842
|
-
continue
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// Check if this file is in a subdirectory that doesn't exist yet
|
|
846
|
-
// If so, we'll track it for glob pattern creation
|
|
847
|
-
const topLevelDir = getTopLevelDir(fileName)
|
|
848
|
-
if (topLevelDir && source === "template") {
|
|
849
|
-
const dirPath = resolve(targetPath, topLevelDir)
|
|
850
|
-
const dirExists = yield* fs.exists(dirPath)
|
|
851
|
-
if (!dirExists) {
|
|
852
|
-
createdDirs.add(topLevelDir)
|
|
853
|
-
verboseLog(`Will create new directory: ${topLevelDir}`)
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
let content: string
|
|
858
|
-
|
|
859
|
-
// Try to read from template first, fall back to default content
|
|
860
|
-
if (source === "template") {
|
|
861
|
-
const templateFilePath = join(templateDir, fileName)
|
|
862
|
-
content = yield* fs.readFile(templateFilePath)
|
|
863
|
-
} else {
|
|
864
|
-
// Use default content from managed files
|
|
865
|
-
const managedFile = managedFiles.find((f) => f.name === fileName)
|
|
866
|
-
content = managedFile?.defaultContent ?? ""
|
|
82
|
+
return
|
|
867
83
|
}
|
|
868
|
-
|
|
869
|
-
// Replace {task} placeholder in TASK.md if task description was provided
|
|
870
|
-
if (fileName === "TASK.md" && taskDescription) {
|
|
871
|
-
content = content.replace("{task}", taskDescription)
|
|
872
|
-
verboseLog(`Replaced {task} placeholder with: ${taskDescription}`)
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Ensure parent directory exists before writing file
|
|
876
|
-
const parentDir = dirname(targetFilePath)
|
|
877
|
-
const parentExists = yield* fs.exists(parentDir)
|
|
878
|
-
if (!parentExists) {
|
|
879
|
-
yield* fs.createDirectory(parentDir)
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
yield* fs.writeFile(targetFilePath, content)
|
|
883
|
-
createdFiles.push(fileName)
|
|
884
|
-
|
|
885
|
-
// Track backpack files (excluding TASK.md and AGENCY.md which are always filtered)
|
|
886
|
-
// For files in new directories, the glob pattern will be added below
|
|
887
|
-
if (fileName !== "TASK.md" && fileName !== "AGENCY.md") {
|
|
888
|
-
// Only track individual files if they're NOT in a newly created directory
|
|
889
|
-
// (directories will be tracked as glob patterns)
|
|
890
|
-
if (!topLevelDir || !createdDirs.has(topLevelDir)) {
|
|
891
|
-
injectedFiles.push(fileName)
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
log(done(`Created ${highlight.file(fileName)}`))
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Handle CLAUDE.md injection
|
|
899
|
-
// If CLAUDE.md is created (new file), include it in main commit and add to injectedFiles
|
|
900
|
-
// If CLAUDE.md is modified (existing file), commit separately with AGENCY_REMOVE_COMMIT marker
|
|
901
|
-
// so it can be completely removed during emission
|
|
902
|
-
const claudeResult = yield* claudeService.injectAgencySection(targetPath)
|
|
903
|
-
let claudeModifiedExisting = false
|
|
904
|
-
if (claudeResult.created) {
|
|
905
|
-
createdFiles.push("CLAUDE.md")
|
|
906
|
-
injectedFiles.push("CLAUDE.md")
|
|
907
|
-
log(done(`Created ${highlight.file("CLAUDE.md")}`))
|
|
908
|
-
} else if (claudeResult.modified) {
|
|
909
|
-
// Mark that we need a separate commit for CLAUDE.md modification
|
|
910
|
-
claudeModifiedExisting = true
|
|
911
|
-
log(done(`Updated ${highlight.file("CLAUDE.md")}`))
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Auto-detect base branch for this feature branch
|
|
915
|
-
let baseBranch: string | undefined
|
|
916
|
-
|
|
917
|
-
// Check repository-level default in git config
|
|
918
|
-
baseBranch =
|
|
919
|
-
(yield* git.getDefaultBaseBranchConfig(targetPath)) || undefined
|
|
920
|
-
|
|
921
|
-
// If no repo-level default, try to auto-detect (preferring remote)
|
|
922
|
-
if (!baseBranch) {
|
|
923
|
-
baseBranch = (yield* git.resolveMainBranch(targetPath)) || undefined
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
if (baseBranch) {
|
|
927
|
-
verboseLog(`Auto-detected base branch: ${highlight.branch(baseBranch)}`)
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// Calculate emitBranch name from current branch
|
|
931
|
-
const finalBranch = yield* git.getCurrentBranch(targetPath)
|
|
932
|
-
// Extract clean branch name from source pattern, or use branch as-is for legacy branches
|
|
933
|
-
const cleanBranch =
|
|
934
|
-
extractCleanBranch(finalBranch, config.sourceBranchPattern) || finalBranch
|
|
935
|
-
const emitBranchName = makeEmitBranchName(cleanBranch, config.emitBranch)
|
|
936
|
-
|
|
937
|
-
// Convert created directories to glob patterns and add to injectedFiles
|
|
938
|
-
// This allows filtering all files in new directories during emit
|
|
939
|
-
for (const dir of createdDirs) {
|
|
940
|
-
const globPattern = dirToGlobPattern(dir)
|
|
941
|
-
injectedFiles.push(globPattern)
|
|
942
|
-
verboseLog(`Tracking directory as glob pattern: ${globPattern}`)
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
// Create agency.json metadata file
|
|
946
|
-
const metadata = {
|
|
947
|
-
version: 1 as const,
|
|
948
|
-
injectedFiles,
|
|
949
|
-
baseBranch, // Save the base branch if detected
|
|
950
|
-
emitBranch: emitBranchName, // Save the emit branch name
|
|
951
|
-
template: templateName,
|
|
952
|
-
createdAt: new Date().toISOString(),
|
|
953
|
-
}
|
|
954
|
-
yield* Effect.tryPromise({
|
|
955
|
-
try: () => writeAgencyMetadata(targetPath, metadata as any),
|
|
956
|
-
catch: (error) => new Error(`Failed to write agency metadata: ${error}`),
|
|
957
|
-
})
|
|
958
|
-
createdFiles.push("agency.json")
|
|
959
|
-
log(done(`Created ${highlight.file("agency.json")}`))
|
|
960
|
-
if (baseBranch) {
|
|
961
|
-
verboseLog(`Base branch: ${highlight.branch(baseBranch)}`)
|
|
962
|
-
}
|
|
963
|
-
verboseLog(`Tracked backpack file${plural(injectedFiles.length)}`)
|
|
964
|
-
|
|
965
|
-
// Format created files using project's formatter (prettier/oxfmt)
|
|
966
|
-
const formatterService = yield* FormatterService
|
|
967
|
-
yield* formatterService.formatFiles(targetPath, createdFiles, verboseLog)
|
|
968
|
-
|
|
969
|
-
// Git add and commit the created files
|
|
970
|
-
if (createdFiles.length > 0) {
|
|
971
|
-
yield* Effect.gen(function* () {
|
|
972
|
-
yield* git.gitAdd(createdFiles, targetPath)
|
|
973
|
-
// Format: chore: agency task (baseBranch) sourceBranch → emitBranch
|
|
974
|
-
const commitMessage = baseBranch
|
|
975
|
-
? `chore: agency task (${baseBranch}) ${finalBranch} → ${emitBranchName}`
|
|
976
|
-
: `chore: agency task ${finalBranch} → ${emitBranchName}`
|
|
977
|
-
yield* git.gitCommit(commitMessage, targetPath, {
|
|
978
|
-
noVerify: true,
|
|
979
|
-
})
|
|
980
|
-
verboseLog(
|
|
981
|
-
`Committed ${createdFiles.length} file${plural(createdFiles.length)}`,
|
|
982
|
-
)
|
|
983
|
-
}).pipe(
|
|
984
|
-
Effect.catchAll((err) => {
|
|
985
|
-
// If commit fails, it might be because there are no changes (e.g., files already staged)
|
|
986
|
-
// We can ignore this error and let the user handle it manually
|
|
987
|
-
verboseLog(`Failed to commit: ${err}`)
|
|
988
|
-
return Effect.void
|
|
989
|
-
}),
|
|
990
|
-
)
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
// Create separate commit for CLAUDE.md modification with AGENCY_REMOVE_COMMIT marker
|
|
994
|
-
// This commit will be completely removed during emission
|
|
995
|
-
if (claudeModifiedExisting) {
|
|
996
|
-
yield* Effect.gen(function* () {
|
|
997
|
-
// Check if CLAUDE.md is a symlink - if so, we need to add both the symlink
|
|
998
|
-
// and its target file, since git add on a symlink only stages the symlink itself
|
|
999
|
-
const claudePath = resolve(targetPath, "CLAUDE.md")
|
|
1000
|
-
const filesToAdd = ["CLAUDE.md"]
|
|
1001
|
-
|
|
1002
|
-
const symlinkTarget = yield* fs.readSymlinkTarget(claudePath)
|
|
1003
|
-
if (symlinkTarget) {
|
|
1004
|
-
// Symlink target can be relative or absolute
|
|
1005
|
-
// If relative, resolve it relative to the symlink's directory
|
|
1006
|
-
const resolvedTarget = symlinkTarget.startsWith("/")
|
|
1007
|
-
? symlinkTarget
|
|
1008
|
-
: resolve(targetPath, symlinkTarget)
|
|
1009
|
-
|
|
1010
|
-
// Make the path relative to targetPath for git add
|
|
1011
|
-
const relativeTarget = resolvedTarget.startsWith(targetPath)
|
|
1012
|
-
? resolvedTarget.slice(targetPath.length + 1)
|
|
1013
|
-
: resolvedTarget
|
|
1014
|
-
|
|
1015
|
-
filesToAdd.push(relativeTarget)
|
|
1016
|
-
verboseLog(
|
|
1017
|
-
`CLAUDE.md is a symlink to ${relativeTarget}, adding both files`,
|
|
1018
|
-
)
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
yield* git.gitAdd(filesToAdd, targetPath)
|
|
1022
|
-
// The AGENCY_REMOVE_COMMIT marker in the commit body tells emit to drop this commit entirely
|
|
1023
|
-
const commitMessage = `chore: agency edit CLAUDE.md\n\n${AGENCY_REMOVE_COMMIT}`
|
|
1024
|
-
yield* git.gitCommit(commitMessage, targetPath, {
|
|
1025
|
-
noVerify: true,
|
|
1026
|
-
})
|
|
1027
|
-
verboseLog(
|
|
1028
|
-
"Created CLAUDE.md modification commit (will be removed on emit)",
|
|
1029
|
-
)
|
|
1030
|
-
}).pipe(
|
|
1031
|
-
Effect.catchAll((err) => {
|
|
1032
|
-
verboseLog(`Failed to commit CLAUDE.md modification: ${err}`)
|
|
1033
|
-
return Effect.void
|
|
1034
|
-
}),
|
|
1035
|
-
)
|
|
1036
|
-
}
|
|
1037
|
-
})
|
|
1038
|
-
|
|
1039
|
-
// Helper: Create feature branch with interactive prompts
|
|
1040
|
-
const createFeatureBranchEffect = (
|
|
1041
|
-
targetPath: string,
|
|
1042
|
-
branchName: string,
|
|
1043
|
-
providedBaseBranch: string | undefined,
|
|
1044
|
-
silent: boolean,
|
|
1045
|
-
verbose: boolean,
|
|
1046
|
-
) =>
|
|
1047
|
-
Effect.gen(function* () {
|
|
1048
|
-
const { log, verboseLog } = createLoggers({ silent, verbose })
|
|
1049
|
-
const git = yield* GitService
|
|
1050
|
-
const promptService = yield* PromptService
|
|
1051
|
-
|
|
1052
|
-
// Use provided base branch if available, otherwise resolve (preferring remote)
|
|
1053
|
-
let baseBranch: string | undefined = providedBaseBranch
|
|
1054
|
-
|
|
1055
|
-
if (!baseBranch) {
|
|
1056
|
-
baseBranch = (yield* git.resolveMainBranch(targetPath)) || undefined
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// If no base branch is configured and not in silent mode, prompt for it
|
|
1060
|
-
if (!baseBranch && !silent) {
|
|
1061
|
-
const suggestions = yield* git.getSuggestedBaseBranches(targetPath)
|
|
1062
|
-
if (suggestions.length > 0) {
|
|
1063
|
-
baseBranch = yield* promptService.promptForSelection(
|
|
1064
|
-
"Select base branch",
|
|
1065
|
-
suggestions,
|
|
1066
|
-
)
|
|
1067
|
-
verboseLog(`Selected base branch: ${baseBranch}`)
|
|
1068
|
-
} else {
|
|
84
|
+
default:
|
|
1069
85
|
return yield* Effect.fail(
|
|
1070
|
-
new Error(
|
|
1071
|
-
"Could not find any base branches. Please ensure your repository has at least one branch.",
|
|
1072
|
-
),
|
|
86
|
+
new Error("Subcommand is required. Available: create, list, show"),
|
|
1073
87
|
)
|
|
1074
|
-
}
|
|
1075
|
-
} else if (!baseBranch && silent) {
|
|
1076
|
-
return yield* Effect.fail(
|
|
1077
|
-
new Error(
|
|
1078
|
-
"No base branch configured. Run without --silent to configure, or set agency.mainBranch in git config.",
|
|
1079
|
-
),
|
|
1080
|
-
)
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
yield* git.createBranch(branchName, targetPath, baseBranch)
|
|
1084
|
-
|
|
1085
|
-
// Load config for emit pattern calculation
|
|
1086
|
-
const configService = yield* ConfigService
|
|
1087
|
-
const config = yield* configService.loadConfig()
|
|
1088
|
-
|
|
1089
|
-
// Calculate the emit branch name for display
|
|
1090
|
-
const cleanBranchForDisplay =
|
|
1091
|
-
extractCleanBranch(branchName, config.sourceBranchPattern) || branchName
|
|
1092
|
-
const emitBranchForDisplay = makeEmitBranchName(
|
|
1093
|
-
cleanBranchForDisplay,
|
|
1094
|
-
config.emitBranch,
|
|
1095
|
-
)
|
|
1096
|
-
|
|
1097
|
-
log(
|
|
1098
|
-
info(
|
|
1099
|
-
baseBranch
|
|
1100
|
-
? `(${highlight.branch(baseBranch)}) ${highlight.branch(branchName)} → ${highlight.branch(emitBranchForDisplay)}`
|
|
1101
|
-
: `${highlight.branch(branchName)} → ${highlight.branch(emitBranchForDisplay)}`,
|
|
1102
|
-
),
|
|
1103
|
-
)
|
|
1104
|
-
log(done(`Created and switched to ${highlight.branch(branchName)}`))
|
|
1105
|
-
})
|
|
1106
|
-
|
|
1107
|
-
// Helper: Discover template files
|
|
1108
|
-
const discoverTemplateFiles = (templateDir: string, verboseLog: Function) =>
|
|
1109
|
-
Effect.gen(function* () {
|
|
1110
|
-
const fs = yield* FileSystemService
|
|
1111
|
-
|
|
1112
|
-
try {
|
|
1113
|
-
const result = yield* fs.runCommand(["find", templateDir, "-type", "f"], {
|
|
1114
|
-
captureOutput: true,
|
|
1115
|
-
})
|
|
1116
|
-
|
|
1117
|
-
if (result.stdout) {
|
|
1118
|
-
const foundFiles = result.stdout
|
|
1119
|
-
.trim()
|
|
1120
|
-
.split("\n")
|
|
1121
|
-
.filter((f: string) => f.length > 0)
|
|
1122
|
-
const templateFiles: string[] = []
|
|
1123
|
-
|
|
1124
|
-
for (const file of foundFiles) {
|
|
1125
|
-
// Get relative path from template directory
|
|
1126
|
-
const relativePath = file.replace(templateDir + "/", "")
|
|
1127
|
-
if (relativePath) {
|
|
1128
|
-
templateFiles.push(relativePath)
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
return templateFiles
|
|
1133
|
-
}
|
|
1134
|
-
} catch (err) {
|
|
1135
|
-
verboseLog(`Error discovering template files: ${err}`)
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
return []
|
|
1139
|
-
})
|
|
1140
|
-
|
|
1141
|
-
// Effect-based taskEdit implementation
|
|
1142
|
-
const taskEditEffect = (options: TaskEditOptions = {}) =>
|
|
1143
|
-
Effect.gen(function* () {
|
|
1144
|
-
const { log, verboseLog } = createLoggers(options)
|
|
1145
|
-
|
|
1146
|
-
const fs = yield* FileSystemService
|
|
1147
|
-
const git = yield* GitService
|
|
1148
|
-
|
|
1149
|
-
const gitRoot = yield* ensureGitRepo()
|
|
1150
|
-
|
|
1151
|
-
const taskFilePath = resolve(gitRoot, "TASK.md")
|
|
1152
|
-
verboseLog(`TASK.md path: ${taskFilePath}`)
|
|
1153
|
-
|
|
1154
|
-
// Check if TASK.md exists
|
|
1155
|
-
const exists = yield* fs.exists(taskFilePath)
|
|
1156
|
-
if (!exists) {
|
|
1157
|
-
return yield* Effect.fail(
|
|
1158
|
-
new Error(
|
|
1159
|
-
"TASK.md not found in repository root. Run 'agency task' first to create it.",
|
|
1160
|
-
),
|
|
1161
|
-
)
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Get editor from environment or use sensible defaults
|
|
1165
|
-
// On macOS, use 'open -W' to wait for the editor to close
|
|
1166
|
-
const editor =
|
|
1167
|
-
process.env.VISUAL ||
|
|
1168
|
-
process.env.EDITOR ||
|
|
1169
|
-
(process.platform === "darwin" ? "open" : "vim")
|
|
1170
|
-
|
|
1171
|
-
verboseLog(`Using editor: ${editor}`)
|
|
1172
|
-
|
|
1173
|
-
// Build the command array
|
|
1174
|
-
// If using 'open' on macOS, add -W flag to wait for the app to close
|
|
1175
|
-
const editorCommand =
|
|
1176
|
-
editor === "open" ? [editor, "-W", taskFilePath] : [editor, taskFilePath]
|
|
1177
|
-
|
|
1178
|
-
// Use interactive mode for editors that need terminal access (stdin/stdout/stderr)
|
|
1179
|
-
// 'open' on macOS launches a separate app, so it doesn't need interactive mode
|
|
1180
|
-
const result = yield* fs.runCommand(editorCommand, {
|
|
1181
|
-
interactive: editor !== "open",
|
|
1182
|
-
})
|
|
1183
|
-
|
|
1184
|
-
if (result.exitCode !== 0) {
|
|
1185
|
-
return yield* Effect.fail(
|
|
1186
|
-
new Error(`Editor exited with code ${result.exitCode}`),
|
|
1187
|
-
)
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
log(done("TASK.md edited"))
|
|
1191
|
-
|
|
1192
|
-
// Check if TASK.md has uncommitted changes
|
|
1193
|
-
const hasChanges = yield* git.hasUncommittedChanges(gitRoot, "TASK.md")
|
|
1194
|
-
verboseLog(`TASK.md has uncommitted changes: ${hasChanges}`)
|
|
1195
|
-
|
|
1196
|
-
if (hasChanges) {
|
|
1197
|
-
// Commit the changes
|
|
1198
|
-
yield* Effect.gen(function* () {
|
|
1199
|
-
yield* git.gitAdd(["TASK.md"], gitRoot)
|
|
1200
|
-
yield* git.gitCommit("chore: agency edit", gitRoot, {
|
|
1201
|
-
noVerify: true,
|
|
1202
|
-
})
|
|
1203
|
-
log(done("Committed TASK.md changes"))
|
|
1204
|
-
}).pipe(
|
|
1205
|
-
Effect.catchAll((err) => {
|
|
1206
|
-
verboseLog(`Failed to commit TASK.md: ${err}`)
|
|
1207
|
-
return Effect.void
|
|
1208
|
-
}),
|
|
1209
|
-
)
|
|
1210
88
|
}
|
|
1211
89
|
})
|
|
1212
90
|
|
|
1213
|
-
export const editHelp = `
|
|
1214
|
-
Usage: agency edit [options]
|
|
1215
|
-
|
|
1216
|
-
Open TASK.md in the system editor for editing.
|
|
1217
|
-
|
|
1218
|
-
Notes:
|
|
1219
|
-
- Requires TASK.md to exist (run 'agency task' first)
|
|
1220
|
-
- Respects VISUAL and EDITOR environment variables
|
|
1221
|
-
- On macOS, defaults to 'open' which uses the default app for .md files
|
|
1222
|
-
- On other platforms, defaults to 'vim'
|
|
1223
|
-
- The command waits for the editor to close before returning
|
|
1224
|
-
|
|
1225
|
-
Example:
|
|
1226
|
-
agency edit # Open TASK.md in default editor
|
|
1227
|
-
`
|
|
1228
|
-
|
|
1229
91
|
export const help = `
|
|
1230
|
-
Usage: agency task <
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
92
|
+
Usage: agency task <subcommand>
|
|
93
|
+
|
|
94
|
+
Subcommands:
|
|
95
|
+
create <id> Create a task
|
|
96
|
+
list List tasks
|
|
97
|
+
show <id> Show a task
|
|
98
|
+
|
|
99
|
+
Create options:
|
|
100
|
+
--ticket-url <url> External ticket URL
|
|
101
|
+
--description <text> Short description of the task
|
|
102
|
+
--epic <id> Parent epic
|
|
103
|
+
--repo <alias> Writable repository
|
|
104
|
+
--reference <alias>:<ref>
|
|
105
|
+
Read-only repository reference; repeatable
|
|
106
|
+
--branch <name> Working branch
|
|
107
|
+
--base <name> Base branch
|
|
108
|
+
--multi-phase Create a task container for phases
|
|
1240
109
|
|
|
1241
110
|
Options:
|
|
1242
|
-
--
|
|
1243
|
-
--branch (Deprecated: use --emit) Branch name to create
|
|
1244
|
-
--from <branch> Branch to branch from instead of main upstream branch
|
|
1245
|
-
--from-current Initialize on current branch instead of creating a new one
|
|
1246
|
-
--continue Continue a task by copying agency files to a new branch
|
|
1247
|
-
--squash With --continue: squash the old branch's emitted commits into one
|
|
1248
|
-
--task <desc> Task description for TASK.md (avoids interactive prompt)
|
|
1249
|
-
|
|
1250
|
-
Continue Mode (--continue):
|
|
1251
|
-
After a PR is merged, use '--continue' to create a new branch that preserves
|
|
1252
|
-
your agency files (TASK.md, AGENCY.md, opencode.json, agency.json, and all
|
|
1253
|
-
backpacked files). This allows you to continue working on a task after its
|
|
1254
|
-
PR has been merged to main.
|
|
1255
|
-
|
|
1256
|
-
The continue workflow:
|
|
1257
|
-
1. Be on an agency source branch with agency files
|
|
1258
|
-
2. Run 'agency task --continue <new-branch-name>'
|
|
1259
|
-
3. A new branch is created from main with all your agency files
|
|
1260
|
-
4. The emitBranch in agency.json is updated for the new branch
|
|
1261
|
-
|
|
1262
|
-
Squash Mode (--continue --squash):
|
|
1263
|
-
Combines --continue with squashing the old branch's emitted commits into a
|
|
1264
|
-
single commit on the new branch. This is useful when you want to carry forward
|
|
1265
|
-
code changes but collapse the commit history. A fresh TASK.md is created with
|
|
1266
|
-
a new task description.
|
|
1267
|
-
|
|
1268
|
-
The squash workflow:
|
|
1269
|
-
1. Be on an agency source branch with agency files
|
|
1270
|
-
2. Run 'agency task --continue --squash <new-branch-name>'
|
|
1271
|
-
3. The old branch is emitted (backpack files stripped)
|
|
1272
|
-
4. A new branch is created from main
|
|
1273
|
-
5. The emitted commits are squash-merged as a single commit
|
|
1274
|
-
6. Fresh agency files (including new TASK.md) are added
|
|
1275
|
-
|
|
1276
|
-
Base Branch Selection:
|
|
1277
|
-
By default, 'agency task' fetches from the remote and branches from the latest
|
|
1278
|
-
main upstream branch (e.g., origin/main). You can override this behavior with:
|
|
1279
|
-
|
|
1280
|
-
- --from <branch>: Branch from a specific branch
|
|
1281
|
-
- --from-current: Initialize on your current branch (no new branch created)
|
|
1282
|
-
|
|
1283
|
-
If the base branch is an agency source branch (e.g., agency--branch-A), the command
|
|
1284
|
-
will automatically use its emit branch instead. This allows you to layer work on top
|
|
1285
|
-
of other feature branches while maintaining clean branch history.
|
|
1286
|
-
|
|
1287
|
-
Examples:
|
|
1288
|
-
agency task my-feature # Create 'my-feature' from latest origin/main
|
|
1289
|
-
agency task my-feature --from develop # Create 'my-feature' from 'develop'
|
|
1290
|
-
agency task --from-current # Initialize on current branch (no new branch)
|
|
1291
|
-
agency task --continue my-feature-v2 # Continue task on new branch after PR merge
|
|
1292
|
-
agency task --continue --squash v2 # Continue with squashed code from old branch
|
|
1293
|
-
|
|
1294
|
-
Template Workflow:
|
|
1295
|
-
1. Run 'agency init' to select template (saved to .git/config)
|
|
1296
|
-
2. Run 'agency task <branch-name>' to create feature branch with template files
|
|
1297
|
-
3. Use 'agency template save <file>' to update template with local changes
|
|
1298
|
-
4. Template directory only created when you save files to it
|
|
1299
|
-
|
|
1300
|
-
Branch Creation:
|
|
1301
|
-
When creating a new branch without --from or --from-current:
|
|
1302
|
-
1. Fetches from the configured remote (or origin)
|
|
1303
|
-
2. Auto-detects main upstream branch (origin/main, origin/master, etc.)
|
|
1304
|
-
3. Creates new branch from the latest remote main branch
|
|
1305
|
-
|
|
1306
|
-
Notes:
|
|
1307
|
-
- Files are created at the git repository root, not the current directory
|
|
1308
|
-
- If files already exist in the repository, they will not be overwritten
|
|
1309
|
-
- Template selection is stored in .git/config (not committed)
|
|
1310
|
-
- To edit TASK.md after creation, use 'agency edit'
|
|
111
|
+
--json Output results as JSON
|
|
1311
112
|
`
|
|
1312
|
-
|
|
1313
|
-
export const taskEdit = (options: TaskEditOptions = {}) =>
|
|
1314
|
-
taskEditEffect(options)
|