@markjaquith/agency 2.32.2 → 2.34.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 +9 -1
- package/cli.ts +8 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +50 -0
- package/src/cli-parser.ts +56 -3
- package/src/commands/epic.test.ts +33 -0
- package/src/commands/epic.ts +19 -2
- package/src/commands/phase.ts +20 -2
- package/src/commands/task-phase.test.ts +35 -0
- package/src/commands/task.test.ts +35 -0
- package/src/commands/task.ts +20 -1
- package/src/commands/work.test.ts +31 -3
- package/src/commands/work.ts +19 -4
- package/src/utils/process.test.ts +7 -4
- package/src/workbase/work-target.ts +5 -0
package/README.md
CHANGED
|
@@ -498,6 +498,8 @@ complete Markdown document.
|
|
|
498
498
|
### Epics
|
|
499
499
|
|
|
500
500
|
```text
|
|
501
|
+
agency epic new <id> --ticket-url <url> [--description <text>]
|
|
502
|
+
--repo <alias>:<ref> [--repo <alias>:<ref>...] [--work [--auto]]
|
|
501
503
|
agency epic create <id> --ticket-url <url> [--description <text>] [--json]
|
|
502
504
|
--repo <alias>:<ref> [--repo <alias>:<ref>...]
|
|
503
505
|
agency epic list [filters] [--json]
|
|
@@ -517,9 +519,13 @@ repository is available, Agency selects it without presenting a redundant
|
|
|
517
519
|
choice. This command requires a TTY and fails with `--no-input`:
|
|
518
520
|
|
|
519
521
|
```text
|
|
520
|
-
agency task new [id]
|
|
522
|
+
agency task new [id] [--work [--auto]]
|
|
521
523
|
```
|
|
522
524
|
|
|
525
|
+
`--work` starts work on the newly created entity. Add `--auto` to pass the
|
|
526
|
+
generated context prompt to the selected runner. These launch options are also
|
|
527
|
+
available on `epic new` and `phase new`; they cannot be combined with `--json`.
|
|
528
|
+
|
|
523
529
|
Create a single-phase task:
|
|
524
530
|
|
|
525
531
|
```text
|
|
@@ -590,6 +596,8 @@ phase. Dependencies remain explicit through `--depends-on`.
|
|
|
590
596
|
### Phases
|
|
591
597
|
|
|
592
598
|
```text
|
|
599
|
+
agency phase new <task-id> <phase-id>
|
|
600
|
+
--repo <alias> --branch <name> --base <name> [--work [--auto]]
|
|
593
601
|
agency phase create <task-id> <phase-id>
|
|
594
602
|
--repo <alias> --branch <name> --base <name>
|
|
595
603
|
[--description <text>] [--reference <alias>:<ref>...]
|
package/cli.ts
CHANGED
|
@@ -251,8 +251,11 @@ const commands: Record<string, Command> = {
|
|
|
251
251
|
ready: options.ready,
|
|
252
252
|
blocked: options.blocked,
|
|
253
253
|
pr: options.pr ? true : options["no-pr"] ? false : undefined,
|
|
254
|
+
work: options.work,
|
|
255
|
+
auto: options.auto,
|
|
254
256
|
silent: options.silent,
|
|
255
257
|
verbose: options.verbose,
|
|
258
|
+
inputAllowed: options.inputAllowed,
|
|
256
259
|
cwd: options.cwd,
|
|
257
260
|
}),
|
|
258
261
|
)
|
|
@@ -304,8 +307,11 @@ const commands: Record<string, Command> = {
|
|
|
304
307
|
ready: options.ready,
|
|
305
308
|
blocked: options.blocked,
|
|
306
309
|
pr: options.pr ? true : options["no-pr"] ? false : undefined,
|
|
310
|
+
work: options.work,
|
|
311
|
+
auto: options.auto,
|
|
307
312
|
silent: options.silent,
|
|
308
313
|
verbose: options.verbose,
|
|
314
|
+
inputAllowed: options.inputAllowed,
|
|
309
315
|
cwd: options.cwd,
|
|
310
316
|
}),
|
|
311
317
|
)
|
|
@@ -452,6 +458,8 @@ const commands: Record<string, Command> = {
|
|
|
452
458
|
ready: options.ready,
|
|
453
459
|
blocked: options.blocked,
|
|
454
460
|
pr: options.pr ? true : options["no-pr"] ? false : undefined,
|
|
461
|
+
work: options.work,
|
|
462
|
+
auto: options.auto,
|
|
455
463
|
silent: options.silent,
|
|
456
464
|
verbose: options.verbose,
|
|
457
465
|
inputAllowed: options.inputAllowed,
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -477,6 +477,56 @@ describe("strict CLI parsing", () => {
|
|
|
477
477
|
)
|
|
478
478
|
})
|
|
479
479
|
|
|
480
|
+
test("accepts work launch options on new entities", () => {
|
|
481
|
+
expect(
|
|
482
|
+
parseCli(["task", "new", "example", "--work", "--auto"]),
|
|
483
|
+
).toMatchObject({
|
|
484
|
+
commandName: "task",
|
|
485
|
+
args: ["new", "example"],
|
|
486
|
+
values: { work: true, auto: true },
|
|
487
|
+
})
|
|
488
|
+
expect(
|
|
489
|
+
parseCli([
|
|
490
|
+
"epic",
|
|
491
|
+
"new",
|
|
492
|
+
"delivery",
|
|
493
|
+
"--ticket-url",
|
|
494
|
+
"https://example.com/delivery",
|
|
495
|
+
"--repo",
|
|
496
|
+
"agency:main",
|
|
497
|
+
"--work",
|
|
498
|
+
]),
|
|
499
|
+
).toMatchObject({ commandName: "epic", args: ["new", "delivery"] })
|
|
500
|
+
expect(
|
|
501
|
+
parseCli([
|
|
502
|
+
"phase",
|
|
503
|
+
"new",
|
|
504
|
+
"delivery",
|
|
505
|
+
"implementation",
|
|
506
|
+
"--repo",
|
|
507
|
+
"agency",
|
|
508
|
+
"--branch",
|
|
509
|
+
"task/implementation",
|
|
510
|
+
"--base",
|
|
511
|
+
"main",
|
|
512
|
+
"--work",
|
|
513
|
+
]),
|
|
514
|
+
).toMatchObject({
|
|
515
|
+
commandName: "phase",
|
|
516
|
+
args: ["new", "delivery", "implementation"],
|
|
517
|
+
})
|
|
518
|
+
expect(() => parseCli(["task", "new", "example", "--auto"])).toThrow(
|
|
519
|
+
"Option '--auto' requires '--work'",
|
|
520
|
+
)
|
|
521
|
+
expect(() =>
|
|
522
|
+
parseCli(["task", "new", "example", "--work", "--json"]),
|
|
523
|
+
).toThrow("cannot be combined")
|
|
524
|
+
expectUsageError(
|
|
525
|
+
["task", "create", "example", "--repo", "agency", "--work"],
|
|
526
|
+
"agency task create",
|
|
527
|
+
)
|
|
528
|
+
})
|
|
529
|
+
|
|
480
530
|
test("accepts repeatable graph filters and rejects output conflicts", () => {
|
|
481
531
|
expect(
|
|
482
532
|
parseCli([
|
package/src/cli-parser.ts
CHANGED
|
@@ -70,6 +70,11 @@ const createOptions = {
|
|
|
70
70
|
repo: { type: "string", multiple: true },
|
|
71
71
|
} satisfies OptionConfig
|
|
72
72
|
|
|
73
|
+
const newWorkOptions = {
|
|
74
|
+
work: { type: "boolean" },
|
|
75
|
+
auto: { type: "boolean" },
|
|
76
|
+
} satisfies OptionConfig
|
|
77
|
+
|
|
73
78
|
const taskCreateOptions = {
|
|
74
79
|
...createOptions,
|
|
75
80
|
reference: { type: "string", multiple: true },
|
|
@@ -282,14 +287,25 @@ const commands = {
|
|
|
282
287
|
},
|
|
283
288
|
},
|
|
284
289
|
epic: {
|
|
285
|
-
usage: "agency epic <create|list|show|update|rename>",
|
|
290
|
+
usage: "agency epic <new|create|list|show|update|rename>",
|
|
286
291
|
options: {
|
|
287
292
|
...createOptions,
|
|
293
|
+
...newWorkOptions,
|
|
288
294
|
...viewOptions,
|
|
289
295
|
...mutationOptions,
|
|
290
296
|
epic: { type: "string" },
|
|
291
297
|
},
|
|
292
298
|
subcommands: {
|
|
299
|
+
new: {
|
|
300
|
+
usage:
|
|
301
|
+
"agency epic new <id> --ticket-url <url> --repo <alias>:<ref> [--repo <alias>:<ref>...] [--work [--auto]]",
|
|
302
|
+
minArgs: 1,
|
|
303
|
+
maxArgs: 1,
|
|
304
|
+
options: ["ticket-url", "description", "repo", "work", "auto", "json"],
|
|
305
|
+
required: ["ticket-url", "repo"],
|
|
306
|
+
repeatable: ["repo"],
|
|
307
|
+
conflicts: [["work", "json"]],
|
|
308
|
+
},
|
|
293
309
|
create: {
|
|
294
310
|
usage:
|
|
295
311
|
"agency epic create <id> --ticket-url <url> --repo <alias>:<ref> [--repo <alias>:<ref>...]",
|
|
@@ -341,6 +357,7 @@ const commands = {
|
|
|
341
357
|
"agency task <new|create|list|show|status|update|rename|move|dependency>",
|
|
342
358
|
options: {
|
|
343
359
|
...taskCreateOptions,
|
|
360
|
+
...newWorkOptions,
|
|
344
361
|
...viewOptions,
|
|
345
362
|
...mutationOptions,
|
|
346
363
|
"pr-url": { type: "string" },
|
|
@@ -348,7 +365,7 @@ const commands = {
|
|
|
348
365
|
},
|
|
349
366
|
subcommands: {
|
|
350
367
|
new: {
|
|
351
|
-
usage: "agency task new [id] [options]",
|
|
368
|
+
usage: "agency task new [id] [options] [--work [--auto]]",
|
|
352
369
|
minArgs: 0,
|
|
353
370
|
maxArgs: 1,
|
|
354
371
|
options: [
|
|
@@ -360,9 +377,12 @@ const commands = {
|
|
|
360
377
|
"branch",
|
|
361
378
|
"base",
|
|
362
379
|
"multi-phase",
|
|
380
|
+
"work",
|
|
381
|
+
"auto",
|
|
363
382
|
"json",
|
|
364
383
|
],
|
|
365
384
|
repeatable: ["reference"],
|
|
385
|
+
conflicts: [["work", "json"]],
|
|
366
386
|
},
|
|
367
387
|
create: {
|
|
368
388
|
usage:
|
|
@@ -452,9 +472,11 @@ const commands = {
|
|
|
452
472
|
},
|
|
453
473
|
},
|
|
454
474
|
phase: {
|
|
455
|
-
usage:
|
|
475
|
+
usage:
|
|
476
|
+
"agency phase <new|create|list|show|status|update|rename|dependency>",
|
|
456
477
|
options: {
|
|
457
478
|
...phaseCreateOptions,
|
|
479
|
+
...newWorkOptions,
|
|
458
480
|
...viewOptions,
|
|
459
481
|
...mutationOptions,
|
|
460
482
|
"pr-url": { type: "string" },
|
|
@@ -462,6 +484,29 @@ const commands = {
|
|
|
462
484
|
phase: { type: "string" },
|
|
463
485
|
},
|
|
464
486
|
subcommands: {
|
|
487
|
+
new: {
|
|
488
|
+
usage:
|
|
489
|
+
"agency phase new <task-id> <phase-id> --repo <alias> --branch <name> --base <name> [options] [--work [--auto]]",
|
|
490
|
+
minArgs: 2,
|
|
491
|
+
maxArgs: 2,
|
|
492
|
+
options: [
|
|
493
|
+
"description",
|
|
494
|
+
"repo",
|
|
495
|
+
"reference",
|
|
496
|
+
"branch",
|
|
497
|
+
"base",
|
|
498
|
+
"depends-on",
|
|
499
|
+
"first-phase",
|
|
500
|
+
"work",
|
|
501
|
+
"auto",
|
|
502
|
+
"json",
|
|
503
|
+
"task",
|
|
504
|
+
"phase",
|
|
505
|
+
],
|
|
506
|
+
required: ["repo", "branch", "base"],
|
|
507
|
+
repeatable: ["reference", "depends-on"],
|
|
508
|
+
conflicts: [["work", "json"]],
|
|
509
|
+
},
|
|
465
510
|
create: {
|
|
466
511
|
usage:
|
|
467
512
|
"agency phase create <task-id> <phase-id> --repo <alias> --branch <name> --base <name> [options]",
|
|
@@ -1295,6 +1340,14 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1295
1340
|
) {
|
|
1296
1341
|
validateTaskCreate(parsed.values, spec, subcommand === "create")
|
|
1297
1342
|
}
|
|
1343
|
+
if (
|
|
1344
|
+
["epic", "task", "phase"].includes(commandName) &&
|
|
1345
|
+
subcommand === "new" &&
|
|
1346
|
+
parsed.values.auto &&
|
|
1347
|
+
!parsed.values.work
|
|
1348
|
+
) {
|
|
1349
|
+
throw usageError("Option '--auto' requires '--work'.", spec.usage)
|
|
1350
|
+
}
|
|
1298
1351
|
if (["task", "phase"].includes(commandName) && subcommand === "dependency") {
|
|
1299
1352
|
if (!["add", "remove"].includes(commandPositionals[0] ?? "")) {
|
|
1300
1353
|
throw usageError(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { mkdir } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
4
5
|
import {
|
|
5
6
|
captureLogs,
|
|
6
7
|
cleanupTempDir,
|
|
@@ -131,4 +132,36 @@ describe("epic command", () => {
|
|
|
131
132
|
expect(logs[0]).toContain("example open waiting agency")
|
|
132
133
|
expect(logs[0]).not.toMatch(/[\u{e000}-\u{f8ff}]/u)
|
|
133
134
|
})
|
|
135
|
+
|
|
136
|
+
test("starts work on a newly created epic", async () => {
|
|
137
|
+
const launches: unknown[] = []
|
|
138
|
+
|
|
139
|
+
await runTestEffect(
|
|
140
|
+
epic(
|
|
141
|
+
{
|
|
142
|
+
subcommand: "new",
|
|
143
|
+
args: ["immediate"],
|
|
144
|
+
ticketUrl: "https://example.com/immediate",
|
|
145
|
+
repos: ["agency:main"],
|
|
146
|
+
work: true,
|
|
147
|
+
auto: true,
|
|
148
|
+
cwd: root,
|
|
149
|
+
silent: true,
|
|
150
|
+
},
|
|
151
|
+
(options) =>
|
|
152
|
+
Effect.sync(() => {
|
|
153
|
+
launches.push(options)
|
|
154
|
+
return undefined
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
expect(launches).toEqual([
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
epicId: "immediate",
|
|
162
|
+
auto: true,
|
|
163
|
+
cwd: root,
|
|
164
|
+
}),
|
|
165
|
+
])
|
|
166
|
+
})
|
|
134
167
|
})
|
package/src/commands/epic.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { formatTable } from "../utils/table"
|
|
|
6
6
|
import { getWorkViews } from "../work-view"
|
|
7
7
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
8
8
|
import { GraphMutationService } from "../services/GraphMutationService"
|
|
9
|
+
import { work as startWork, type StartWork } from "./work"
|
|
9
10
|
|
|
10
11
|
interface EpicOptions extends BaseCommandOptions {
|
|
11
12
|
readonly subcommand?: string
|
|
@@ -21,9 +22,11 @@ interface EpicOptions extends BaseCommandOptions {
|
|
|
21
22
|
readonly ready?: boolean
|
|
22
23
|
readonly blocked?: boolean
|
|
23
24
|
readonly pr?: boolean
|
|
25
|
+
readonly work?: boolean
|
|
26
|
+
readonly auto?: boolean
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
export const epic = (options: EpicOptions) =>
|
|
29
|
+
export const epic = (options: EpicOptions, work: StartWork = startWork) =>
|
|
27
30
|
Effect.gen(function* () {
|
|
28
31
|
const epics = yield* EpicService
|
|
29
32
|
const mutations = yield* GraphMutationService
|
|
@@ -31,6 +34,7 @@ export const epic = (options: EpicOptions) =>
|
|
|
31
34
|
const cwd = options.cwd ?? process.cwd()
|
|
32
35
|
|
|
33
36
|
switch (options.subcommand) {
|
|
37
|
+
case "new":
|
|
34
38
|
case "create": {
|
|
35
39
|
const id = options.args[0]
|
|
36
40
|
if (!id || !options.ticketUrl || !options.repos?.length) {
|
|
@@ -53,6 +57,16 @@ export const epic = (options: EpicOptions) =>
|
|
|
53
57
|
? JSON.stringify(output, null, 2)
|
|
54
58
|
: `Created epic '${record.id}'`,
|
|
55
59
|
)
|
|
60
|
+
if (options.subcommand === "new" && options.work) {
|
|
61
|
+
yield* work({
|
|
62
|
+
epicId: record.id,
|
|
63
|
+
auto: options.auto,
|
|
64
|
+
cwd,
|
|
65
|
+
inputAllowed: options.inputAllowed,
|
|
66
|
+
silent: options.silent,
|
|
67
|
+
verbose: options.verbose,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
56
70
|
return
|
|
57
71
|
}
|
|
58
72
|
|
|
@@ -157,7 +171,7 @@ export const epic = (options: EpicOptions) =>
|
|
|
157
171
|
default:
|
|
158
172
|
return yield* Effect.fail(
|
|
159
173
|
new Error(
|
|
160
|
-
"Subcommand is required. Available subcommands: create, list, show, update, rename",
|
|
174
|
+
"Subcommand is required. Available subcommands: new, create, list, show, update, rename",
|
|
161
175
|
),
|
|
162
176
|
)
|
|
163
177
|
}
|
|
@@ -167,6 +181,7 @@ export const help = `
|
|
|
167
181
|
Usage: agency epic <subcommand>
|
|
168
182
|
|
|
169
183
|
Subcommands:
|
|
184
|
+
new <id> Create an epic, optionally starting work
|
|
170
185
|
create <id> Create an epic
|
|
171
186
|
list List epics
|
|
172
187
|
show <id> Show an epic
|
|
@@ -177,6 +192,8 @@ Create options:
|
|
|
177
192
|
--ticket-url <url> External ticket URL
|
|
178
193
|
--description <text> Short description of the epic
|
|
179
194
|
--repo <alias>:<ref> Read-only repository reference; repeatable
|
|
195
|
+
--work Start work on the new epic after creating it
|
|
196
|
+
--auto Pass --auto to work; requires --work
|
|
180
197
|
|
|
181
198
|
Update options:
|
|
182
199
|
--ticket-url <url> Replace the external ticket URL
|
package/src/commands/phase.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { formatTable } from "../utils/table"
|
|
|
6
6
|
import { getWorkViews } from "../work-view"
|
|
7
7
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
8
8
|
import { GraphMutationService } from "../services/GraphMutationService"
|
|
9
|
+
import { work as startWork, type StartWork } from "./work"
|
|
9
10
|
|
|
10
11
|
interface PhaseOptions extends BaseCommandOptions {
|
|
11
12
|
readonly subcommand?: string
|
|
@@ -28,9 +29,11 @@ interface PhaseOptions extends BaseCommandOptions {
|
|
|
28
29
|
readonly ready?: boolean
|
|
29
30
|
readonly blocked?: boolean
|
|
30
31
|
readonly pr?: boolean
|
|
32
|
+
readonly work?: boolean
|
|
33
|
+
readonly auto?: boolean
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
export const phase = (options: PhaseOptions) =>
|
|
36
|
+
export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
|
|
34
37
|
Effect.gen(function* () {
|
|
35
38
|
const phases = yield* PhaseService
|
|
36
39
|
const mutations = yield* GraphMutationService
|
|
@@ -39,6 +42,7 @@ export const phase = (options: PhaseOptions) =>
|
|
|
39
42
|
const [taskId, phaseId] = options.args
|
|
40
43
|
|
|
41
44
|
switch (options.subcommand) {
|
|
45
|
+
case "new":
|
|
42
46
|
case "create": {
|
|
43
47
|
if (
|
|
44
48
|
!taskId ||
|
|
@@ -73,6 +77,17 @@ export const phase = (options: PhaseOptions) =>
|
|
|
73
77
|
? JSON.stringify(output, null, 2)
|
|
74
78
|
: `Created phase '${record.id}' on task '${record.taskId}'`,
|
|
75
79
|
)
|
|
80
|
+
if (options.subcommand === "new" && options.work) {
|
|
81
|
+
yield* work({
|
|
82
|
+
taskId: record.taskId,
|
|
83
|
+
phaseId: record.id,
|
|
84
|
+
auto: options.auto,
|
|
85
|
+
cwd,
|
|
86
|
+
inputAllowed: options.inputAllowed,
|
|
87
|
+
silent: options.silent,
|
|
88
|
+
verbose: options.verbose,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
76
91
|
return
|
|
77
92
|
}
|
|
78
93
|
case "list": {
|
|
@@ -245,7 +260,7 @@ export const phase = (options: PhaseOptions) =>
|
|
|
245
260
|
default:
|
|
246
261
|
return yield* Effect.fail(
|
|
247
262
|
new Error(
|
|
248
|
-
"Subcommand is required. Available: create, list, show, status, update, rename, dependency",
|
|
263
|
+
"Subcommand is required. Available: new, create, list, show, status, update, rename, dependency",
|
|
249
264
|
),
|
|
250
265
|
)
|
|
251
266
|
}
|
|
@@ -255,6 +270,7 @@ export const help = `
|
|
|
255
270
|
Usage: agency phase <subcommand> <task-id> [phase-id]
|
|
256
271
|
|
|
257
272
|
Subcommands:
|
|
273
|
+
new <task> <phase> Create a phase, optionally starting work
|
|
258
274
|
create <task> <phase> Create a phase
|
|
259
275
|
list <task> List task phases
|
|
260
276
|
show <task> <phase> Show a phase
|
|
@@ -278,6 +294,8 @@ Create options:
|
|
|
278
294
|
--base <name> Base branch
|
|
279
295
|
--depends-on <id> Phase dependency; repeatable
|
|
280
296
|
--first-phase <id> Existing execution phase ID when converting a task
|
|
297
|
+
--work Start work on the new phase after creating it
|
|
298
|
+
--auto Pass --auto to work; requires --work
|
|
281
299
|
|
|
282
300
|
Update options:
|
|
283
301
|
--description <text> / --clear-description
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { mkdir } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
4
5
|
import {
|
|
5
6
|
captureLogs,
|
|
6
7
|
cleanupTempDir,
|
|
@@ -199,6 +200,40 @@ describe("task and phase command JSON output", () => {
|
|
|
199
200
|
})
|
|
200
201
|
})
|
|
201
202
|
|
|
203
|
+
test("starts work on a newly created phase", async () => {
|
|
204
|
+
const launches: unknown[] = []
|
|
205
|
+
|
|
206
|
+
await runTestEffect(
|
|
207
|
+
phase(
|
|
208
|
+
{
|
|
209
|
+
subcommand: "new",
|
|
210
|
+
args: ["multi", "immediate"],
|
|
211
|
+
repo: "agency",
|
|
212
|
+
branch: "task/immediate",
|
|
213
|
+
base: "main",
|
|
214
|
+
work: true,
|
|
215
|
+
auto: true,
|
|
216
|
+
cwd: root,
|
|
217
|
+
silent: true,
|
|
218
|
+
},
|
|
219
|
+
(options) =>
|
|
220
|
+
Effect.sync(() => {
|
|
221
|
+
launches.push(options)
|
|
222
|
+
return undefined
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
expect(launches).toEqual([
|
|
228
|
+
expect.objectContaining({
|
|
229
|
+
taskId: "multi",
|
|
230
|
+
phaseId: "immediate",
|
|
231
|
+
auto: true,
|
|
232
|
+
cwd: root,
|
|
233
|
+
}),
|
|
234
|
+
])
|
|
235
|
+
})
|
|
236
|
+
|
|
202
237
|
test("sets task and phase status", async () => {
|
|
203
238
|
const phaseLogs = await captureLogs(() =>
|
|
204
239
|
runTestEffect(
|
|
@@ -167,4 +167,39 @@ describe("task creation input", () => {
|
|
|
167
167
|
),
|
|
168
168
|
).rejects.toThrow("task new requires interactive input")
|
|
169
169
|
})
|
|
170
|
+
|
|
171
|
+
test("starts work on a newly created task", async () => {
|
|
172
|
+
const launches: unknown[] = []
|
|
173
|
+
|
|
174
|
+
await runTestEffect(
|
|
175
|
+
task(
|
|
176
|
+
{
|
|
177
|
+
subcommand: "new",
|
|
178
|
+
args: ["immediate"],
|
|
179
|
+
ticketUrl: "",
|
|
180
|
+
description: "",
|
|
181
|
+
multiPhase: false,
|
|
182
|
+
repo: "agency",
|
|
183
|
+
work: true,
|
|
184
|
+
auto: true,
|
|
185
|
+
cwd: root,
|
|
186
|
+
silent: true,
|
|
187
|
+
},
|
|
188
|
+
undefined,
|
|
189
|
+
(options) =>
|
|
190
|
+
Effect.sync(() => {
|
|
191
|
+
launches.push(options)
|
|
192
|
+
return undefined
|
|
193
|
+
}),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(launches).toEqual([
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
taskId: "immediate",
|
|
200
|
+
auto: true,
|
|
201
|
+
cwd: root,
|
|
202
|
+
}),
|
|
203
|
+
])
|
|
204
|
+
})
|
|
170
205
|
})
|
package/src/commands/task.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { choose } from "../utils/chooser"
|
|
|
10
10
|
import { formatTable } from "../utils/table"
|
|
11
11
|
import { getWorkViews } from "../work-view"
|
|
12
12
|
import { GraphMutationService } from "../services/GraphMutationService"
|
|
13
|
+
import { work as startWork, type StartWork } from "./work"
|
|
13
14
|
|
|
14
15
|
interface TaskOptions extends BaseCommandOptions {
|
|
15
16
|
readonly subcommand?: string
|
|
@@ -35,6 +36,8 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
35
36
|
readonly ready?: boolean
|
|
36
37
|
readonly blocked?: boolean
|
|
37
38
|
readonly pr?: boolean
|
|
39
|
+
readonly work?: boolean
|
|
40
|
+
readonly auto?: boolean
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export interface TaskInteraction {
|
|
@@ -68,7 +71,11 @@ const defaultInteraction = (
|
|
|
68
71
|
),
|
|
69
72
|
})
|
|
70
73
|
|
|
71
|
-
export const task = (
|
|
74
|
+
export const task = (
|
|
75
|
+
options: TaskOptions,
|
|
76
|
+
interaction?: TaskInteraction,
|
|
77
|
+
work: StartWork = startWork,
|
|
78
|
+
) =>
|
|
72
79
|
Effect.gen(function* () {
|
|
73
80
|
const tasks = yield* TaskService
|
|
74
81
|
const epics = yield* EpicService
|
|
@@ -183,6 +190,16 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
183
190
|
? JSON.stringify(output, null, 2)
|
|
184
191
|
: `Created task '${record.id}'`,
|
|
185
192
|
)
|
|
193
|
+
if (options.subcommand === "new" && options.work) {
|
|
194
|
+
yield* work({
|
|
195
|
+
taskId: record.id,
|
|
196
|
+
auto: options.auto,
|
|
197
|
+
cwd,
|
|
198
|
+
inputAllowed: options.inputAllowed,
|
|
199
|
+
silent: options.silent,
|
|
200
|
+
verbose: options.verbose,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
186
203
|
return
|
|
187
204
|
}
|
|
188
205
|
case "create": {
|
|
@@ -426,6 +443,8 @@ Create options:
|
|
|
426
443
|
--branch <name> Working branch (default: task/<id>)
|
|
427
444
|
--base <name> Base branch (default: main)
|
|
428
445
|
--multi-phase Create a task container for phases
|
|
446
|
+
--work Start work on the new task after creating it
|
|
447
|
+
--auto Pass --auto to work; requires --work
|
|
429
448
|
|
|
430
449
|
Update options:
|
|
431
450
|
--ticket-url <url> / --clear-ticket
|
|
@@ -71,6 +71,8 @@ interface HarnessOptions {
|
|
|
71
71
|
readonly guardError?: Error
|
|
72
72
|
readonly workTargetIds?: readonly string[]
|
|
73
73
|
readonly opencodeIntegrationState?: "managed" | "customized"
|
|
74
|
+
readonly taskStatus?: "open" | "working" | "delegated" | "done" | "dropped"
|
|
75
|
+
readonly phaseStatus?: "open" | "working" | "delegated" | "done" | "dropped"
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -141,7 +143,12 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
141
143
|
path: `/workbase/tasks/${id}/TASK.md`,
|
|
142
144
|
data: options.multiPhaseTasks?.includes(id)
|
|
143
145
|
? { phases: [] }
|
|
144
|
-
: {
|
|
146
|
+
: {
|
|
147
|
+
repo: "agency",
|
|
148
|
+
branch: `task/${id}`,
|
|
149
|
+
base: "main",
|
|
150
|
+
status: options.taskStatus ?? "open",
|
|
151
|
+
},
|
|
145
152
|
})
|
|
146
153
|
},
|
|
147
154
|
list: () => Effect.succeed(options.taskRecords ?? []),
|
|
@@ -156,7 +163,12 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
156
163
|
taskId,
|
|
157
164
|
id,
|
|
158
165
|
path: `/workbase/tasks/${taskId}/phases/${id}/PHASE.md`,
|
|
159
|
-
data: {
|
|
166
|
+
data: {
|
|
167
|
+
repo: "agency",
|
|
168
|
+
branch: `task/${id}`,
|
|
169
|
+
base: "main",
|
|
170
|
+
status: options.phaseStatus ?? "open",
|
|
171
|
+
},
|
|
160
172
|
}),
|
|
161
173
|
list: () => Effect.succeed(options.phaseRecords ?? []),
|
|
162
174
|
setStatus: (taskId: string, id: string, status: string) => {
|
|
@@ -702,6 +714,22 @@ describe("work command", () => {
|
|
|
702
714
|
)
|
|
703
715
|
})
|
|
704
716
|
|
|
717
|
+
test("continues existing work with the resume command", async () => {
|
|
718
|
+
const harness = createHarness({ taskStatus: "working" })
|
|
719
|
+
|
|
720
|
+
await harness.run({ taskId: "example", opencode: true, auto: true })
|
|
721
|
+
|
|
722
|
+
expect(harness.launches[0]?.args).toEqual([
|
|
723
|
+
"opencode",
|
|
724
|
+
"--continue",
|
|
725
|
+
"--prompt",
|
|
726
|
+
"Continue the task. Read /workbase/tasks/example/TASK.md.",
|
|
727
|
+
])
|
|
728
|
+
expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
|
|
729
|
+
"Continue the task. Read /workbase/tasks/example/TASK.md.",
|
|
730
|
+
)
|
|
731
|
+
})
|
|
732
|
+
|
|
705
733
|
test("resumes OpenCode deterministically when a session identity exists", async () => {
|
|
706
734
|
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
707
735
|
process.env.AGENCY_SESSION_ID = "existing-session"
|
|
@@ -722,7 +750,7 @@ describe("work command", () => {
|
|
|
722
750
|
"opencode",
|
|
723
751
|
"--continue",
|
|
724
752
|
"--prompt",
|
|
725
|
-
"
|
|
753
|
+
"Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
726
754
|
],
|
|
727
755
|
cwd: taskDirectory,
|
|
728
756
|
})
|
package/src/commands/work.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
runnerEnvironment,
|
|
30
30
|
} from "../workbase/runner-command"
|
|
31
31
|
|
|
32
|
-
interface WorkOptions extends BaseCommandOptions {
|
|
32
|
+
export interface WorkOptions extends BaseCommandOptions {
|
|
33
33
|
readonly directory?: string
|
|
34
34
|
readonly taskId?: string
|
|
35
35
|
readonly phaseId?: string
|
|
@@ -42,6 +42,8 @@ interface WorkOptions extends BaseCommandOptions {
|
|
|
42
42
|
readonly force?: boolean
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export type StartWork = (options: WorkOptions) => ReturnType<typeof work>
|
|
46
|
+
|
|
45
47
|
type LaunchAgent = (
|
|
46
48
|
cli: string,
|
|
47
49
|
args: readonly string[],
|
|
@@ -138,6 +140,7 @@ export const work = (
|
|
|
138
140
|
taskId: task.id,
|
|
139
141
|
phaseId: phase.id,
|
|
140
142
|
path: phase.path,
|
|
143
|
+
status: phase.data.status,
|
|
141
144
|
}
|
|
142
145
|
} else {
|
|
143
146
|
target = {
|
|
@@ -145,6 +148,7 @@ export const work = (
|
|
|
145
148
|
taskId: task.id,
|
|
146
149
|
path: task.path,
|
|
147
150
|
multiPhase: "phases" in task.data,
|
|
151
|
+
status: "phases" in task.data ? undefined : task.data.status,
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
} else if (options.directory && !isDirectory) {
|
|
@@ -154,6 +158,7 @@ export const work = (
|
|
|
154
158
|
taskId: task.id,
|
|
155
159
|
path: task.path,
|
|
156
160
|
multiPhase: "phases" in task.data,
|
|
161
|
+
status: "phases" in task.data ? undefined : task.data.status,
|
|
157
162
|
}
|
|
158
163
|
} else if (directoryPath) {
|
|
159
164
|
const path = relative(root, startPath)
|
|
@@ -173,6 +178,7 @@ export const work = (
|
|
|
173
178
|
taskId: task.id,
|
|
174
179
|
phaseId: phase.id,
|
|
175
180
|
path: phase.path,
|
|
181
|
+
status: phase.data.status,
|
|
176
182
|
}
|
|
177
183
|
} else {
|
|
178
184
|
target = {
|
|
@@ -180,6 +186,7 @@ export const work = (
|
|
|
180
186
|
taskId: task.id,
|
|
181
187
|
path: task.path,
|
|
182
188
|
multiPhase: "phases" in task.data,
|
|
189
|
+
status: "phases" in task.data ? undefined : task.data.status,
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
192
|
}
|
|
@@ -224,6 +231,11 @@ export const work = (
|
|
|
224
231
|
}
|
|
225
232
|
yield* readiness.guardWorkTarget(targetNodeId(target), root, options.force)
|
|
226
233
|
|
|
234
|
+
const continuing =
|
|
235
|
+
target.kind !== "epic" &&
|
|
236
|
+
!(target.kind === "task" && target.multiPhase) &&
|
|
237
|
+
(process.env.AGENCY_SESSION_ID !== undefined ||
|
|
238
|
+
(target.status !== undefined && target.status !== "open"))
|
|
227
239
|
let prompt: string
|
|
228
240
|
let launchPath: string
|
|
229
241
|
let writablePath: string | undefined
|
|
@@ -247,9 +259,10 @@ export const work = (
|
|
|
247
259
|
Effect.sync(() => progress.fail("Workspace preparation failed")),
|
|
248
260
|
),
|
|
249
261
|
)
|
|
262
|
+
const action = continuing ? "Continue" : "Start"
|
|
250
263
|
prompt = workspace.phasePath
|
|
251
|
-
?
|
|
252
|
-
:
|
|
264
|
+
? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
265
|
+
: `${action} the task. Read ${workspace.taskPath}.`
|
|
253
266
|
launchPath = dirname(workspace.taskPath)
|
|
254
267
|
writablePath = workspace.writablePath
|
|
255
268
|
}
|
|
@@ -267,7 +280,9 @@ export const work = (
|
|
|
267
280
|
const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
|
|
268
281
|
const sessionId =
|
|
269
282
|
process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
|
|
270
|
-
const resume =
|
|
283
|
+
const resume =
|
|
284
|
+
process.env.AGENCY_SESSION_ID !== undefined ||
|
|
285
|
+
(Boolean(options.auto) && continuing)
|
|
271
286
|
const variables = {
|
|
272
287
|
prompt: options.auto ? prompt : "",
|
|
273
288
|
workbase: root,
|
|
@@ -45,10 +45,13 @@ describe("spawnProcess", () => {
|
|
|
45
45
|
const lineCount = 256
|
|
46
46
|
const script = [
|
|
47
47
|
`const line = ${JSON.stringify(line)}`,
|
|
48
|
-
`
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
48
|
+
`const indexes = Array.from({ length: ${lineCount} }, (_, i) => i)`,
|
|
49
|
+
"const stdout = indexes.map((i) => `out:${i}:${line}`).join(`\n`)",
|
|
50
|
+
"const stderr = indexes.map((i) => `err:${i}:${line}`).join(`\n`)",
|
|
51
|
+
"const write = (stream, output) => new Promise((resolve, reject) => {",
|
|
52
|
+
"stream.write(output, (error) => error ? reject(error) : resolve())",
|
|
53
|
+
"})",
|
|
54
|
+
"await Promise.all([write(process.stdout, stdout), write(process.stderr, stderr)])",
|
|
52
55
|
].join("\n")
|
|
53
56
|
|
|
54
57
|
const result = await Effect.runPromise(
|
|
@@ -13,12 +13,14 @@ export type WorkTarget =
|
|
|
13
13
|
readonly taskId: string
|
|
14
14
|
readonly path: string
|
|
15
15
|
readonly multiPhase: boolean
|
|
16
|
+
readonly status?: WorkStatus
|
|
16
17
|
}
|
|
17
18
|
| {
|
|
18
19
|
readonly kind: "phase"
|
|
19
20
|
readonly taskId: string
|
|
20
21
|
readonly phaseId: string
|
|
21
22
|
readonly path: string
|
|
23
|
+
readonly status?: WorkStatus
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface EpicRecord {
|
|
@@ -116,6 +118,7 @@ const taskChoices = (
|
|
|
116
118
|
taskId: task.id,
|
|
117
119
|
path: task.path,
|
|
118
120
|
multiPhase,
|
|
121
|
+
status: multiPhase ? undefined : (task.data.status ?? "open"),
|
|
119
122
|
},
|
|
120
123
|
},
|
|
121
124
|
]
|
|
@@ -147,6 +150,7 @@ const taskChoices = (
|
|
|
147
150
|
taskId: task.id,
|
|
148
151
|
phaseId: record.id,
|
|
149
152
|
path: record.path,
|
|
153
|
+
status: record.data.status ?? "open",
|
|
150
154
|
},
|
|
151
155
|
})
|
|
152
156
|
}
|
|
@@ -172,6 +176,7 @@ const taskChoices = (
|
|
|
172
176
|
taskId: task.id,
|
|
173
177
|
phaseId: record.id,
|
|
174
178
|
path: record.path,
|
|
179
|
+
status: record.data.status ?? "open",
|
|
175
180
|
},
|
|
176
181
|
})
|
|
177
182
|
}
|