@markjaquith/agency 2.19.0 → 2.21.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 +67 -5
- package/cli.ts +15 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +4 -1
- package/src/cli-parser.test.ts +61 -1
- package/src/cli-parser.ts +168 -7
- package/src/cli.test.ts +62 -0
- package/src/commands/epic.ts +51 -1
- package/src/commands/phase.ts +94 -1
- package/src/commands/task.ts +108 -1
- package/src/commands/work.test.ts +105 -16
- package/src/commands/work.ts +99 -28
- package/src/services/GraphMutationService.test.ts +336 -0
- package/src/services/GraphMutationService.ts +952 -0
- package/src/services/WorkbaseService.test.ts +19 -0
- package/src/services/WorkbaseService.ts +15 -37
- package/src/test-utils.ts +2 -0
- package/src/workbase/dependency-graph.ts +51 -0
- package/src/workbase/runner-command.test.ts +79 -0
- package/src/workbase/runner-command.ts +118 -0
- package/src/workbase/schemas.test.ts +26 -0
- package/src/workbase/schemas.ts +15 -0
package/src/commands/epic.ts
CHANGED
|
@@ -5,12 +5,14 @@ import { createLoggers } from "../utils/effect"
|
|
|
5
5
|
import { formatTable } from "../utils/table"
|
|
6
6
|
import { getWorkViews } from "../work-view"
|
|
7
7
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
8
|
+
import { GraphMutationService } from "../services/GraphMutationService"
|
|
8
9
|
|
|
9
10
|
interface EpicOptions extends BaseCommandOptions {
|
|
10
11
|
readonly subcommand?: string
|
|
11
12
|
readonly args: readonly string[]
|
|
12
13
|
readonly ticketUrl?: string
|
|
13
14
|
readonly description?: string
|
|
15
|
+
readonly clearDescription?: boolean
|
|
14
16
|
readonly repos?: readonly string[]
|
|
15
17
|
readonly json?: boolean
|
|
16
18
|
readonly statuses?: readonly string[]
|
|
@@ -23,6 +25,7 @@ interface EpicOptions extends BaseCommandOptions {
|
|
|
23
25
|
export const epic = (options: EpicOptions) =>
|
|
24
26
|
Effect.gen(function* () {
|
|
25
27
|
const epics = yield* EpicService
|
|
28
|
+
const mutations = yield* GraphMutationService
|
|
26
29
|
const { log } = createLoggers(options)
|
|
27
30
|
const cwd = options.cwd ?? process.cwd()
|
|
28
31
|
|
|
@@ -105,10 +108,49 @@ export const epic = (options: EpicOptions) =>
|
|
|
105
108
|
return
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
case "update": {
|
|
112
|
+
const id = options.args[0]
|
|
113
|
+
if (!id) return yield* Effect.fail(new Error("Epic ID is required"))
|
|
114
|
+
const output = yield* mutations.updateEpic(
|
|
115
|
+
id,
|
|
116
|
+
{
|
|
117
|
+
description: options.clearDescription ? null : options.description,
|
|
118
|
+
ticketUrl: options.ticketUrl,
|
|
119
|
+
repos:
|
|
120
|
+
options.repos === undefined
|
|
121
|
+
? undefined
|
|
122
|
+
: parseRepositoryReferences(options.repos),
|
|
123
|
+
},
|
|
124
|
+
cwd,
|
|
125
|
+
)
|
|
126
|
+
log(
|
|
127
|
+
options.json
|
|
128
|
+
? JSON.stringify(output, null, 2)
|
|
129
|
+
: `Updated epic '${id}'`,
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case "rename": {
|
|
135
|
+
const [id, newId] = options.args
|
|
136
|
+
if (!id || !newId) {
|
|
137
|
+
return yield* Effect.fail(
|
|
138
|
+
new Error("Epic ID and new ID are required"),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
const output = yield* mutations.renameEpic(id, newId, cwd)
|
|
142
|
+
log(
|
|
143
|
+
options.json
|
|
144
|
+
? JSON.stringify(output, null, 2)
|
|
145
|
+
: `Renamed epic '${id}' to '${newId}'`,
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
108
150
|
default:
|
|
109
151
|
return yield* Effect.fail(
|
|
110
152
|
new Error(
|
|
111
|
-
"Subcommand is required. Available subcommands: create, list, show",
|
|
153
|
+
"Subcommand is required. Available subcommands: create, list, show, update, rename",
|
|
112
154
|
),
|
|
113
155
|
)
|
|
114
156
|
}
|
|
@@ -121,12 +163,20 @@ Subcommands:
|
|
|
121
163
|
create <id> Create an epic
|
|
122
164
|
list List epics
|
|
123
165
|
show <id> Show an epic
|
|
166
|
+
update <id> Update epic metadata
|
|
167
|
+
rename <id> <new-id> Rename an epic and update task references
|
|
124
168
|
|
|
125
169
|
Create options:
|
|
126
170
|
--ticket-url <url> External ticket URL
|
|
127
171
|
--description <text> Short description of the epic
|
|
128
172
|
--repo <alias>:<ref> Read-only repository reference; repeatable
|
|
129
173
|
|
|
174
|
+
Update options:
|
|
175
|
+
--ticket-url <url> Replace the external ticket URL
|
|
176
|
+
--description <text> Replace the description
|
|
177
|
+
--clear-description Remove the description
|
|
178
|
+
--repo <alias>:<ref> Replace repository references; repeatable
|
|
179
|
+
|
|
130
180
|
Options:
|
|
131
181
|
--json Output results as JSON
|
|
132
182
|
--status <status> Filter list by status; repeatable
|
package/src/commands/phase.ts
CHANGED
|
@@ -5,15 +5,20 @@ import { createLoggers } from "../utils/effect"
|
|
|
5
5
|
import { formatTable } from "../utils/table"
|
|
6
6
|
import { getWorkViews } from "../work-view"
|
|
7
7
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
8
|
+
import { GraphMutationService } from "../services/GraphMutationService"
|
|
8
9
|
|
|
9
10
|
interface PhaseOptions extends BaseCommandOptions {
|
|
10
11
|
readonly subcommand?: string
|
|
11
12
|
readonly args: readonly string[]
|
|
12
13
|
readonly description?: string
|
|
14
|
+
readonly clearDescription?: boolean
|
|
13
15
|
readonly repo?: string
|
|
14
16
|
readonly references?: readonly string[]
|
|
15
17
|
readonly branch?: string
|
|
16
18
|
readonly base?: string
|
|
19
|
+
readonly clearReferences?: boolean
|
|
20
|
+
readonly prUrl?: string
|
|
21
|
+
readonly clearPr?: boolean
|
|
17
22
|
readonly dependsOn?: readonly string[]
|
|
18
23
|
readonly firstPhase?: string
|
|
19
24
|
readonly json?: boolean
|
|
@@ -27,6 +32,7 @@ interface PhaseOptions extends BaseCommandOptions {
|
|
|
27
32
|
export const phase = (options: PhaseOptions) =>
|
|
28
33
|
Effect.gen(function* () {
|
|
29
34
|
const phases = yield* PhaseService
|
|
35
|
+
const mutations = yield* GraphMutationService
|
|
30
36
|
const { log } = createLoggers(options)
|
|
31
37
|
const cwd = options.cwd ?? process.cwd()
|
|
32
38
|
const [taskId, phaseId] = options.args
|
|
@@ -153,10 +159,84 @@ export const phase = (options: PhaseOptions) =>
|
|
|
153
159
|
)
|
|
154
160
|
return
|
|
155
161
|
}
|
|
162
|
+
case "update": {
|
|
163
|
+
if (!taskId || !phaseId) {
|
|
164
|
+
return yield* Effect.fail(
|
|
165
|
+
new Error("Task ID and phase ID are required"),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const output = yield* mutations.updatePhase(
|
|
169
|
+
taskId,
|
|
170
|
+
phaseId,
|
|
171
|
+
{
|
|
172
|
+
description: options.clearDescription ? null : options.description,
|
|
173
|
+
repo: options.repo,
|
|
174
|
+
repos: options.clearReferences
|
|
175
|
+
? null
|
|
176
|
+
: options.references === undefined
|
|
177
|
+
? undefined
|
|
178
|
+
: parseRepositoryReferences(options.references),
|
|
179
|
+
branch: options.branch,
|
|
180
|
+
base: options.base,
|
|
181
|
+
pr: options.clearPr ? null : options.prUrl,
|
|
182
|
+
},
|
|
183
|
+
cwd,
|
|
184
|
+
)
|
|
185
|
+
log(
|
|
186
|
+
options.json
|
|
187
|
+
? JSON.stringify(output, null, 2)
|
|
188
|
+
: `Updated phase '${phaseId}'`,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
case "rename": {
|
|
193
|
+
const newId = options.args[2]
|
|
194
|
+
if (!taskId || !phaseId || !newId) {
|
|
195
|
+
return yield* Effect.fail(
|
|
196
|
+
new Error("Task ID, phase ID, and new ID are required"),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
const output = yield* mutations.renamePhase(taskId, phaseId, newId, cwd)
|
|
200
|
+
log(
|
|
201
|
+
options.json
|
|
202
|
+
? JSON.stringify(output, null, 2)
|
|
203
|
+
: `Renamed phase '${phaseId}' to '${newId}'`,
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
case "dependency": {
|
|
208
|
+
const [operation, dependencyTaskId, dependencyPhaseId, dependencyId] =
|
|
209
|
+
options.args
|
|
210
|
+
if (
|
|
211
|
+
(operation !== "add" && operation !== "remove") ||
|
|
212
|
+
!dependencyTaskId ||
|
|
213
|
+
!dependencyPhaseId ||
|
|
214
|
+
!dependencyId
|
|
215
|
+
) {
|
|
216
|
+
return yield* Effect.fail(
|
|
217
|
+
new Error(
|
|
218
|
+
"Usage: agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id>",
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
const output = yield* mutations.mutatePhaseDependency(
|
|
223
|
+
operation,
|
|
224
|
+
dependencyTaskId,
|
|
225
|
+
dependencyPhaseId,
|
|
226
|
+
dependencyId,
|
|
227
|
+
cwd,
|
|
228
|
+
)
|
|
229
|
+
log(
|
|
230
|
+
options.json
|
|
231
|
+
? JSON.stringify(output, null, 2)
|
|
232
|
+
: `${operation === "add" ? "Added" : "Removed"} dependency '${dependencyId}' ${operation === "add" ? "to" : "from"} phase '${dependencyPhaseId}'`,
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
156
236
|
default:
|
|
157
237
|
return yield* Effect.fail(
|
|
158
238
|
new Error(
|
|
159
|
-
"Subcommand is required. Available: create, list, show, status",
|
|
239
|
+
"Subcommand is required. Available: create, list, show, status, update, rename, dependency",
|
|
160
240
|
),
|
|
161
241
|
)
|
|
162
242
|
}
|
|
@@ -171,6 +251,11 @@ Subcommands:
|
|
|
171
251
|
show <task> <phase> Show a phase
|
|
172
252
|
status <task> <phase> <status>
|
|
173
253
|
Set open, done, or dropped
|
|
254
|
+
update <task> <phase> Update phase metadata
|
|
255
|
+
rename <task> <phase> <new-id>
|
|
256
|
+
Rename a phase and update dependencies
|
|
257
|
+
dependency <operation> <task> <phase> <dependency>
|
|
258
|
+
Add or remove a phase dependency
|
|
174
259
|
|
|
175
260
|
Create options:
|
|
176
261
|
--description <text> Short description of the phase
|
|
@@ -182,6 +267,14 @@ Create options:
|
|
|
182
267
|
--depends-on <id> Phase dependency; repeatable
|
|
183
268
|
--first-phase <id> Existing execution phase ID when converting a task
|
|
184
269
|
|
|
270
|
+
Update options:
|
|
271
|
+
--description <text> / --clear-description
|
|
272
|
+
--repo <alias> Replace the writable repository
|
|
273
|
+
--reference <alias>:<ref> / --clear-references
|
|
274
|
+
--branch <name> Replace the working branch
|
|
275
|
+
--base <name> Replace the base branch
|
|
276
|
+
--pr-url <url> / --clear-pr
|
|
277
|
+
|
|
185
278
|
Options:
|
|
186
279
|
--json Output results as JSON
|
|
187
280
|
--status <status> Filter list by status; repeatable
|
package/src/commands/task.ts
CHANGED
|
@@ -10,17 +10,24 @@ import { WorkbaseService } from "../services/WorkbaseService"
|
|
|
10
10
|
import { choose } from "../utils/chooser"
|
|
11
11
|
import { formatTable } from "../utils/table"
|
|
12
12
|
import { getWorkViews } from "../work-view"
|
|
13
|
+
import { GraphMutationService } from "../services/GraphMutationService"
|
|
13
14
|
|
|
14
15
|
interface TaskOptions extends BaseCommandOptions {
|
|
15
16
|
readonly subcommand?: string
|
|
16
17
|
readonly args: readonly string[]
|
|
17
18
|
readonly ticketUrl?: string
|
|
18
19
|
readonly description?: string
|
|
20
|
+
readonly clearDescription?: boolean
|
|
21
|
+
readonly clearTicket?: boolean
|
|
19
22
|
readonly epic?: string
|
|
20
23
|
readonly repo?: string
|
|
21
24
|
readonly references?: readonly string[]
|
|
22
25
|
readonly branch?: string
|
|
23
26
|
readonly base?: string
|
|
27
|
+
readonly clearReferences?: boolean
|
|
28
|
+
readonly prUrl?: string
|
|
29
|
+
readonly clearPr?: boolean
|
|
30
|
+
readonly noEpic?: boolean
|
|
24
31
|
readonly multiPhase?: boolean
|
|
25
32
|
readonly json?: boolean
|
|
26
33
|
readonly statuses?: readonly string[]
|
|
@@ -74,6 +81,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
74
81
|
const epics = yield* EpicService
|
|
75
82
|
const repositories = yield* RepositoryService
|
|
76
83
|
const workbase = yield* WorkbaseService
|
|
84
|
+
const mutations = yield* GraphMutationService
|
|
77
85
|
const { log } = createLoggers(options)
|
|
78
86
|
const cwd = options.cwd ?? process.cwd()
|
|
79
87
|
|
|
@@ -291,10 +299,95 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
291
299
|
)
|
|
292
300
|
return
|
|
293
301
|
}
|
|
302
|
+
case "update": {
|
|
303
|
+
const id = options.args[0]
|
|
304
|
+
if (!id) return yield* Effect.fail(new Error("Task ID is required"))
|
|
305
|
+
const output = yield* mutations.updateTask(
|
|
306
|
+
id,
|
|
307
|
+
{
|
|
308
|
+
description: options.clearDescription ? null : options.description,
|
|
309
|
+
ticketUrl: options.clearTicket ? null : options.ticketUrl,
|
|
310
|
+
repo: options.repo,
|
|
311
|
+
repos: options.clearReferences
|
|
312
|
+
? null
|
|
313
|
+
: options.references === undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: parseRepositoryReferences(options.references),
|
|
316
|
+
branch: options.branch,
|
|
317
|
+
base: options.base,
|
|
318
|
+
pr: options.clearPr ? null : options.prUrl,
|
|
319
|
+
},
|
|
320
|
+
cwd,
|
|
321
|
+
)
|
|
322
|
+
log(
|
|
323
|
+
options.json
|
|
324
|
+
? JSON.stringify(output, null, 2)
|
|
325
|
+
: `Updated task '${id}'`,
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
case "rename": {
|
|
330
|
+
const [id, newId] = options.args
|
|
331
|
+
if (!id || !newId) {
|
|
332
|
+
return yield* Effect.fail(
|
|
333
|
+
new Error("Task ID and new ID are required"),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
const output = yield* mutations.renameTask(id, newId, cwd)
|
|
337
|
+
log(
|
|
338
|
+
options.json
|
|
339
|
+
? JSON.stringify(output, null, 2)
|
|
340
|
+
: `Renamed task '${id}' to '${newId}'`,
|
|
341
|
+
)
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
case "move": {
|
|
345
|
+
const id = options.args[0]
|
|
346
|
+
if (!id) return yield* Effect.fail(new Error("Task ID is required"))
|
|
347
|
+
const output = yield* mutations.moveTask(
|
|
348
|
+
id,
|
|
349
|
+
options.noEpic ? null : (options.epic ?? null),
|
|
350
|
+
cwd,
|
|
351
|
+
)
|
|
352
|
+
log(
|
|
353
|
+
options.json
|
|
354
|
+
? JSON.stringify(output, null, 2)
|
|
355
|
+
: options.noEpic
|
|
356
|
+
? `Removed task '${id}' from its epic`
|
|
357
|
+
: `Moved task '${id}' to epic '${options.epic}'`,
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
case "dependency": {
|
|
362
|
+
const [operation, id, dependencyId] = options.args
|
|
363
|
+
if (
|
|
364
|
+
(operation !== "add" && operation !== "remove") ||
|
|
365
|
+
!id ||
|
|
366
|
+
!dependencyId
|
|
367
|
+
) {
|
|
368
|
+
return yield* Effect.fail(
|
|
369
|
+
new Error(
|
|
370
|
+
"Usage: agency task dependency <add|remove> <task-id> <dependency-id>",
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
const output = yield* mutations.mutateTaskDependency(
|
|
375
|
+
operation,
|
|
376
|
+
id,
|
|
377
|
+
dependencyId,
|
|
378
|
+
cwd,
|
|
379
|
+
)
|
|
380
|
+
log(
|
|
381
|
+
options.json
|
|
382
|
+
? JSON.stringify(output, null, 2)
|
|
383
|
+
: `${operation === "add" ? "Added" : "Removed"} dependency '${dependencyId}' ${operation === "add" ? "to" : "from"} task '${id}'`,
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
294
387
|
default:
|
|
295
388
|
return yield* Effect.fail(
|
|
296
389
|
new Error(
|
|
297
|
-
"Subcommand is required. Available: new, create, list, show, status",
|
|
390
|
+
"Subcommand is required. Available: new, create, list, show, status, update, rename, move, dependency",
|
|
298
391
|
),
|
|
299
392
|
)
|
|
300
393
|
}
|
|
@@ -309,6 +402,11 @@ Subcommands:
|
|
|
309
402
|
list List tasks
|
|
310
403
|
show <id> Show a task
|
|
311
404
|
status <id> <status> Set open, done, or dropped
|
|
405
|
+
update <id> Update task metadata
|
|
406
|
+
rename <id> <new-id> Rename a task and update graph references
|
|
407
|
+
move <id> Move a task with --epic or --no-epic
|
|
408
|
+
dependency <operation> <task> <dependency>
|
|
409
|
+
Add or remove a task dependency
|
|
312
410
|
|
|
313
411
|
Create options:
|
|
314
412
|
--ticket-url <url> External ticket URL (optional)
|
|
@@ -321,6 +419,15 @@ Create options:
|
|
|
321
419
|
--base <name> Base branch (default: main)
|
|
322
420
|
--multi-phase Create a task container for phases
|
|
323
421
|
|
|
422
|
+
Update options:
|
|
423
|
+
--ticket-url <url> / --clear-ticket
|
|
424
|
+
--description <text> / --clear-description
|
|
425
|
+
--repo <alias> Replace the writable repository
|
|
426
|
+
--reference <alias>:<ref> / --clear-references
|
|
427
|
+
--branch <name> Replace the working branch
|
|
428
|
+
--base <name> Replace the base branch
|
|
429
|
+
--pr-url <url> / --clear-pr
|
|
430
|
+
|
|
324
431
|
Task creation is noninteractive. Single-phase tasks require --repo; use
|
|
325
432
|
--multi-phase instead for a task container. Guided input is available only
|
|
326
433
|
through task new, which fails when --no-input is set or no TTY is available.
|
|
@@ -47,8 +47,16 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
47
47
|
interface HarnessOptions {
|
|
48
48
|
readonly workspace?: ExecutionWorkspace
|
|
49
49
|
readonly materializeError?: Error
|
|
50
|
-
readonly available?:
|
|
50
|
+
readonly available?: Readonly<Record<string, boolean>>
|
|
51
51
|
readonly chooserCommand?: readonly string[]
|
|
52
|
+
readonly runners?: Record<
|
|
53
|
+
string,
|
|
54
|
+
{
|
|
55
|
+
command: readonly [string, ...string[]]
|
|
56
|
+
resumeCommand?: readonly [string, ...string[]]
|
|
57
|
+
environment?: Record<string, string>
|
|
58
|
+
}
|
|
59
|
+
>
|
|
52
60
|
readonly multiPhaseTasks?: readonly string[]
|
|
53
61
|
readonly epicRecords?: readonly any[]
|
|
54
62
|
readonly taskRecords?: readonly any[]
|
|
@@ -72,6 +80,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
72
80
|
args: readonly string[]
|
|
73
81
|
cwd: string
|
|
74
82
|
}> = []
|
|
83
|
+
const launchEnvironments: Array<Readonly<Record<string, string>>> = []
|
|
75
84
|
const materializeOptions: Array<
|
|
76
85
|
Parameters<WorktreeService["materialize"]>[3]
|
|
77
86
|
> = []
|
|
@@ -105,6 +114,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
105
114
|
config: {
|
|
106
115
|
version: 2 as const,
|
|
107
116
|
chooserCommand: options.chooserCommand,
|
|
117
|
+
runners: options.runners,
|
|
108
118
|
},
|
|
109
119
|
}),
|
|
110
120
|
}
|
|
@@ -204,7 +214,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
204
214
|
isDirectory: (path: string) =>
|
|
205
215
|
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
206
216
|
runCommand: (args: readonly string[]) => {
|
|
207
|
-
const cli = args[1]
|
|
217
|
+
const cli = args[1]!
|
|
208
218
|
events.push(`probe:${cli}`)
|
|
209
219
|
probes.push(cli)
|
|
210
220
|
return Effect.succeed({
|
|
@@ -214,9 +224,15 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
214
224
|
})
|
|
215
225
|
},
|
|
216
226
|
}
|
|
217
|
-
const launch = (
|
|
227
|
+
const launch = (
|
|
228
|
+
cli: string,
|
|
229
|
+
args: readonly string[],
|
|
230
|
+
cwd: string,
|
|
231
|
+
environment: Readonly<Record<string, string>>,
|
|
232
|
+
) => {
|
|
218
233
|
events.push(`launch:${cli}`)
|
|
219
234
|
launches.push({ cli, args, cwd })
|
|
235
|
+
launchEnvironments.push(environment)
|
|
220
236
|
}
|
|
221
237
|
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
222
238
|
const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
|
|
@@ -257,6 +273,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
257
273
|
events,
|
|
258
274
|
probes,
|
|
259
275
|
launches,
|
|
276
|
+
launchEnvironments,
|
|
260
277
|
materializeOptions,
|
|
261
278
|
statusUpdates,
|
|
262
279
|
shownTasks,
|
|
@@ -353,7 +370,6 @@ describe("work command", () => {
|
|
|
353
370
|
cli: "opencode",
|
|
354
371
|
args: [
|
|
355
372
|
"opencode",
|
|
356
|
-
"--continue",
|
|
357
373
|
"--prompt",
|
|
358
374
|
"Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
|
|
359
375
|
],
|
|
@@ -403,7 +419,6 @@ describe("work command", () => {
|
|
|
403
419
|
cli: "opencode",
|
|
404
420
|
args: [
|
|
405
421
|
"opencode",
|
|
406
|
-
"--continue",
|
|
407
422
|
"--prompt",
|
|
408
423
|
"Work on the task. Read /workbase/tasks/delivery/TASK.md.",
|
|
409
424
|
],
|
|
@@ -429,7 +444,6 @@ describe("work command", () => {
|
|
|
429
444
|
cli: "opencode",
|
|
430
445
|
args: [
|
|
431
446
|
"opencode",
|
|
432
|
-
"--continue",
|
|
433
447
|
"--prompt",
|
|
434
448
|
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
435
449
|
],
|
|
@@ -596,7 +610,7 @@ describe("work command", () => {
|
|
|
596
610
|
|
|
597
611
|
await expect(
|
|
598
612
|
harness.run({ taskId: "example", opencode: true, claude: true }),
|
|
599
|
-
).rejects.toThrow("Cannot
|
|
613
|
+
).rejects.toThrow("Cannot combine --runner, --opencode, and --claude")
|
|
600
614
|
expect(harness.events).toEqual([])
|
|
601
615
|
})
|
|
602
616
|
|
|
@@ -624,7 +638,6 @@ describe("work command", () => {
|
|
|
624
638
|
cli: "opencode",
|
|
625
639
|
args: [
|
|
626
640
|
"opencode",
|
|
627
|
-
"--continue",
|
|
628
641
|
"--prompt",
|
|
629
642
|
"Start the task. Read /workbase/tasks/example/TASK.md.",
|
|
630
643
|
],
|
|
@@ -638,14 +651,18 @@ describe("work command", () => {
|
|
|
638
651
|
])
|
|
639
652
|
})
|
|
640
653
|
|
|
641
|
-
test("
|
|
654
|
+
test("resumes OpenCode deterministically when a session identity exists", async () => {
|
|
642
655
|
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
656
|
+
process.env.AGENCY_SESSION_ID = "existing-session"
|
|
657
|
+
try {
|
|
658
|
+
await harness.run({
|
|
659
|
+
taskId: "example",
|
|
660
|
+
phaseId: "implementation",
|
|
661
|
+
opencode: true,
|
|
662
|
+
})
|
|
663
|
+
} finally {
|
|
664
|
+
delete process.env.AGENCY_SESSION_ID
|
|
665
|
+
}
|
|
649
666
|
|
|
650
667
|
expect(harness.launches[0]).toEqual({
|
|
651
668
|
cli: "opencode",
|
|
@@ -659,6 +676,78 @@ describe("work command", () => {
|
|
|
659
676
|
})
|
|
660
677
|
})
|
|
661
678
|
|
|
679
|
+
test("expands a named runner with shared context and claim identity", async () => {
|
|
680
|
+
const harness = createHarness({
|
|
681
|
+
available: { codex: true },
|
|
682
|
+
runners: {
|
|
683
|
+
custom: {
|
|
684
|
+
command: ["codex", "--task", "{task}", "{prompt}"],
|
|
685
|
+
environment: {
|
|
686
|
+
CUSTOM_TARGET: "{target}",
|
|
687
|
+
AGENCY_TARGET: "cannot-override",
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
await harness.run({ taskId: "example", runner: "custom" })
|
|
694
|
+
|
|
695
|
+
expect(harness.probes).toEqual(["codex"])
|
|
696
|
+
expect(harness.launches[0]).toEqual({
|
|
697
|
+
cli: "codex",
|
|
698
|
+
args: [
|
|
699
|
+
"codex",
|
|
700
|
+
"--task",
|
|
701
|
+
"example",
|
|
702
|
+
"Start the task. Read /workbase/tasks/example/TASK.md.",
|
|
703
|
+
],
|
|
704
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
705
|
+
})
|
|
706
|
+
expect(harness.launchEnvironments[0]).toMatchObject({
|
|
707
|
+
AGENCY_RUNNER: "custom",
|
|
708
|
+
AGENCY_CLAIMANT: process.env.USER ?? "agency",
|
|
709
|
+
AGENCY_WORKBASE: "/workbase",
|
|
710
|
+
AGENCY_TARGET: "execution-unit:task/example",
|
|
711
|
+
AGENCY_TASK_ID: "example",
|
|
712
|
+
AGENCY_PHASE_ID: "",
|
|
713
|
+
AGENCY_CLAIM_REVISION: "1".repeat(64),
|
|
714
|
+
CUSTOM_TARGET: "execution-unit:task/example",
|
|
715
|
+
})
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
test("prints the exact command contract without launching and omits secrets", async () => {
|
|
719
|
+
const harness = createHarness({
|
|
720
|
+
available: { agent: true },
|
|
721
|
+
runners: {
|
|
722
|
+
custom: {
|
|
723
|
+
command: ["agent", "{prompt}"],
|
|
724
|
+
environment: {
|
|
725
|
+
VISIBLE: "{task}",
|
|
726
|
+
API_TOKEN: "do-not-print",
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
const output = await captureLogs(() =>
|
|
733
|
+
harness.run({
|
|
734
|
+
taskId: "example",
|
|
735
|
+
runner: "custom",
|
|
736
|
+
printCommand: true,
|
|
737
|
+
}),
|
|
738
|
+
)
|
|
739
|
+
const printed = JSON.parse(output.join("\n"))
|
|
740
|
+
|
|
741
|
+
expect(harness.launches).toEqual([])
|
|
742
|
+
expect(printed.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
743
|
+
expect(printed.argv).toEqual([
|
|
744
|
+
"agent",
|
|
745
|
+
"Start the task. Read /workbase/tasks/example/TASK.md.",
|
|
746
|
+
])
|
|
747
|
+
expect(printed.environment.VISIBLE).toBe("example")
|
|
748
|
+
expect(printed.environment.API_TOKEN).toBeUndefined()
|
|
749
|
+
})
|
|
750
|
+
|
|
662
751
|
test("automatically falls back to Claude", async () => {
|
|
663
752
|
const harness = createHarness({ available: { opencode: false } })
|
|
664
753
|
|
|
@@ -729,7 +818,7 @@ describe("work command", () => {
|
|
|
729
818
|
verboseHarness.run({ taskId: "example", verbose: true }),
|
|
730
819
|
)
|
|
731
820
|
expect(verboseLogs).toEqual([
|
|
732
|
-
"Launching command: opencode --
|
|
821
|
+
"Launching command: opencode --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
|
|
733
822
|
])
|
|
734
823
|
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
735
824
|
|