@markjaquith/agency 2.7.2 → 2.7.3
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 +10 -1
- package/cli.ts +10 -98
- package/package.json +1 -1
- package/skills/agency/SKILL.md +6 -1
- package/src/cli-parser.test.ts +203 -0
- package/src/cli-parser.ts +555 -0
- package/src/cli.test.ts +26 -2
- package/src/commands/task.test.ts +43 -0
- package/src/commands/task.ts +75 -38
- package/src/commands/validate.test.ts +28 -0
- package/src/commands/validate.ts +10 -1
- package/src/commands/work.test.ts +48 -0
- package/src/commands/work.ts +13 -1
- package/src/utils/command.ts +2 -0
- package/src/workbase/workbase-choice.ts +8 -0
package/src/commands/task.ts
CHANGED
|
@@ -81,14 +81,16 @@ export const task = (
|
|
|
81
81
|
const cwd = options.cwd ?? process.cwd()
|
|
82
82
|
|
|
83
83
|
switch (options.subcommand) {
|
|
84
|
-
case "new":
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
case "new": {
|
|
85
|
+
if (options.inputAllowed === false) {
|
|
86
|
+
return yield* Effect.fail(
|
|
87
|
+
new Error(
|
|
88
|
+
"task new requires interactive input; use 'agency task create <id> --repo <alias>' for automation",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
87
92
|
const id =
|
|
88
|
-
options.args[0] ??
|
|
89
|
-
(interactive
|
|
90
|
-
? (yield* interaction.text("Task ID: ")).trim()
|
|
91
|
-
: undefined)
|
|
93
|
+
options.args[0] ?? (yield* interaction.text("Task ID: ")).trim()
|
|
92
94
|
if (!id) {
|
|
93
95
|
return yield* Effect.fail(new Error("Task ID is required"))
|
|
94
96
|
}
|
|
@@ -99,42 +101,39 @@ export const task = (
|
|
|
99
101
|
let multiPhase = options.multiPhase ?? false
|
|
100
102
|
let repo = options.repo
|
|
101
103
|
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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",
|
|
104
|
+
if (options.ticketUrl === undefined) {
|
|
105
|
+
ticketUrl =
|
|
106
|
+
(yield* interaction.text("Ticket URL (optional): ")).trim() || 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),
|
|
131
120
|
])
|
|
132
121
|
if (selected === null) {
|
|
133
122
|
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
134
123
|
}
|
|
135
|
-
|
|
124
|
+
epic = selected === none ? undefined : selected
|
|
136
125
|
}
|
|
137
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
|
+
}
|
|
138
137
|
|
|
139
138
|
if (!multiPhase && !repo) {
|
|
140
139
|
const records = yield* repositories.list(cwd)
|
|
@@ -177,6 +176,39 @@ export const task = (
|
|
|
177
176
|
)
|
|
178
177
|
return
|
|
179
178
|
}
|
|
179
|
+
case "create": {
|
|
180
|
+
const id = options.args[0]
|
|
181
|
+
if (!id) {
|
|
182
|
+
return yield* Effect.fail(new Error("Task ID is required"))
|
|
183
|
+
}
|
|
184
|
+
const multiPhase = options.multiPhase ?? false
|
|
185
|
+
if (!multiPhase && !options.repo) {
|
|
186
|
+
return yield* Effect.fail(
|
|
187
|
+
new Error("Writable repository is required for task create"),
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
const record = yield* tasks.create(
|
|
191
|
+
{
|
|
192
|
+
id,
|
|
193
|
+
ticketUrl: options.ticketUrl?.trim() || null,
|
|
194
|
+
description: options.description?.trim() || undefined,
|
|
195
|
+
epic: options.epic,
|
|
196
|
+
multiPhase,
|
|
197
|
+
repo: options.repo,
|
|
198
|
+
repos: parseRepositoryReferences(options.references),
|
|
199
|
+
branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
|
|
200
|
+
base: multiPhase ? undefined : (options.base ?? "main"),
|
|
201
|
+
},
|
|
202
|
+
cwd,
|
|
203
|
+
)
|
|
204
|
+
const { content: _, ...output } = record
|
|
205
|
+
log(
|
|
206
|
+
options.json
|
|
207
|
+
? JSON.stringify(output, null, 2)
|
|
208
|
+
: `Created task '${record.id}'`,
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
180
212
|
case "list": {
|
|
181
213
|
const records = yield* tasks.list(cwd)
|
|
182
214
|
if (options.json) {
|
|
@@ -234,7 +266,7 @@ Usage: agency task <subcommand>
|
|
|
234
266
|
|
|
235
267
|
Subcommands:
|
|
236
268
|
new [id] Create a task with guided input
|
|
237
|
-
create <id> Create a task
|
|
269
|
+
create <id> Create a task without prompting
|
|
238
270
|
list List tasks
|
|
239
271
|
show <id> Show a task
|
|
240
272
|
status <id> <status> Set open, working, delegated, done, or dropped
|
|
@@ -250,6 +282,11 @@ Create options:
|
|
|
250
282
|
--base <name> Base branch (default: main)
|
|
251
283
|
--multi-phase Create a task container for phases
|
|
252
284
|
|
|
285
|
+
Task creation is noninteractive. Single-phase tasks require --repo; use
|
|
286
|
+
--multi-phase instead for a task container. Guided input is available only
|
|
287
|
+
through task new, which fails when --no-input is set or no TTY is available.
|
|
288
|
+
|
|
253
289
|
Options:
|
|
254
290
|
--json Output results as JSON
|
|
291
|
+
--no-input Never open interactive task creation
|
|
255
292
|
`
|
|
@@ -108,6 +108,34 @@ pr: null
|
|
|
108
108
|
expect(discovered).toEqual(["/outside", "/selected"])
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
test("does not select a registered workbase when input is disabled", async () => {
|
|
112
|
+
const workbase = {
|
|
113
|
+
discover: () =>
|
|
114
|
+
Effect.fail({
|
|
115
|
+
_tag: "WorkbaseNotFoundError" as const,
|
|
116
|
+
message: "No Agency workbase found from /outside",
|
|
117
|
+
}),
|
|
118
|
+
listRegistered: () => Effect.succeed(["/selected"]),
|
|
119
|
+
}
|
|
120
|
+
const fs = {
|
|
121
|
+
runCommand: () => Effect.fail(new Error("unexpected fzf probe")),
|
|
122
|
+
}
|
|
123
|
+
const pick: PickWorkbase = () =>
|
|
124
|
+
Effect.fail(new Error("unexpected workbase selection"))
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
Effect.runPromise(
|
|
128
|
+
validate(
|
|
129
|
+
{ cwd: "/outside", silent: true, inputAllowed: false },
|
|
130
|
+
pick,
|
|
131
|
+
).pipe(
|
|
132
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
133
|
+
Effect.provideService(FileSystemService, fs as never),
|
|
134
|
+
) as Effect.Effect<void, unknown, never>,
|
|
135
|
+
),
|
|
136
|
+
).rejects.toThrow("provide an explicit path or run Agency from a workbase")
|
|
137
|
+
})
|
|
138
|
+
|
|
111
139
|
test("outputs the validation report as JSON", async () => {
|
|
112
140
|
await Bun.write(
|
|
113
141
|
join(root, "tasks/example/TASK.md"),
|
package/src/commands/validate.ts
CHANGED
|
@@ -27,7 +27,12 @@ export const validate = (
|
|
|
27
27
|
const startPath = options.path ?? options.cwd ?? process.cwd()
|
|
28
28
|
const root = options.path
|
|
29
29
|
? yield* workbase.discover(startPath)
|
|
30
|
-
: yield* resolveWorkbase(
|
|
30
|
+
: yield* resolveWorkbase(
|
|
31
|
+
startPath,
|
|
32
|
+
log,
|
|
33
|
+
pick,
|
|
34
|
+
options.inputAllowed ?? true,
|
|
35
|
+
)
|
|
31
36
|
if (!root) return
|
|
32
37
|
const report = yield* workbase.validate(root)
|
|
33
38
|
|
|
@@ -61,4 +66,8 @@ graphs. The current or selected registered workbase is used when path is omitted
|
|
|
61
66
|
|
|
62
67
|
Options:
|
|
63
68
|
--json Output the validation report as JSON
|
|
69
|
+
--no-input Never open an interactive workbase selector
|
|
70
|
+
|
|
71
|
+
When interactive input is unavailable, pass a path or run this command from a
|
|
72
|
+
workbase. Registered-workbase selection otherwise fails.
|
|
64
73
|
`
|
|
@@ -329,6 +329,36 @@ describe("work command", () => {
|
|
|
329
329
|
expect(harness.launches[0]?.cwd).toBe(multiPhaseWorkspace.writablePath)
|
|
330
330
|
})
|
|
331
331
|
|
|
332
|
+
test("requires an explicit target when input is disabled", async () => {
|
|
333
|
+
const harness = createHarness()
|
|
334
|
+
|
|
335
|
+
await expect(
|
|
336
|
+
harness.run({
|
|
337
|
+
cwd: "/workbase",
|
|
338
|
+
opencode: true,
|
|
339
|
+
inputAllowed: false,
|
|
340
|
+
}),
|
|
341
|
+
).rejects.toThrow("provide a directory, task ID, or --epic")
|
|
342
|
+
expect(harness.events).toEqual([])
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test("runs an explicit target when input is disabled", async () => {
|
|
346
|
+
const harness = createHarness({ existingDirectories: [] })
|
|
347
|
+
|
|
348
|
+
await harness.run({
|
|
349
|
+
cwd: "/workbase",
|
|
350
|
+
directory: "example",
|
|
351
|
+
opencode: true,
|
|
352
|
+
inputAllowed: false,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
expect(harness.events).toEqual([
|
|
356
|
+
"materialize",
|
|
357
|
+
"probe:opencode",
|
|
358
|
+
"launch:opencode",
|
|
359
|
+
])
|
|
360
|
+
})
|
|
361
|
+
|
|
332
362
|
test("selects a registered workbase when local discovery fails", async () => {
|
|
333
363
|
const harness = createHarness({
|
|
334
364
|
outsideWorkbase: true,
|
|
@@ -355,6 +385,24 @@ describe("work command", () => {
|
|
|
355
385
|
])
|
|
356
386
|
})
|
|
357
387
|
|
|
388
|
+
test("does not select a registered workbase when input is disabled", async () => {
|
|
389
|
+
const harness = createHarness({
|
|
390
|
+
outsideWorkbase: true,
|
|
391
|
+
registeredWorkbases: ["/workbase"],
|
|
392
|
+
existingDirectories: [],
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
await expect(
|
|
396
|
+
harness.run({
|
|
397
|
+
cwd: "/outside",
|
|
398
|
+
directory: "example",
|
|
399
|
+
opencode: true,
|
|
400
|
+
inputAllowed: false,
|
|
401
|
+
}),
|
|
402
|
+
).rejects.toThrow("provide an explicit path or run Agency from a workbase")
|
|
403
|
+
expect(harness.events).toEqual([])
|
|
404
|
+
})
|
|
405
|
+
|
|
358
406
|
test("explains how to register a workbase when none are known", async () => {
|
|
359
407
|
const harness = createHarness({ outsideWorkbase: true })
|
|
360
408
|
|
package/src/commands/work.ts
CHANGED
|
@@ -84,7 +84,8 @@ export const work = (
|
|
|
84
84
|
? yield* fs.isDirectory(directoryPath)
|
|
85
85
|
: false
|
|
86
86
|
const startPath = isDirectory && directoryPath ? directoryPath : cwd
|
|
87
|
-
const
|
|
87
|
+
const inputAllowed = options.inputAllowed ?? true
|
|
88
|
+
const root = yield* resolveWorkbase(startPath, log, pickBase, inputAllowed)
|
|
88
89
|
if (!root) return
|
|
89
90
|
|
|
90
91
|
let target: WorkTarget | null = null
|
|
@@ -148,6 +149,13 @@ export const work = (
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
if (!target) {
|
|
152
|
+
if (!inputAllowed) {
|
|
153
|
+
return yield* Effect.fail(
|
|
154
|
+
new Error(
|
|
155
|
+
"Work target selection requires interactive input; provide a directory, task ID, or --epic <id>",
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
151
159
|
const epicRecords = yield* epics.list(root)
|
|
152
160
|
const taskRecords = yield* tasks.list(root)
|
|
153
161
|
const phaseRecords = []
|
|
@@ -247,4 +255,8 @@ Options:
|
|
|
247
255
|
--epic <id> Work on an epic
|
|
248
256
|
--opencode Require OpenCode
|
|
249
257
|
--claude Require Claude Code
|
|
258
|
+
--no-input Never open an interactive selector
|
|
259
|
+
|
|
260
|
+
Without interactive input, provide a directory, task ID, or --epic and run the
|
|
261
|
+
command from a workbase. Workbase and target selection otherwise fail.
|
|
250
262
|
`
|
package/src/utils/command.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface BaseCommandOptions {
|
|
|
5
5
|
readonly silent?: boolean
|
|
6
6
|
readonly verbose?: boolean
|
|
7
7
|
readonly json?: boolean
|
|
8
|
+
/** Whether this invocation may open prompts or selectors. */
|
|
9
|
+
readonly inputAllowed?: boolean
|
|
8
10
|
/**
|
|
9
11
|
* Working directory to use instead of process.cwd().
|
|
10
12
|
* Primarily used for testing to enable concurrent test execution.
|
|
@@ -34,6 +34,7 @@ export const resolveWorkbase = (
|
|
|
34
34
|
startPath: string,
|
|
35
35
|
log: (message: string) => void,
|
|
36
36
|
pick: PickWorkbase = pickWorkbase,
|
|
37
|
+
inputAllowed = true,
|
|
37
38
|
) =>
|
|
38
39
|
Effect.gen(function* () {
|
|
39
40
|
const fs = yield* FileSystemService
|
|
@@ -50,6 +51,13 @@ export const resolveWorkbase = (
|
|
|
50
51
|
),
|
|
51
52
|
)
|
|
52
53
|
}
|
|
54
|
+
if (!inputAllowed) {
|
|
55
|
+
return yield* Effect.fail(
|
|
56
|
+
new Error(
|
|
57
|
+
"Workbase selection requires interactive input; provide an explicit path or run Agency from a workbase",
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
53
61
|
|
|
54
62
|
const fzf = yield* fs.runCommand(["which", "fzf"], {
|
|
55
63
|
captureOutput: true,
|