@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.
@@ -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
- case "create": {
86
- const interactive = options.subcommand === "new" || !options.args[0]
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 (interactive) {
103
- if (options.ticketUrl === undefined) {
104
- ticketUrl =
105
- (yield* interaction.text("Ticket URL (optional): ")).trim() ||
106
- null
107
- }
108
- if (options.description === undefined) {
109
- description =
110
- (yield* interaction.text("Description (optional): ")).trim() ||
111
- undefined
112
- }
113
- if (options.epic === undefined) {
114
- const epicRecords = yield* epics.list(cwd)
115
- if (epicRecords.length > 0) {
116
- const none = "(none)"
117
- const selected = yield* interaction.select("Parent epic", [
118
- none,
119
- ...epicRecords.map((record) => record.id),
120
- ])
121
- if (selected === null) {
122
- return yield* Effect.fail(new Error("Task creation cancelled"))
123
- }
124
- epic = selected === none ? undefined : selected
125
- }
126
- }
127
- if (options.multiPhase === undefined) {
128
- const selected = yield* interaction.select("Task type", [
129
- "single-phase",
130
- "multi-phase",
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
- multiPhase = selected === "multi-phase"
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; omitted metadata uses defaults
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"),
@@ -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(startPath, log, pick)
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
 
@@ -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 root = yield* resolveWorkbase(startPath, log, pickBase)
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
  `
@@ -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,