@markjaquith/agency 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -16
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +31 -4
- package/src/cli.test.ts +1 -0
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/pr.ts +1 -0
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/work.test.ts +63 -34
- package/src/commands/work.ts +20 -3
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/FileSystemService.ts +7 -2
- package/src/services/PhaseService.ts +28 -0
- package/src/services/PullRequestService.ts +3 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +66 -0
- package/src/services/WorkbaseService.ts +32 -2
- package/src/services/WorktreeService.test.ts +210 -2
- package/src/services/WorktreeService.ts +143 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/process.test.ts +38 -1
- package/src/utils/process.ts +30 -6
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +50 -0
- package/src/workbase/schemas.ts +6 -2
- package/src/workbase/work-target.test.ts +9 -9
- package/src/workbase/work-target.ts +36 -6
package/src/commands/task.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { createInterface } from "node:readline/promises"
|
|
2
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
4
|
import { TaskService } from "../services/TaskService"
|
|
5
|
+
import { EpicService } from "../services/EpicService"
|
|
6
|
+
import { RepositoryService } from "../services/RepositoryService"
|
|
4
7
|
import { createLoggers } from "../utils/effect"
|
|
5
8
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
6
9
|
|
|
@@ -18,31 +21,151 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
18
21
|
readonly json?: boolean
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
export
|
|
24
|
+
export interface TaskInteraction {
|
|
25
|
+
readonly text: (prompt: string) => Effect.Effect<string, Error>
|
|
26
|
+
readonly select: (
|
|
27
|
+
prompt: string,
|
|
28
|
+
choices: readonly string[],
|
|
29
|
+
) => Effect.Effect<string | null, Error>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultInteraction: TaskInteraction = {
|
|
33
|
+
text: (prompt) =>
|
|
34
|
+
Effect.tryPromise({
|
|
35
|
+
try: async () => {
|
|
36
|
+
const input = createInterface({
|
|
37
|
+
input: process.stdin,
|
|
38
|
+
output: process.stderr,
|
|
39
|
+
})
|
|
40
|
+
try {
|
|
41
|
+
return await input.question(prompt)
|
|
42
|
+
} finally {
|
|
43
|
+
input.close()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
catch: (cause) => new Error("Failed to read task input", { cause }),
|
|
47
|
+
}),
|
|
48
|
+
select: (prompt, choices) =>
|
|
49
|
+
Effect.tryPromise({
|
|
50
|
+
try: async () => {
|
|
51
|
+
const process = Bun.spawn(
|
|
52
|
+
["fzf", `--prompt=${prompt}> `, "--height=~40%", "--reverse"],
|
|
53
|
+
{
|
|
54
|
+
stdin: new Blob([choices.join("\n")]),
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "inherit",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
const [exitCode, output] = await Promise.all([
|
|
60
|
+
process.exited,
|
|
61
|
+
new Response(process.stdout).text(),
|
|
62
|
+
])
|
|
63
|
+
if (exitCode === 1 || exitCode === 130) return null
|
|
64
|
+
if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
|
|
65
|
+
return output.trim() || null
|
|
66
|
+
},
|
|
67
|
+
catch: (cause) =>
|
|
68
|
+
new Error("Failed to select task input with fzf", { cause }),
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const task = (
|
|
73
|
+
options: TaskOptions,
|
|
74
|
+
interaction: TaskInteraction = defaultInteraction,
|
|
75
|
+
) =>
|
|
22
76
|
Effect.gen(function* () {
|
|
23
77
|
const tasks = yield* TaskService
|
|
78
|
+
const epics = yield* EpicService
|
|
79
|
+
const repositories = yield* RepositoryService
|
|
24
80
|
const { log } = createLoggers(options)
|
|
25
81
|
const cwd = options.cwd ?? process.cwd()
|
|
26
82
|
|
|
27
83
|
switch (options.subcommand) {
|
|
84
|
+
case "new":
|
|
28
85
|
case "create": {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
86
|
+
const interactive = options.subcommand === "new" || !options.args[0]
|
|
87
|
+
const id =
|
|
88
|
+
options.args[0] ??
|
|
89
|
+
(interactive
|
|
90
|
+
? (yield* interaction.text("Task ID: ")).trim()
|
|
91
|
+
: undefined)
|
|
92
|
+
if (!id) {
|
|
93
|
+
return yield* Effect.fail(new Error("Task ID is required"))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let ticketUrl = options.ticketUrl?.trim() || null
|
|
97
|
+
let description = options.description?.trim() || undefined
|
|
98
|
+
let epic = options.epic
|
|
99
|
+
let multiPhase = options.multiPhase ?? false
|
|
100
|
+
let repo = options.repo
|
|
101
|
+
|
|
102
|
+
if (interactive) {
|
|
103
|
+
if (options.ticketUrl === undefined) {
|
|
104
|
+
ticketUrl =
|
|
105
|
+
(yield* interaction.text("Ticket URL (optional): ")).trim() ||
|
|
106
|
+
null
|
|
107
|
+
}
|
|
108
|
+
if (options.description === undefined) {
|
|
109
|
+
description =
|
|
110
|
+
(yield* interaction.text("Description (optional): ")).trim() ||
|
|
111
|
+
undefined
|
|
112
|
+
}
|
|
113
|
+
if (options.epic === undefined) {
|
|
114
|
+
const epicRecords = yield* epics.list(cwd)
|
|
115
|
+
if (epicRecords.length > 0) {
|
|
116
|
+
const none = "(none)"
|
|
117
|
+
const selected = yield* interaction.select("Parent epic", [
|
|
118
|
+
none,
|
|
119
|
+
...epicRecords.map((record) => record.id),
|
|
120
|
+
])
|
|
121
|
+
if (selected === null) {
|
|
122
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
123
|
+
}
|
|
124
|
+
epic = selected === none ? undefined : selected
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (options.multiPhase === undefined) {
|
|
128
|
+
const selected = yield* interaction.select("Task type", [
|
|
129
|
+
"single-phase",
|
|
130
|
+
"multi-phase",
|
|
131
|
+
])
|
|
132
|
+
if (selected === null) {
|
|
133
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
134
|
+
}
|
|
135
|
+
multiPhase = selected === "multi-phase"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!multiPhase && !repo) {
|
|
140
|
+
const records = yield* repositories.list(cwd)
|
|
141
|
+
if (records.length === 0) {
|
|
142
|
+
return yield* Effect.fail(
|
|
143
|
+
new Error(
|
|
144
|
+
"No repositories found; add or link a repository first",
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const selected = yield* interaction.select(
|
|
149
|
+
"Writable repository",
|
|
150
|
+
records.map((record) => record.alias),
|
|
33
151
|
)
|
|
152
|
+
if (!selected) {
|
|
153
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
154
|
+
}
|
|
155
|
+
repo = selected
|
|
34
156
|
}
|
|
157
|
+
|
|
35
158
|
const record = yield* tasks.create(
|
|
36
159
|
{
|
|
37
160
|
id,
|
|
38
|
-
ticketUrl
|
|
39
|
-
description
|
|
40
|
-
epic
|
|
41
|
-
multiPhase
|
|
42
|
-
repo
|
|
161
|
+
ticketUrl,
|
|
162
|
+
description,
|
|
163
|
+
epic,
|
|
164
|
+
multiPhase,
|
|
165
|
+
repo,
|
|
43
166
|
repos: parseRepositoryReferences(options.references),
|
|
44
|
-
branch: options.branch,
|
|
45
|
-
base: options.base,
|
|
167
|
+
branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
|
|
168
|
+
base: multiPhase ? undefined : (options.base ?? "main"),
|
|
46
169
|
},
|
|
47
170
|
cwd,
|
|
48
171
|
)
|
|
@@ -81,9 +204,27 @@ export const task = (options: TaskOptions) =>
|
|
|
81
204
|
)
|
|
82
205
|
return
|
|
83
206
|
}
|
|
207
|
+
case "status": {
|
|
208
|
+
const [id, status] = options.args
|
|
209
|
+
if (!id || !status) {
|
|
210
|
+
return yield* Effect.fail(
|
|
211
|
+
new Error("Usage: agency task status <id> <status>"),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
const record = yield* tasks.setStatus(id, status, cwd)
|
|
215
|
+
const { content: _, ...output } = record
|
|
216
|
+
log(
|
|
217
|
+
options.json
|
|
218
|
+
? JSON.stringify(output, null, 2)
|
|
219
|
+
: `Marked task '${id}' as ${record.data.status}`,
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
84
223
|
default:
|
|
85
224
|
return yield* Effect.fail(
|
|
86
|
-
new Error(
|
|
225
|
+
new Error(
|
|
226
|
+
"Subcommand is required. Available: new, create, list, show, status",
|
|
227
|
+
),
|
|
87
228
|
)
|
|
88
229
|
}
|
|
89
230
|
})
|
|
@@ -92,19 +233,21 @@ export const help = `
|
|
|
92
233
|
Usage: agency task <subcommand>
|
|
93
234
|
|
|
94
235
|
Subcommands:
|
|
95
|
-
|
|
236
|
+
new [id] Create a task with guided input
|
|
237
|
+
create <id> Create a task; omitted metadata uses defaults
|
|
96
238
|
list List tasks
|
|
97
239
|
show <id> Show a task
|
|
240
|
+
status <id> <status> Set open, working, done, or dropped
|
|
98
241
|
|
|
99
242
|
Create options:
|
|
100
|
-
--ticket-url <url> External ticket URL
|
|
243
|
+
--ticket-url <url> External ticket URL (optional)
|
|
101
244
|
--description <text> Short description of the task
|
|
102
245
|
--epic <id> Parent epic
|
|
103
246
|
--repo <alias> Writable repository
|
|
104
247
|
--reference <alias>:<ref>
|
|
105
248
|
Read-only repository reference; repeatable
|
|
106
|
-
--branch <name> Working branch
|
|
107
|
-
--base <name> Base branch
|
|
249
|
+
--branch <name> Working branch (default: task/<id>)
|
|
250
|
+
--base <name> Base branch (default: main)
|
|
108
251
|
--multi-phase Create a task container for phases
|
|
109
252
|
|
|
110
253
|
Options:
|
|
@@ -9,6 +9,7 @@ import { WorktreeService } from "../services/WorktreeService"
|
|
|
9
9
|
import { captureLogs } from "../test-utils"
|
|
10
10
|
import { work } from "./work"
|
|
11
11
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
12
|
+
import type { Progress } from "../utils/progress"
|
|
12
13
|
|
|
13
14
|
type ExecutionWorkspace = Effect.Effect.Success<
|
|
14
15
|
ReturnType<WorktreeService["materialize"]>
|
|
@@ -47,14 +48,25 @@ interface HarnessOptions {
|
|
|
47
48
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
48
49
|
const events: string[] = []
|
|
49
50
|
const probes: string[] = []
|
|
51
|
+
const statusUpdates: string[] = []
|
|
52
|
+
const progressUpdates: string[] = []
|
|
50
53
|
const launches: Array<{
|
|
51
54
|
cli: string
|
|
52
55
|
args: readonly string[]
|
|
53
56
|
cwd: string
|
|
54
57
|
}> = []
|
|
58
|
+
const materializeOptions: Array<
|
|
59
|
+
Parameters<WorktreeService["materialize"]>[3]
|
|
60
|
+
> = []
|
|
55
61
|
const worktrees = {
|
|
56
|
-
materialize: (
|
|
62
|
+
materialize: (
|
|
63
|
+
_taskId: string,
|
|
64
|
+
_phaseId?: string,
|
|
65
|
+
_root?: string,
|
|
66
|
+
commandOptions?: Parameters<WorktreeService["materialize"]>[3],
|
|
67
|
+
) => {
|
|
57
68
|
events.push("materialize")
|
|
69
|
+
materializeOptions.push(commandOptions)
|
|
58
70
|
return options.materializeError
|
|
59
71
|
? Effect.fail(options.materializeError)
|
|
60
72
|
: Effect.succeed(options.workspace ?? singlePhaseWorkspace)
|
|
@@ -82,6 +94,10 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
82
94
|
: { repo: "agency", branch: `task/${id}`, base: "main" },
|
|
83
95
|
}),
|
|
84
96
|
list: () => Effect.succeed(options.taskRecords ?? []),
|
|
97
|
+
setStatus: (id: string, status: string) => {
|
|
98
|
+
statusUpdates.push(`task:${id}:${status}`)
|
|
99
|
+
return Effect.void
|
|
100
|
+
},
|
|
85
101
|
}
|
|
86
102
|
const phases = {
|
|
87
103
|
show: (taskId: string, id: string) =>
|
|
@@ -92,6 +108,10 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
92
108
|
data: { repo: "agency", branch: `task/${id}`, base: "main" },
|
|
93
109
|
}),
|
|
94
110
|
list: () => Effect.succeed(options.phaseRecords ?? []),
|
|
111
|
+
setStatus: (taskId: string, id: string, status: string) => {
|
|
112
|
+
statusUpdates.push(`phase:${taskId}:${id}:${status}`)
|
|
113
|
+
return Effect.void
|
|
114
|
+
},
|
|
95
115
|
}
|
|
96
116
|
const fs = {
|
|
97
117
|
runCommand: (args: readonly string[]) => {
|
|
@@ -110,12 +130,17 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
110
130
|
launches.push({ cli, args, cwd })
|
|
111
131
|
}
|
|
112
132
|
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
133
|
+
const progress: Progress = {
|
|
134
|
+
start: (message) => progressUpdates.push(`start:${message}`),
|
|
135
|
+
succeed: (message) => progressUpdates.push(`succeed:${message}`),
|
|
136
|
+
fail: (message) => progressUpdates.push(`fail:${message}`),
|
|
137
|
+
}
|
|
113
138
|
const run = (
|
|
114
139
|
commandOptions: Parameters<typeof work>[0],
|
|
115
140
|
pick: PickWorkTarget = defaultPick,
|
|
116
141
|
) =>
|
|
117
142
|
Effect.runPromise(
|
|
118
|
-
work(commandOptions, launch, pick).pipe(
|
|
143
|
+
work(commandOptions, launch, pick, progress).pipe(
|
|
119
144
|
Effect.provideService(WorktreeService, worktrees as never),
|
|
120
145
|
Effect.provideService(FileSystemService, fs as never),
|
|
121
146
|
Effect.provideService(WorkbaseService, workbase as never),
|
|
@@ -125,7 +150,15 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
125
150
|
) as Effect.Effect<void, unknown, never>,
|
|
126
151
|
)
|
|
127
152
|
|
|
128
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
events,
|
|
155
|
+
probes,
|
|
156
|
+
launches,
|
|
157
|
+
materializeOptions,
|
|
158
|
+
statusUpdates,
|
|
159
|
+
progressUpdates,
|
|
160
|
+
run,
|
|
161
|
+
}
|
|
129
162
|
}
|
|
130
163
|
|
|
131
164
|
describe("work command", () => {
|
|
@@ -137,11 +170,7 @@ describe("work command", () => {
|
|
|
137
170
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
138
171
|
expect(harness.launches[0]).toEqual({
|
|
139
172
|
cli: "opencode",
|
|
140
|
-
args: [
|
|
141
|
-
"opencode",
|
|
142
|
-
"--prompt",
|
|
143
|
-
"Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
|
|
144
|
-
],
|
|
173
|
+
args: ["opencode", "--continue"],
|
|
145
174
|
cwd: "/workbase/epics/delivery",
|
|
146
175
|
})
|
|
147
176
|
})
|
|
@@ -154,11 +183,7 @@ describe("work command", () => {
|
|
|
154
183
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
155
184
|
expect(harness.launches[0]).toEqual({
|
|
156
185
|
cli: "opencode",
|
|
157
|
-
args: [
|
|
158
|
-
"opencode",
|
|
159
|
-
"--prompt",
|
|
160
|
-
"Work on the task. Read /workbase/tasks/delivery/TASK.md.",
|
|
161
|
-
],
|
|
186
|
+
args: ["opencode", "--continue"],
|
|
162
187
|
cwd: "/workbase/tasks/delivery",
|
|
163
188
|
})
|
|
164
189
|
})
|
|
@@ -176,9 +201,10 @@ describe("work command", () => {
|
|
|
176
201
|
"probe:opencode",
|
|
177
202
|
"launch:opencode",
|
|
178
203
|
])
|
|
179
|
-
expect(harness.launches[0]?.args).
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
|
|
205
|
+
expect(harness.statusUpdates).toEqual([
|
|
206
|
+
"phase:example:implementation:working",
|
|
207
|
+
])
|
|
182
208
|
})
|
|
183
209
|
|
|
184
210
|
test("infers a single-phase task from a nested checkout directory", async () => {
|
|
@@ -190,7 +216,7 @@ describe("work command", () => {
|
|
|
190
216
|
})
|
|
191
217
|
|
|
192
218
|
expect(harness.events[0]).toBe("materialize")
|
|
193
|
-
expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example
|
|
219
|
+
expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
|
|
194
220
|
})
|
|
195
221
|
|
|
196
222
|
test("selects a target with fzf outside an entity directory", async () => {
|
|
@@ -225,7 +251,7 @@ describe("work command", () => {
|
|
|
225
251
|
"launch:opencode",
|
|
226
252
|
])
|
|
227
253
|
expect(harness.launches[0]?.cwd).toBe(
|
|
228
|
-
"/workbase/tasks/
|
|
254
|
+
"/workbase/tasks/delivery/phases/build",
|
|
229
255
|
)
|
|
230
256
|
})
|
|
231
257
|
|
|
@@ -269,7 +295,7 @@ describe("work command", () => {
|
|
|
269
295
|
expect(harness.events).toEqual([])
|
|
270
296
|
})
|
|
271
297
|
|
|
272
|
-
test("launches OpenCode in the
|
|
298
|
+
test("launches OpenCode in the task directory and continues its session", async () => {
|
|
273
299
|
const harness = createHarness()
|
|
274
300
|
|
|
275
301
|
await harness.run({ taskId: "example", opencode: true })
|
|
@@ -282,17 +308,18 @@ describe("work command", () => {
|
|
|
282
308
|
expect(harness.launches).toEqual([
|
|
283
309
|
{
|
|
284
310
|
cli: "opencode",
|
|
285
|
-
args: [
|
|
286
|
-
|
|
287
|
-
"--prompt",
|
|
288
|
-
"Start the task. Read /workbase/tasks/example/TASK.md.",
|
|
289
|
-
],
|
|
290
|
-
cwd: "/workbase/tasks/example/code/agency",
|
|
311
|
+
args: ["opencode", "--continue"],
|
|
312
|
+
cwd: "/workbase/tasks/example",
|
|
291
313
|
},
|
|
292
314
|
])
|
|
315
|
+
expect(harness.statusUpdates).toEqual(["task:example:working"])
|
|
316
|
+
expect(harness.progressUpdates).toEqual([
|
|
317
|
+
"start:Preparing workspace...",
|
|
318
|
+
"succeed:Workspace ready",
|
|
319
|
+
])
|
|
293
320
|
})
|
|
294
321
|
|
|
295
|
-
test("
|
|
322
|
+
test("continues OpenCode for a multi-phase task", async () => {
|
|
296
323
|
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
297
324
|
|
|
298
325
|
await harness.run({
|
|
@@ -301,11 +328,7 @@ describe("work command", () => {
|
|
|
301
328
|
opencode: true,
|
|
302
329
|
})
|
|
303
330
|
|
|
304
|
-
expect(harness.launches[0]?.args).toEqual([
|
|
305
|
-
"opencode",
|
|
306
|
-
"--prompt",
|
|
307
|
-
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
308
|
-
])
|
|
331
|
+
expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
|
|
309
332
|
})
|
|
310
333
|
|
|
311
334
|
test("automatically falls back to Claude", async () => {
|
|
@@ -317,7 +340,7 @@ describe("work command", () => {
|
|
|
317
340
|
expect(harness.launches[0]).toEqual({
|
|
318
341
|
cli: "claude",
|
|
319
342
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
320
|
-
cwd: "/workbase/tasks/example
|
|
343
|
+
cwd: "/workbase/tasks/example",
|
|
321
344
|
})
|
|
322
345
|
})
|
|
323
346
|
|
|
@@ -329,6 +352,7 @@ describe("work command", () => {
|
|
|
329
352
|
).rejects.toThrow("opencode CLI tool not found")
|
|
330
353
|
expect(harness.probes).toEqual(["opencode"])
|
|
331
354
|
expect(harness.launches).toEqual([])
|
|
355
|
+
expect(harness.statusUpdates).toEqual([])
|
|
332
356
|
})
|
|
333
357
|
|
|
334
358
|
test("launches explicitly requested Claude", async () => {
|
|
@@ -340,7 +364,7 @@ describe("work command", () => {
|
|
|
340
364
|
expect(harness.launches[0]).toEqual({
|
|
341
365
|
cli: "claude",
|
|
342
366
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
343
|
-
cwd: "/workbase/tasks/example
|
|
367
|
+
cwd: "/workbase/tasks/example",
|
|
344
368
|
})
|
|
345
369
|
})
|
|
346
370
|
|
|
@@ -365,6 +389,10 @@ describe("work command", () => {
|
|
|
365
389
|
"materialization failed",
|
|
366
390
|
)
|
|
367
391
|
expect(harness.events).toEqual(["materialize"])
|
|
392
|
+
expect(harness.progressUpdates).toEqual([
|
|
393
|
+
"start:Preparing workspace...",
|
|
394
|
+
"fail:Workspace preparation failed",
|
|
395
|
+
])
|
|
368
396
|
})
|
|
369
397
|
|
|
370
398
|
test("respects silent and verbose logging options", async () => {
|
|
@@ -373,8 +401,9 @@ describe("work command", () => {
|
|
|
373
401
|
verboseHarness.run({ taskId: "example", verbose: true }),
|
|
374
402
|
)
|
|
375
403
|
expect(verboseLogs).toEqual([
|
|
376
|
-
"Launching opencode in /workbase/tasks/example
|
|
404
|
+
"Launching opencode in /workbase/tasks/example",
|
|
377
405
|
])
|
|
406
|
+
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
378
407
|
|
|
379
408
|
const silentHarness = createHarness()
|
|
380
409
|
const silentLogs = await captureLogs(() =>
|
package/src/commands/work.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { TaskService } from "../services/TaskService"
|
|
|
9
9
|
import { PhaseService } from "../services/PhaseService"
|
|
10
10
|
import { createLoggers } from "../utils/effect"
|
|
11
11
|
import { execvp } from "../utils/exec"
|
|
12
|
+
import { createProgress, type Progress } from "../utils/progress"
|
|
12
13
|
import {
|
|
13
14
|
buildWorkTargetChoices,
|
|
14
15
|
pickWorkTarget,
|
|
@@ -35,6 +36,7 @@ export const work = (
|
|
|
35
36
|
options: WorkOptions = {},
|
|
36
37
|
launch: LaunchAgent = launchAgent,
|
|
37
38
|
pick: PickWorkTarget = pickWorkTarget,
|
|
39
|
+
progress: Progress = createProgress(options),
|
|
38
40
|
) =>
|
|
39
41
|
Effect.gen(function* () {
|
|
40
42
|
if (options.opencode && options.claude) {
|
|
@@ -155,11 +157,21 @@ export const work = (
|
|
|
155
157
|
} else {
|
|
156
158
|
const taskId = target.taskId
|
|
157
159
|
const phaseId = target.kind === "phase" ? target.phaseId : undefined
|
|
158
|
-
|
|
160
|
+
progress.start("Preparing workspace...")
|
|
161
|
+
const workspace = yield* worktrees
|
|
162
|
+
.materialize(taskId, phaseId, root, options)
|
|
163
|
+
.pipe(
|
|
164
|
+
Effect.tap(() =>
|
|
165
|
+
Effect.sync(() => progress.succeed("Workspace ready")),
|
|
166
|
+
),
|
|
167
|
+
Effect.tapError(() =>
|
|
168
|
+
Effect.sync(() => progress.fail("Workspace preparation failed")),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
159
171
|
prompt = workspace.phasePath
|
|
160
172
|
? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
161
173
|
: `Start the task. Read ${workspace.taskPath}.`
|
|
162
|
-
launchPath =
|
|
174
|
+
launchPath = dirname(target.path)
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
const requested = options.claude ? "claude" : "opencode"
|
|
@@ -174,9 +186,14 @@ export const work = (
|
|
|
174
186
|
if (available.exitCode !== 0) {
|
|
175
187
|
return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
|
|
176
188
|
}
|
|
189
|
+
if (target.kind === "phase") {
|
|
190
|
+
yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
|
|
191
|
+
} else if (target.kind === "task" && !target.multiPhase) {
|
|
192
|
+
yield* tasks.setStatus(target.taskId, "working", root)
|
|
193
|
+
}
|
|
177
194
|
|
|
178
195
|
verboseLog(`Launching ${cli} in ${launchPath}`)
|
|
179
|
-
const args = cli === "opencode" ? ["--
|
|
196
|
+
const args = cli === "opencode" ? ["--continue"] : [prompt]
|
|
180
197
|
launch(cli, [cli, ...args], launchPath)
|
|
181
198
|
})
|
|
182
199
|
|