@markjaquith/agency 2.4.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/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 +52 -33
- package/src/commands/work.ts +20 -8
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -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 +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- 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/README.md
CHANGED
|
@@ -43,6 +43,8 @@ frontmatter; prose below it supplies human and agent context.
|
|
|
43
43
|
```text
|
|
44
44
|
workbase/
|
|
45
45
|
AGENTS.md # managed workbase instructions
|
|
46
|
+
.opencode/
|
|
47
|
+
opencode.jsonc # managed task and epic references
|
|
46
48
|
agency.json
|
|
47
49
|
repos/
|
|
48
50
|
frontend/ # bare Git repository or symlink
|
|
@@ -69,10 +71,11 @@ workbase/
|
|
|
69
71
|
backend/
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
Agency creates `AGENTS.md`
|
|
73
|
-
the workbase is discovered.
|
|
74
|
-
|
|
75
|
-
files
|
|
74
|
+
Agency creates `AGENTS.md` and `.opencode/opencode.jsonc` during initialization
|
|
75
|
+
and ensures they exist whenever the workbase is discovered. The OpenCode config
|
|
76
|
+
grants external-directory access to task and epic references. Checksums in the
|
|
77
|
+
generated files let newer Agency versions refresh unmodified content while
|
|
78
|
+
preserving custom or edited files.
|
|
76
79
|
|
|
77
80
|
Repository metadata comes directly from Git under `repos/{alias}`. Workbase
|
|
78
81
|
configuration may provide a custom writable-worktree creation command.
|
|
@@ -177,6 +180,7 @@ repos:
|
|
|
177
180
|
branch: task/refresh-copy
|
|
178
181
|
base: main
|
|
179
182
|
pr: null
|
|
183
|
+
status: open
|
|
180
184
|
---
|
|
181
185
|
```
|
|
182
186
|
|
|
@@ -207,6 +211,7 @@ repos:
|
|
|
207
211
|
branch: task/checkout-ui
|
|
208
212
|
base: task/checkout-api
|
|
209
213
|
pr: null
|
|
214
|
+
status: open
|
|
210
215
|
---
|
|
211
216
|
```
|
|
212
217
|
|
|
@@ -222,13 +227,7 @@ cd ~/work
|
|
|
222
227
|
agency repo add frontend git@github.com:example/frontend.git
|
|
223
228
|
agency repo link backend ~/Dev/backend
|
|
224
229
|
|
|
225
|
-
agency task
|
|
226
|
-
--ticket-url https://example.com/tickets/refresh-copy \
|
|
227
|
-
--description "Refresh user-facing checkout copy" \
|
|
228
|
-
--repo frontend \
|
|
229
|
-
--reference backend:main \
|
|
230
|
-
--branch task/refresh-copy \
|
|
231
|
-
--base main
|
|
230
|
+
agency task new
|
|
232
231
|
|
|
233
232
|
agency validate
|
|
234
233
|
agency work refresh-copy
|
|
@@ -266,19 +265,28 @@ back-reference.
|
|
|
266
265
|
|
|
267
266
|
### Tasks
|
|
268
267
|
|
|
268
|
+
Create a task interactively. Text prompts identify optional values, and known
|
|
269
|
+
choices use fzf:
|
|
270
|
+
|
|
271
|
+
```text
|
|
272
|
+
agency task new [id]
|
|
273
|
+
```
|
|
274
|
+
|
|
269
275
|
Create a single-phase task:
|
|
270
276
|
|
|
271
277
|
```text
|
|
272
|
-
agency task create <id> --
|
|
273
|
-
--
|
|
274
|
-
[--
|
|
278
|
+
agency task create <id> --repo <alias>
|
|
279
|
+
[--ticket-url <url>] [--description <text>] [--epic <id>]
|
|
280
|
+
[--reference <alias>:<ref>...] [--branch <name>] [--base <name>] [--json]
|
|
275
281
|
```
|
|
276
282
|
|
|
283
|
+
The branch defaults to `task/<id>` and the base defaults to `main`.
|
|
284
|
+
|
|
277
285
|
Create a multi-phase task container:
|
|
278
286
|
|
|
279
287
|
```text
|
|
280
|
-
agency task create <id> --
|
|
281
|
-
[--description <text>] [--epic <id>] [--json]
|
|
288
|
+
agency task create <id> --multi-phase
|
|
289
|
+
[--ticket-url <url>] [--description <text>] [--epic <id>] [--json]
|
|
282
290
|
```
|
|
283
291
|
|
|
284
292
|
Inspect tasks:
|
|
@@ -286,6 +294,7 @@ Inspect tasks:
|
|
|
286
294
|
```text
|
|
287
295
|
agency task list [--json]
|
|
288
296
|
agency task show <id> [--json]
|
|
297
|
+
agency task status <id> <open|working|done|dropped> [--json]
|
|
289
298
|
```
|
|
290
299
|
|
|
291
300
|
To add a phase to an existing single-phase task, name the phase that will own
|
|
@@ -312,8 +321,28 @@ agency phase create <task-id> <phase-id>
|
|
|
312
321
|
|
|
313
322
|
agency phase list <task-id> [--json]
|
|
314
323
|
agency phase show <task-id> <phase-id> [--json]
|
|
324
|
+
agency phase status <task-id> <phase-id> <open|working|done|dropped> [--json]
|
|
315
325
|
```
|
|
316
326
|
|
|
327
|
+
Single-phase tasks and phases store status in YAML. New execution units start
|
|
328
|
+
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
329
|
+
before launch. Use the status subcommands to mark work `done`, `dropped`, or open
|
|
330
|
+
it again. The interactive work selector displays status markers before execution
|
|
331
|
+
units.
|
|
332
|
+
|
|
333
|
+
### Archive
|
|
334
|
+
|
|
335
|
+
```text
|
|
336
|
+
agency archive epic <epic-id> [--json]
|
|
337
|
+
agency archive task <task-id> [--json]
|
|
338
|
+
agency archive phase <task-id> <phase-id> [--json]
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Archived work keeps its hierarchy under `archive/`. Epic archiving includes its
|
|
342
|
+
listed tasks. Task and phase archiving update the active parent document and
|
|
343
|
+
reject items that active siblings depend on. Agency removes registered worktrees
|
|
344
|
+
before moving files, refuses dirty worktrees, and preserves branches.
|
|
345
|
+
|
|
317
346
|
### Work and Pull Requests
|
|
318
347
|
|
|
319
348
|
```text
|
package/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { validate, help as validateHelp } from "./src/commands/validate"
|
|
|
11
11
|
import { repo, help as repoHelp } from "./src/commands/repo"
|
|
12
12
|
import { epic, help as epicHelp } from "./src/commands/epic"
|
|
13
13
|
import { phase, help as phaseHelp } from "./src/commands/phase"
|
|
14
|
+
import { archive, help as archiveHelp } from "./src/commands/archive"
|
|
14
15
|
import type { Command } from "./src/types"
|
|
15
16
|
import { FileSystemService } from "./src/services/FileSystemService"
|
|
16
17
|
import { WorkbaseService } from "./src/services/WorkbaseService"
|
|
@@ -20,6 +21,7 @@ import { TaskService } from "./src/services/TaskService"
|
|
|
20
21
|
import { PhaseService } from "./src/services/PhaseService"
|
|
21
22
|
import { WorktreeService } from "./src/services/WorktreeService"
|
|
22
23
|
import { PullRequestService } from "./src/services/PullRequestService"
|
|
24
|
+
import { ArchiveService } from "./src/services/ArchiveService"
|
|
23
25
|
|
|
24
26
|
// Create CLI layer with all services
|
|
25
27
|
const CliLayer = Layer.mergeAll(
|
|
@@ -31,6 +33,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
31
33
|
PhaseService.Default,
|
|
32
34
|
WorktreeService.Default,
|
|
33
35
|
PullRequestService.Default,
|
|
36
|
+
ArchiveService.Default,
|
|
34
37
|
)
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -153,6 +156,23 @@ const commands: Record<string, Command> = {
|
|
|
153
156
|
)
|
|
154
157
|
},
|
|
155
158
|
},
|
|
159
|
+
archive: {
|
|
160
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
161
|
+
if (options.help) {
|
|
162
|
+
console.log(archiveHelp)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
await runCommand(
|
|
166
|
+
archive({
|
|
167
|
+
type: args[0],
|
|
168
|
+
args: args.slice(1),
|
|
169
|
+
json: options.json,
|
|
170
|
+
silent: options.silent,
|
|
171
|
+
verbose: options.verbose,
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
},
|
|
175
|
+
},
|
|
156
176
|
repo: {
|
|
157
177
|
run: async (args: string[], options: Record<string, any>) => {
|
|
158
178
|
if (options.help) {
|
|
@@ -257,6 +277,7 @@ Commands:
|
|
|
257
277
|
init [path] Initialize an Agency workbase
|
|
258
278
|
epic <subcommand> Manage epics
|
|
259
279
|
phase <subcommand> Manage task phases
|
|
280
|
+
archive <type> Archive a work item
|
|
260
281
|
task <subcommand> Manage tasks
|
|
261
282
|
work [task] [phase] Work on an epic, task, or phase
|
|
262
283
|
pr create Create a pull request for an execution unit
|
package/package.json
CHANGED
package/skills/agency/SKILL.md
CHANGED
|
@@ -100,17 +100,22 @@ Epic task ordering and dependencies live in `EPIC.md`. Creating a task with
|
|
|
100
100
|
|
|
101
101
|
## Create Single-Phase Tasks
|
|
102
102
|
|
|
103
|
+
For guided creation, run `agency task new`. It prompts for text input, uses fzf
|
|
104
|
+
for known choices, and allows optional inputs to be skipped.
|
|
105
|
+
|
|
103
106
|
```bash
|
|
104
107
|
agency task create <id> \
|
|
105
|
-
--ticket-url <url> \
|
|
108
|
+
[--ticket-url <url>] \
|
|
106
109
|
[--description <text>] \
|
|
107
110
|
[--epic <epic-id>] \
|
|
108
111
|
--repo <writable-alias> \
|
|
109
112
|
[--reference <read-only-alias>:<ref>] \
|
|
110
|
-
--branch <branch> \
|
|
111
|
-
--base <base>
|
|
113
|
+
[--branch <branch>] \
|
|
114
|
+
[--base <base>]
|
|
112
115
|
```
|
|
113
116
|
|
|
117
|
+
The branch defaults to `task/<id>` and the base defaults to `main`.
|
|
118
|
+
|
|
114
119
|
The task itself is the execution unit. Its worktrees live under
|
|
115
120
|
`tasks/{id}/code/{alias}`.
|
|
116
121
|
|
|
@@ -120,7 +125,7 @@ Create the task container:
|
|
|
120
125
|
|
|
121
126
|
```bash
|
|
122
127
|
agency task create <id> \
|
|
123
|
-
--ticket-url <url> \
|
|
128
|
+
[--ticket-url <url>] \
|
|
124
129
|
[--description <text>] \
|
|
125
130
|
[--epic <epic-id>] \
|
|
126
131
|
--multi-phase
|
|
@@ -170,6 +175,7 @@ repos:
|
|
|
170
175
|
branch: task/example
|
|
171
176
|
base: main
|
|
172
177
|
pr: null
|
|
178
|
+
status: open
|
|
173
179
|
---
|
|
174
180
|
```
|
|
175
181
|
|
|
@@ -189,6 +195,8 @@ output, including mutations, entity inspection, status, validation, and PR creat
|
|
|
189
195
|
- Multi-phase task frontmatter owns the phase dependency graph.
|
|
190
196
|
- Epic frontmatter owns the child-task dependency graph.
|
|
191
197
|
- `pr` is either a GitHub PR URL string or `null`.
|
|
198
|
+
- Execution-unit `status` is `open`, `working`, `done`, or `dropped`. New work
|
|
199
|
+
starts open, and `agency work` marks it working before agent launch.
|
|
192
200
|
- Keep directory IDs stable; encode sequencing with `dependsOn`, not numeric
|
|
193
201
|
directory prefixes.
|
|
194
202
|
- Do not use YAML duplicate keys, anchors, aliases, or custom tags.
|
|
@@ -196,6 +204,25 @@ output, including mutations, entity inspection, status, validation, and PR creat
|
|
|
196
204
|
Prefer Agency commands for creation. When manually editing dependencies or
|
|
197
205
|
prose, preserve backlinks and run validation immediately afterward.
|
|
198
206
|
|
|
207
|
+
Update execution status with:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
agency task status <task-id> <open|working|done|dropped>
|
|
211
|
+
agency phase status <task-id> <phase-id> <open|working|done|dropped>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Archive Completed Work
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
agency archive epic <epic-id>
|
|
218
|
+
agency archive task <task-id>
|
|
219
|
+
agency archive phase <task-id> <phase-id>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Use these commands instead of moving work item folders manually. Agency mirrors
|
|
223
|
+
their hierarchy under `archive/`, removes registered worktrees first, and keeps
|
|
224
|
+
branches. It refuses dirty worktrees and active sibling dependencies.
|
|
225
|
+
|
|
199
226
|
## Validate Every Structural Change
|
|
200
227
|
|
|
201
228
|
```bash
|
package/src/cli.test.ts
CHANGED
|
@@ -80,6 +80,7 @@ describe("CLI", () => {
|
|
|
80
80
|
["epic", "Usage: agency epic"],
|
|
81
81
|
["task", "Usage: agency task"],
|
|
82
82
|
["phase", "Usage: agency phase"],
|
|
83
|
+
["archive", "Usage: agency archive"],
|
|
83
84
|
["work", "Usage: agency work"],
|
|
84
85
|
["pr", "Usage: agency pr"],
|
|
85
86
|
["status", "Usage: agency status"],
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
captureLogs,
|
|
5
|
+
cleanupTempDir,
|
|
6
|
+
createTempDir,
|
|
7
|
+
runTestEffect,
|
|
8
|
+
} from "../test-utils"
|
|
9
|
+
import { archive } from "./archive"
|
|
10
|
+
import { task } from "./task"
|
|
11
|
+
|
|
12
|
+
describe("archive command", () => {
|
|
13
|
+
let root: string
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
root = await createTempDir()
|
|
17
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
18
|
+
const initialized = Bun.spawnSync([
|
|
19
|
+
"git",
|
|
20
|
+
"init",
|
|
21
|
+
"--bare",
|
|
22
|
+
join(root, "repos/agency"),
|
|
23
|
+
])
|
|
24
|
+
if (initialized.exitCode !== 0) {
|
|
25
|
+
throw new Error(new TextDecoder().decode(initialized.stderr))
|
|
26
|
+
}
|
|
27
|
+
await runTestEffect(
|
|
28
|
+
task({
|
|
29
|
+
subcommand: "create",
|
|
30
|
+
args: ["example"],
|
|
31
|
+
ticketUrl: "https://example.com/task",
|
|
32
|
+
repo: "agency",
|
|
33
|
+
branch: "task/example",
|
|
34
|
+
base: "main",
|
|
35
|
+
cwd: root,
|
|
36
|
+
silent: true,
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(async () => cleanupTempDir(root))
|
|
42
|
+
|
|
43
|
+
test("archives a task and outputs the result as JSON", async () => {
|
|
44
|
+
const logs = await captureLogs(() =>
|
|
45
|
+
runTestEffect(
|
|
46
|
+
archive({
|
|
47
|
+
type: "task",
|
|
48
|
+
args: ["example"],
|
|
49
|
+
cwd: root,
|
|
50
|
+
json: true,
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(JSON.parse(logs[0]!)).toEqual({
|
|
56
|
+
kind: "task",
|
|
57
|
+
id: "example",
|
|
58
|
+
path: join(root, "archive/tasks/example"),
|
|
59
|
+
archivedPaths: [join(root, "archive/tasks/example")],
|
|
60
|
+
removedWorktrees: [],
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("requires a supported work item type", async () => {
|
|
65
|
+
await expect(
|
|
66
|
+
runTestEffect(archive({ args: [], cwd: root, silent: true })),
|
|
67
|
+
).rejects.toThrow("Available: epic, task, phase")
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { ArchiveService } from "../services/ArchiveService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface ArchiveOptions extends BaseCommandOptions {
|
|
7
|
+
readonly type?: string
|
|
8
|
+
readonly args: readonly string[]
|
|
9
|
+
readonly json?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const archive = (options: ArchiveOptions) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const archives = yield* ArchiveService
|
|
15
|
+
const { log } = createLoggers(options)
|
|
16
|
+
const cwd = options.cwd ?? process.cwd()
|
|
17
|
+
const [id, phaseId] = options.args
|
|
18
|
+
|
|
19
|
+
let result
|
|
20
|
+
switch (options.type) {
|
|
21
|
+
case "epic":
|
|
22
|
+
if (!id) {
|
|
23
|
+
return yield* Effect.fail(
|
|
24
|
+
new Error("Usage: agency archive epic <epic-id>"),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
result = yield* archives.archiveEpic(id, cwd)
|
|
28
|
+
break
|
|
29
|
+
case "task":
|
|
30
|
+
if (!id) {
|
|
31
|
+
return yield* Effect.fail(
|
|
32
|
+
new Error("Usage: agency archive task <task-id>"),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
result = yield* archives.archiveTask(id, cwd)
|
|
36
|
+
break
|
|
37
|
+
case "phase":
|
|
38
|
+
if (!id || !phaseId) {
|
|
39
|
+
return yield* Effect.fail(
|
|
40
|
+
new Error("Usage: agency archive phase <task-id> <phase-id>"),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
result = yield* archives.archivePhase(id, phaseId, cwd)
|
|
44
|
+
break
|
|
45
|
+
default:
|
|
46
|
+
return yield* Effect.fail(
|
|
47
|
+
new Error("Work item type is required. Available: epic, task, phase"),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log(
|
|
52
|
+
options.json
|
|
53
|
+
? JSON.stringify(result, null, 2)
|
|
54
|
+
: `Archived ${result.kind} '${result.id}' to ${result.path}`,
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export const help = `
|
|
59
|
+
Usage: agency archive <type> <id>
|
|
60
|
+
|
|
61
|
+
Archive a work item after removing its worktrees. Branches are preserved.
|
|
62
|
+
|
|
63
|
+
Types:
|
|
64
|
+
epic <epic-id> Archive an epic and its tasks
|
|
65
|
+
task <task-id> Archive a task
|
|
66
|
+
phase <task-id> <phase-id> Archive a phase
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
--json Output results as JSON
|
|
70
|
+
`
|
|
@@ -36,6 +36,9 @@ describe("init command", () => {
|
|
|
36
36
|
expect(await Bun.file(join(root, "AGENTS.md")).text()).toContain(
|
|
37
37
|
"# Agency Workbase",
|
|
38
38
|
)
|
|
39
|
+
expect(
|
|
40
|
+
await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
|
|
41
|
+
).toContain('"path": "../tasks"')
|
|
39
42
|
})
|
|
40
43
|
|
|
41
44
|
test("preserves existing gitignore entries", async () => {
|
package/src/commands/phase.ts
CHANGED
|
@@ -90,9 +90,29 @@ export const phase = (options: PhaseOptions) =>
|
|
|
90
90
|
)
|
|
91
91
|
return
|
|
92
92
|
}
|
|
93
|
+
case "status": {
|
|
94
|
+
const status = options.args[2]
|
|
95
|
+
if (!taskId || !phaseId || !status) {
|
|
96
|
+
return yield* Effect.fail(
|
|
97
|
+
new Error(
|
|
98
|
+
"Usage: agency phase status <task-id> <phase-id> <status>",
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const record = yield* phases.setStatus(taskId, phaseId, status, cwd)
|
|
103
|
+
const { content: _, ...output } = record
|
|
104
|
+
log(
|
|
105
|
+
options.json
|
|
106
|
+
? JSON.stringify(output, null, 2)
|
|
107
|
+
: `Marked phase '${phaseId}' as ${record.data.status}`,
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
93
111
|
default:
|
|
94
112
|
return yield* Effect.fail(
|
|
95
|
-
new Error(
|
|
113
|
+
new Error(
|
|
114
|
+
"Subcommand is required. Available: create, list, show, status",
|
|
115
|
+
),
|
|
96
116
|
)
|
|
97
117
|
}
|
|
98
118
|
})
|
|
@@ -104,6 +124,8 @@ Subcommands:
|
|
|
104
124
|
create <task> <phase> Create a phase
|
|
105
125
|
list <task> List task phases
|
|
106
126
|
show <task> <phase> Show a phase
|
|
127
|
+
status <task> <phase> <status>
|
|
128
|
+
Set open, working, done, or dropped
|
|
107
129
|
|
|
108
130
|
Create options:
|
|
109
131
|
--description <text> Short description of the phase
|
|
@@ -95,6 +95,7 @@ describe("task and phase command JSON output", () => {
|
|
|
95
95
|
branch: "task/first",
|
|
96
96
|
base: "main",
|
|
97
97
|
pr: null,
|
|
98
|
+
status: "open",
|
|
98
99
|
},
|
|
99
100
|
},
|
|
100
101
|
])
|
|
@@ -118,6 +119,7 @@ describe("task and phase command JSON output", () => {
|
|
|
118
119
|
branch: "task/first",
|
|
119
120
|
base: "main",
|
|
120
121
|
pr: null,
|
|
122
|
+
status: "open",
|
|
121
123
|
},
|
|
122
124
|
})
|
|
123
125
|
})
|
|
@@ -156,10 +158,53 @@ describe("task and phase command JSON output", () => {
|
|
|
156
158
|
expect(JSON.parse(phaseLogs[0]!)).toMatchObject({
|
|
157
159
|
taskId: "another",
|
|
158
160
|
id: "first",
|
|
159
|
-
data: {
|
|
161
|
+
data: {
|
|
162
|
+
repo: "agency",
|
|
163
|
+
branch: "task/another-first",
|
|
164
|
+
base: "main",
|
|
165
|
+
status: "open",
|
|
166
|
+
},
|
|
160
167
|
})
|
|
161
168
|
})
|
|
162
169
|
|
|
170
|
+
test("sets task and phase status", async () => {
|
|
171
|
+
const phaseLogs = await captureLogs(() =>
|
|
172
|
+
runTestEffect(
|
|
173
|
+
phase({
|
|
174
|
+
subcommand: "status",
|
|
175
|
+
args: ["multi", "first", "done"],
|
|
176
|
+
cwd: root,
|
|
177
|
+
json: true,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("done")
|
|
182
|
+
|
|
183
|
+
await runTestEffect(
|
|
184
|
+
task({
|
|
185
|
+
subcommand: "create",
|
|
186
|
+
args: ["single-status"],
|
|
187
|
+
ticketUrl: "https://example.com/task",
|
|
188
|
+
repo: "agency",
|
|
189
|
+
branch: "task/single-status",
|
|
190
|
+
base: "main",
|
|
191
|
+
cwd: root,
|
|
192
|
+
silent: true,
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
const taskLogs = await captureLogs(() =>
|
|
196
|
+
runTestEffect(
|
|
197
|
+
task({
|
|
198
|
+
subcommand: "status",
|
|
199
|
+
args: ["single-status", "dropped"],
|
|
200
|
+
cwd: root,
|
|
201
|
+
json: true,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
expect(JSON.parse(taskLogs[0]!).data.status).toBe("dropped")
|
|
206
|
+
})
|
|
207
|
+
|
|
163
208
|
test("converts a single-phase task with an explicit first phase ID", async () => {
|
|
164
209
|
await runTestEffect(
|
|
165
210
|
task({
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { task, type TaskInteraction } from "./task"
|
|
7
|
+
|
|
8
|
+
describe("task creation input", () => {
|
|
9
|
+
let root: string
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
root = await createTempDir()
|
|
13
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
14
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => cleanupTempDir(root))
|
|
18
|
+
|
|
19
|
+
test("guides task creation and defaults branch metadata", async () => {
|
|
20
|
+
await mkdir(join(root, "epics/delivery"), { recursive: true })
|
|
21
|
+
await Bun.write(
|
|
22
|
+
join(root, "epics/delivery/EPIC.md"),
|
|
23
|
+
"---\nticketUrl: https://example.com/delivery\nrepos:\n - repo: agency\n ref: main\ntasks: []\n---\n\n# Delivery\n",
|
|
24
|
+
)
|
|
25
|
+
const textAnswers = ["guided-task", "", ""]
|
|
26
|
+
const prompts: string[] = []
|
|
27
|
+
const interaction: TaskInteraction = {
|
|
28
|
+
text: (prompt) => {
|
|
29
|
+
prompts.push(prompt)
|
|
30
|
+
return Effect.succeed(textAnswers.shift() ?? "")
|
|
31
|
+
},
|
|
32
|
+
select: (prompt, choices) => {
|
|
33
|
+
prompts.push(`${prompt}: ${choices.join(",")}`)
|
|
34
|
+
return Effect.succeed(
|
|
35
|
+
prompt === "Parent epic"
|
|
36
|
+
? "(none)"
|
|
37
|
+
: prompt === "Task type"
|
|
38
|
+
? "single-phase"
|
|
39
|
+
: "agency",
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await runTestEffect(
|
|
45
|
+
task(
|
|
46
|
+
{ subcommand: "new", args: [], cwd: root, silent: true },
|
|
47
|
+
interaction,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const content = await Bun.file(
|
|
52
|
+
join(root, "tasks/guided-task/TASK.md"),
|
|
53
|
+
).text()
|
|
54
|
+
expect(content).toContain("ticketUrl: null")
|
|
55
|
+
expect(content).toContain("repo: agency")
|
|
56
|
+
expect(content).toContain("branch: task/guided-task")
|
|
57
|
+
expect(content).toContain("base: main")
|
|
58
|
+
expect(prompts).toEqual([
|
|
59
|
+
"Task ID: ",
|
|
60
|
+
"Ticket URL (optional): ",
|
|
61
|
+
"Description (optional): ",
|
|
62
|
+
"Parent epic: (none),delivery",
|
|
63
|
+
"Task type: single-phase,multi-phase",
|
|
64
|
+
"Writable repository: agency",
|
|
65
|
+
])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("keeps scripted creation non-interactive and permits no ticket URL", async () => {
|
|
69
|
+
const interaction: TaskInteraction = {
|
|
70
|
+
text: () => Effect.fail(new Error("unexpected text prompt")),
|
|
71
|
+
select: () => Effect.fail(new Error("unexpected selection prompt")),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await runTestEffect(
|
|
75
|
+
task(
|
|
76
|
+
{
|
|
77
|
+
subcommand: "create",
|
|
78
|
+
args: ["scripted-task"],
|
|
79
|
+
repo: "agency",
|
|
80
|
+
cwd: root,
|
|
81
|
+
silent: true,
|
|
82
|
+
},
|
|
83
|
+
interaction,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const content = await Bun.file(
|
|
88
|
+
join(root, "tasks/scripted-task/TASK.md"),
|
|
89
|
+
).text()
|
|
90
|
+
expect(content).toContain("ticketUrl: null")
|
|
91
|
+
expect(content).toContain("branch: task/scripted-task")
|
|
92
|
+
expect(content).toContain("base: main")
|
|
93
|
+
})
|
|
94
|
+
})
|