@markjaquith/agency 3.5.1 → 3.6.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.
@@ -1,536 +1,702 @@
1
+ import { Schema } from "@effect/schema"
1
2
  import { Effect } from "effect"
2
- import { isAbsolute, relative, resolve, sep } from "node:path"
3
- import type { GraphNode } from "../graph-schema"
4
- import { isTerminalStatus } from "../readiness"
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
4
+ import { ActDiscovery } from "../act-schema"
5
5
  import { FileSystemService } from "../services/FileSystemService"
6
6
  import { GraphService } from "../services/GraphService"
7
7
  import { WorkbaseService } from "../services/WorkbaseService"
8
8
  import type { BaseCommandOptions } from "../utils/command"
9
- import { choose, type Choice } from "../utils/chooser"
9
+ import type { Choice } from "../utils/chooser"
10
10
  import { createLoggers } from "../utils/effect"
11
11
  import { macchiato } from "../utils/theme"
12
- import { archive as archiveCommand } from "./archive"
13
- import { phase as phaseCommand } from "./phase"
14
- import { prCreate as createPullRequest } from "./pr"
15
- import { task as taskCommand } from "./task"
12
+ import { workKindStyle, workStatusStyle } from "../workbase/work-target"
13
+ import {
14
+ actionGroups,
15
+ actActions,
16
+ actionOutput,
17
+ type ActAction,
18
+ type ActEntity,
19
+ } from "./act-actions"
20
+ import {
21
+ ActCancelled,
22
+ actionPrompts,
23
+ wizardInputs,
24
+ actTabs,
25
+ defaultInteraction,
26
+ openActSession,
27
+ type ActInteraction,
28
+ } from "./act-prompts"
16
29
  import { work as startWork, type StartWork } from "./work"
17
30
 
18
- type EntityNode = Extract<
19
- GraphNode,
20
- { readonly kind: "epic" | "task" | "phase" }
21
- >
22
-
23
- type ActAction = "work" | "pr" | "reopen" | "drop" | "archive"
24
-
25
- export interface ActInteraction {
26
- readonly select: <T>(
27
- prompt: string,
28
- choices: readonly Choice<T>[],
29
- command?: readonly string[],
30
- ) => Effect.Effect<T | null, Error>
31
- }
31
+ export type { ActInteraction } from "./act-prompts"
32
32
 
33
33
  interface ActOptions extends BaseCommandOptions {
34
+ readonly action?: string
34
35
  readonly directory?: string
35
36
  readonly auto?: boolean
36
37
  readonly draft?: boolean
37
38
  readonly dryRun?: boolean
38
- readonly json?: boolean
39
39
  readonly epicId?: string
40
40
  readonly taskId?: string
41
41
  readonly phaseId?: string
42
- }
43
-
44
- const defaultInteraction: ActInteraction = {
45
- select: (prompt, choices, command) => choose(prompt, choices, command),
46
- }
47
-
48
- const entityKey = (node: EntityNode) => `${node.kind}:${node.key}`
49
-
50
- const entityDescription = (node: EntityNode) =>
51
- "description" in node.data && node.data.description
52
- ? ` - ${node.data.description}`
53
- : ""
54
-
55
- const orderedEntities = (
56
- nodes: readonly EntityNode[],
57
- edges: readonly {
58
- readonly kind: string
59
- readonly from: string
60
- readonly to: string
61
- }[],
62
- ) => {
63
- const byId = new Map(nodes.map((node) => [node.id, node]))
64
- const children = new Map<string, EntityNode[]>()
65
- const owned = new Set<string>()
66
- for (const edge of edges) {
67
- if (edge.kind !== "owns") continue
68
- const parent = byId.get(edge.from)
69
- const child = byId.get(edge.to)
70
- if (!parent || !child) continue
71
- children.set(edge.from, [...(children.get(edge.from) ?? []), child])
72
- owned.add(child.id)
73
- }
74
-
75
- const ordered: { readonly node: EntityNode; readonly depth: number }[] = []
76
- const append = (node: EntityNode, depth: number) => {
77
- ordered.push({ node, depth })
78
- for (const child of children.get(node.id) ?? []) append(child, depth + 1)
79
- }
80
- for (const node of nodes) {
81
- if (!owned.has(node.id)) append(node, 0)
82
- }
83
- return ordered
42
+ readonly exitOnEscape?: boolean
84
43
  }
85
44
 
86
45
  const entityChoices = (
87
- nodes: readonly EntityNode[],
88
- edges: readonly {
89
- readonly kind: string
90
- readonly from: string
91
- readonly to: string
92
- }[],
93
- ): readonly Choice<string>[] =>
94
- orderedEntities(nodes, edges).map(({ node, depth }, index) => ({
95
- key: String(index),
96
- label: `[${node.status}] ${node.kind} ${node.key}${entityDescription(node)}`,
97
- depth,
98
- segments: [
99
- { text: `[${node.status}] `, color: macchiato.overlay1 },
100
- { text: `${node.kind} `, color: macchiato.sapphire },
101
- { text: node.key },
102
- ...(entityDescription(node)
103
- ? [{ text: entityDescription(node), color: macchiato.overlay0 }]
104
- : []),
105
- ],
106
- value: entityKey(node),
107
- }))
108
-
109
- const executionNode = (
110
- node: EntityNode,
111
- executions: ReadonlyMap<
112
- string,
113
- Extract<GraphNode, { readonly kind: "execution-unit" }>
114
- >,
115
- ): Extract<GraphNode, { readonly kind: "execution-unit" }> | undefined => {
116
- if (node.kind === "epic") return undefined
117
- if (node.kind === "task" && "phases" in node.data) return undefined
118
- return executions.get(node.id)
119
- }
120
-
121
- const canWork = (
122
- node: EntityNode,
123
- executions: Parameters<typeof executionNode>[1],
124
- ) => {
125
- if (node.kind === "epic" || (node.kind === "task" && "phases" in node.data)) {
126
- return node.readiness.ready
127
- }
128
- const execution = executionNode(node, executions)
129
- return Boolean(
130
- execution &&
131
- (execution.readiness.ready ||
132
- (execution.status === "working" &&
133
- execution.readiness.blockers.every(
134
- (blocker) => blocker.kind !== "validation",
135
- ))),
136
- )
137
- }
138
-
139
- const canCreatePr = (
140
- node: EntityNode,
141
- executions: Parameters<typeof executionNode>[1],
142
- ) => {
143
- const execution = executionNode(node, executions)
144
- return Boolean(
145
- execution &&
146
- !execution.readiness.terminal &&
147
- !("pr" in execution.data && execution.data.pr) &&
148
- !execution.readiness.blockers.some(
149
- (blocker) =>
150
- blocker.kind === "dependency" || blocker.kind === "validation",
151
- ),
46
+ nodes: readonly ActEntity[],
47
+ twoRows = false,
48
+ ): Choice<string>[] => {
49
+ const name = (node: ActEntity) =>
50
+ node.kind === "phase"
51
+ ? node.key.slice(node.key.lastIndexOf("/") + 1)
52
+ : node.key
53
+ const nameWidth = Math.min(
54
+ 32,
55
+ Math.max(0, ...nodes.map((node) => name(node).length)),
152
56
  )
153
- }
154
-
155
- const actionChoices = (
156
- node: EntityNode,
157
- executions: Parameters<typeof executionNode>[1],
158
- ): readonly Choice<ActAction>[] => {
159
- const choices: Choice<ActAction>[] = []
160
- const execution = executionNode(node, executions)
161
- if (canWork(node, executions)) {
162
- choices.push({ key: "work", label: "Work on this item", value: "work" })
163
- }
164
- if (canCreatePr(node, executions)) {
165
- choices.push({ key: "pr", label: "Create pull request", value: "pr" })
166
- }
167
- if (execution && isTerminalStatus(node.status)) {
168
- choices.push({ key: "reopen", label: "Reopen", value: "reopen" })
169
- }
170
- if (execution && !isTerminalStatus(node.status)) {
171
- choices.push({ key: "drop", label: "Drop", value: "drop" })
172
- }
173
- if (node.readiness.terminal) {
174
- choices.push({ key: "archive", label: "Archive", value: "archive" })
175
- }
176
- return choices
177
- }
178
-
179
- const entityParts = (node: EntityNode) => {
180
- if (node.kind !== "phase") return { taskId: node.key }
181
- const separator = node.key.indexOf("/")
182
- return {
183
- taskId: node.key.slice(0, separator),
184
- phaseId: node.key.slice(separator + 1),
185
- }
186
- }
187
-
188
- const actionCommand = (
189
- node: EntityNode,
190
- action: ActAction,
191
- options: Pick<ActOptions, "auto" | "draft">,
192
- ): readonly string[] => {
193
- const { taskId, phaseId } = entityParts(node)
194
- switch (action) {
195
- case "work":
196
- return [
197
- "agency",
198
- "work",
199
- ...(node.kind === "epic"
200
- ? ["--epic", node.key]
201
- : ["--task", taskId, ...(phaseId ? ["--phase", phaseId] : [])]),
202
- ...(options.auto ? ["--auto"] : []),
203
- ]
204
- case "pr":
205
- return [
206
- "agency",
207
- "pr",
208
- "create",
209
- taskId,
210
- ...(phaseId ? [phaseId] : []),
211
- ...(options.draft ? ["--draft"] : []),
57
+ const shorten = (text: string, width: number) =>
58
+ text.length > width ? `${text.slice(0, width - 1)}…` : text
59
+ return nodes
60
+ .toSorted((a, b) => a.key.localeCompare(b.key))
61
+ .map((node) => {
62
+ const description = (
63
+ "description" in node.data ? node.data.description : ""
64
+ )
65
+ ?.replace(/\s+/g, " ")
66
+ .trim()
67
+ const parent =
68
+ node.kind === "phase"
69
+ ? node.key.slice(0, node.key.lastIndexOf("/"))
70
+ : undefined
71
+ const blocked = node.readiness.blockers.some(
72
+ (blocker) =>
73
+ blocker.kind === "dependency" || blocker.kind === "validation",
74
+ )
75
+ const state = blocked
76
+ ? { icon: "󰀦", color: macchiato.yellow }
77
+ : workStatusStyle[node.status]
78
+ const segments = [
79
+ {
80
+ text: `${workKindStyle[node.kind].icon} `,
81
+ color: workKindStyle[node.kind].color,
82
+ },
83
+ {
84
+ text: shorten(name(node), nameWidth).padEnd(nameWidth),
85
+ color: macchiato.text,
86
+ },
87
+ {
88
+ text: ` ${state.icon} ${(blocked ? "blocked" : node.status).padEnd(9)}`,
89
+ color: state.color,
90
+ },
91
+ ...(parent
92
+ ? [{ text: ` in ${shorten(parent, 24)}`, color: macchiato.subtext0 }]
93
+ : []),
94
+ ...(node.repositories.length
95
+ ? [
96
+ {
97
+ text: `  ${node.repositories.join(", ")}`,
98
+ color: macchiato.overlay1,
99
+ },
100
+ ]
101
+ : []),
102
+ ...(description
103
+ ? [{ text: ` — ${description}`, color: macchiato.overlay0 }]
104
+ : []),
212
105
  ]
213
- case "reopen":
214
- case "drop": {
215
- const status = action === "reopen" ? "open" : "dropped"
216
- return phaseId
217
- ? ["agency", "phase", "status", taskId, phaseId, status]
218
- : ["agency", "task", "status", taskId, status]
219
- }
220
- case "archive":
221
- return node.kind === "phase"
222
- ? ["agency", "archive", "phase", taskId, phaseId!]
223
- : ["agency", "archive", node.kind, node.key]
224
- }
106
+ return {
107
+ key: node.id,
108
+ value: node.id,
109
+ segments,
110
+ ...(twoRows
111
+ ? {
112
+ details: {
113
+ title: [
114
+ {
115
+ text: `${workKindStyle[node.kind].icon} `,
116
+ color: workKindStyle[node.kind].color,
117
+ },
118
+ { text: node.key, color: macchiato.text },
119
+ ],
120
+ metadata: [
121
+ {
122
+ text: ` ${"repo" in node.data ? node.data.repo : (node.repositories[0] ?? "—")} `,
123
+ color: macchiato.overlay1,
124
+ },
125
+ {
126
+ text: `${state.icon} ${blocked ? "blocked" : node.status}`,
127
+ color: state.color,
128
+ },
129
+ ],
130
+ description: description ?? "",
131
+ },
132
+ }
133
+ : {}),
134
+ label: segments.map((segment) => segment.text).join(""),
135
+ plainLabel: `[${node.status}] ${node.kind} ${node.key}${blocked ? " blocked" : ""} ${node.repositories.join(" ")}${description ? ` — ${description}` : ""}`,
136
+ }
137
+ })
225
138
  }
226
139
 
227
- const shellCommand = (command: readonly string[]) =>
228
- command
229
- .map((argument) =>
230
- /^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
231
- ? argument
232
- : `'${argument.replaceAll("'", `'\\''`)}'`,
140
+ const pathEntityKey = (root: string, path: string) => {
141
+ const local = relative(root, path)
142
+ const parts =
143
+ !local || isAbsolute(local) || local.startsWith(`..${sep}`)
144
+ ? []
145
+ : local.split(sep)
146
+ if (parts[0] === "epics" && parts[1]) return `epic:${parts[1]}`
147
+ if (parts[0] !== "tasks" || !parts[1]) return undefined
148
+ return parts[2] === "phases" && parts[3]
149
+ ? `phase:${parts[1]}/${parts[3]}`
150
+ : `task:${parts[1]}`
151
+ }
152
+ const shellCommand = (argv: readonly string[]) =>
153
+ argv
154
+ .map((arg) =>
155
+ /^[A-Za-z0-9_./:=+@%-]+$/.test(arg)
156
+ ? arg
157
+ : `'${arg.replaceAll("'", `'\\''`)}'`,
233
158
  )
234
159
  .join(" ")
235
-
236
- const targetOutput = (
237
- node: EntityNode,
238
- executions: Parameters<typeof executionNode>[1],
239
- options: Pick<ActOptions, "auto" | "draft">,
240
- ) => ({
160
+ const available = (action: ActAction) => !action.blockedReason
161
+ const entitySummary = (node: ActEntity) => ({
241
162
  kind: node.kind,
242
163
  id: node.id,
243
164
  key: node.key,
244
165
  status: node.status,
166
+ description: "description" in node.data ? node.data.description : undefined,
167
+ repo: "repo" in node.data ? node.data.repo : undefined,
168
+ repositories: node.repositories,
245
169
  readiness: node.readiness,
246
170
  revision: node.data.sha256,
247
- actions: actionChoices(node, executions).map((choice) => ({
248
- id: choice.value,
249
- label: choice.label,
250
- command: actionCommand(node, choice.value, options),
251
- })),
252
171
  })
253
-
254
- const executionNodes = (nodes: readonly GraphNode[]) => {
255
- const executions = new Map<
256
- string,
257
- Extract<GraphNode, { readonly kind: "execution-unit" }>
258
- >()
259
- for (const node of nodes) {
260
- if (node.kind !== "execution-unit") continue
261
- const key =
262
- "phaseId" in node.data
263
- ? `phase:${node.data.taskId}/${node.data.phaseId}`
264
- : `task:${node.data.taskId}`
265
- executions.set(key, node)
266
- }
267
- return executions
172
+ const actionIcons: Record<string, string> = {
173
+ reopen: "󰑓",
174
+ drop: "󰅖",
175
+ complete: "󰄬",
176
+ sync: "󰑓",
177
+ "pr-ready": "󰄬",
178
+ "pr-close": "",
268
179
  }
180
+ const actionChoices = (actions: readonly ActAction[]): Choice<string>[] =>
181
+ actions.map(({ id, label }) => {
182
+ const icon =
183
+ actionIcons[id] ??
184
+ actionGroups.find((group) =>
185
+ group.actions.some((action) => action === id),
186
+ )!.icon
187
+ const color =
188
+ id === "drop" || id === "pr-close" ? macchiato.red : macchiato.overlay1
189
+ return {
190
+ key: id,
191
+ value: id,
192
+ label: `${icon} ${label}`,
193
+ plainLabel: label,
194
+ segments: [{ text: `${icon} `, color }, { text: label }],
195
+ }
196
+ })
269
197
 
270
- const selectedEntityKey = (options: ActOptions) =>
271
- options.epicId
272
- ? `epic:${options.epicId}`
273
- : options.phaseId
274
- ? `phase:${options.taskId}/${options.phaseId}`
275
- : options.taskId
276
- ? `task:${options.taskId}`
277
- : undefined
278
-
279
- const pathEntityKey = (
280
- directory: string | undefined,
281
- isDirectory: boolean,
282
- root: string,
283
- startPath: string,
284
- ) => {
285
- if (!directory) return undefined
286
- if (!isDirectory) return `task:${directory}`
287
- const path = relative(root, startPath)
288
- const parts =
289
- !path || isAbsolute(path) || path.startsWith(`..${sep}`)
290
- ? []
291
- : path.split(sep)
292
- if (parts[0] === "epics" && parts[1]) return `epic:${parts[1]}`
293
- if (parts[0] !== "tasks" || !parts[1]) return undefined
294
- return parts[2] === "phases" && parts[3]
295
- ? `phase:${parts[1]}/${parts[3]}`
296
- : `task:${parts[1]}`
198
+ interface ActState {
199
+ session?: Effect.Effect.Success<ReturnType<typeof openActSession>>
200
+ recap: string[]
201
+ root?: string
202
+ view: "home" | "item-menu" | "goal-menu" | "flow"
203
+ native: boolean
204
+ notice: string
205
+ item?: string
206
+ goal?: string
297
207
  }
298
208
 
299
- const sameActions = (
300
- left: readonly Choice<ActAction>[],
301
- right: readonly Choice<ActAction>[],
302
- ) =>
303
- left.map((choice) => choice.value).join("\0") ===
304
- right.map((choice) => choice.value).join("\0")
305
-
306
209
  export const act = (
307
210
  options: ActOptions = {},
308
211
  interaction: ActInteraction = defaultInteraction,
309
212
  work: StartWork = startWork,
310
213
  ) =>
311
214
  Effect.gen(function* () {
312
- if (!options.json && options.inputAllowed === false) {
215
+ const state: ActState = {
216
+ recap: [],
217
+ view: "home",
218
+ native: false,
219
+ notice: "",
220
+ }
221
+ let nextOptions = options
222
+ const { log } = createLoggers(options)
223
+ yield* Effect.gen(function* () {
224
+ while (true) {
225
+ state.view = "home"
226
+ const keepGoing = yield* actStep(
227
+ nextOptions,
228
+ interaction,
229
+ work,
230
+ state,
231
+ ).pipe(
232
+ Effect.tap(() =>
233
+ Effect.sync(() => {
234
+ state.goal = undefined
235
+ }),
236
+ ),
237
+ Effect.as(true),
238
+ Effect.catchAll((error) => {
239
+ if (error instanceof ActCancelled) {
240
+ if (state.view === "item-menu") state.item = undefined
241
+ if (state.view === "goal-menu") state.goal = undefined
242
+ return Effect.succeed(
243
+ state.view !== "home" || options.exitOnEscape === false,
244
+ )
245
+ }
246
+ if (!state.native || state.view === "home")
247
+ return Effect.fail(error)
248
+ state.goal = undefined
249
+ state.notice = `󰅖 ${error instanceof Error ? error.message : String(error)}`
250
+ return Effect.succeed(true)
251
+ }),
252
+ )
253
+ if (!state.native || !keepGoing || state.session?.quitRequested) break
254
+ state.session?.resetCancellation()
255
+ if (state.session?.takeNavigation()) {
256
+ state.item = undefined
257
+ state.goal = undefined
258
+ }
259
+ state.session?.notice(state.notice)
260
+ nextOptions = {
261
+ ...options,
262
+ cwd: state.root,
263
+ directory: undefined,
264
+ epicId: undefined,
265
+ taskId: undefined,
266
+ phaseId: undefined,
267
+ action: undefined,
268
+ ...(state.item?.startsWith("phase:")
269
+ ? {
270
+ taskId: state.item.slice(6).split("/")[0],
271
+ phaseId: state.item.slice(6).split("/")[1],
272
+ }
273
+ : state.item?.startsWith("task:")
274
+ ? { taskId: state.item.slice(5) }
275
+ : state.item?.startsWith("epic:")
276
+ ? { epicId: state.item.slice(5) }
277
+ : {}),
278
+ }
279
+ }
280
+ }).pipe(
281
+ Effect.scoped,
282
+ Effect.ensuring(
283
+ Effect.sync(() => {
284
+ if (state.recap.length)
285
+ log(
286
+ `\n Agency\n\n${state.recap.map((line) => ` ${line}`).join("\n")}\n`,
287
+ )
288
+ }),
289
+ ),
290
+ )
291
+ })
292
+
293
+ const actStep = (
294
+ options: ActOptions,
295
+ interaction: ActInteraction,
296
+ work: StartWork,
297
+ state: ActState,
298
+ ) =>
299
+ Effect.gen(function* () {
300
+ if (!options.json && options.inputAllowed === false)
313
301
  return yield* Effect.fail(
314
302
  new Error(
315
303
  "agency act requires interactive input; use --json to list actions for automation",
316
304
  ),
317
305
  )
318
- }
319
- const cwd = options.cwd ?? process.cwd()
320
306
  const fs = yield* FileSystemService
321
307
  const workbase = yield* WorkbaseService
322
308
  const graphs = yield* GraphService
323
309
  const { log } = createLoggers(options)
324
- const directoryPath = options.directory
325
- ? resolve(cwd, options.directory)
326
- : undefined
327
- const isDirectory = directoryPath
328
- ? yield* fs.isDirectory(directoryPath)
329
- : false
330
- const startPath = isDirectory && directoryPath ? directoryPath : cwd
331
- const { root, config } = yield* workbase.loadConfig(startPath)
310
+ const cwd = options.cwd ?? process.cwd()
311
+ const path = resolve(cwd, options.directory ?? ".")
312
+ const isDirectory = yield* fs.isDirectory(path)
313
+ const isPath =
314
+ isDirectory || Boolean(options.directory && (yield* fs.exists(path)))
315
+ const { root, config } = yield* workbase.loadConfig(
316
+ isDirectory ? path : isPath ? dirname(path) : cwd,
317
+ )
318
+ state.root = root
332
319
  const graph = yield* graphs.get({
333
320
  cwd: root,
334
321
  loadedConfig: { root, config },
335
322
  })
336
- const executions = executionNodes(graph.nodes)
337
323
  const nodes = graph.nodes.filter(
338
- (node): node is EntityNode =>
324
+ (node): node is ActEntity =>
339
325
  node.kind === "epic" || node.kind === "task" || node.kind === "phase",
340
326
  )
341
- if (nodes.length === 0) {
342
- if (options.json) {
343
- log(JSON.stringify({ targets: [] }, null, 2))
344
- return
345
- }
327
+ const recap = state.recap
328
+ const session =
329
+ state.session ??
330
+ (!options.json &&
331
+ interaction === defaultInteraction &&
332
+ !config.chooserCommand &&
333
+ process.stdin.isTTY &&
334
+ process.stdout.isTTY
335
+ ? yield* Effect.acquireRelease(openActSession(), (session) =>
336
+ session.close(),
337
+ )
338
+ : undefined)
339
+ state.session = session
340
+ state.native = Boolean(session)
341
+ session?.notice(state.notice)
342
+ const ui = session?.interaction ?? interaction
343
+ const nativeOptions = {
344
+ cwd: root,
345
+ auto: options.auto,
346
+ draft: options.draft,
347
+ inputAllowed: options.inputAllowed,
348
+ silent: session ? true : options.silent,
349
+ verbose: options.verbose,
350
+ }
351
+ const runWork: StartWork = session
352
+ ? (args) => work({ ...args, silent: options.silent })
353
+ : work
354
+ const globals = actActions(graph.nodes, nativeOptions, runWork)
355
+ const catalog = new Map(
356
+ nodes.map((node) => [
357
+ node.id,
358
+ actActions(graph.nodes, nativeOptions, runWork, node),
359
+ ]),
360
+ )
361
+ let selectedKey = options.epicId
362
+ ? `epic:${options.epicId}`
363
+ : options.phaseId
364
+ ? `phase:${options.taskId}/${options.phaseId}`
365
+ : options.taskId
366
+ ? `task:${options.taskId}`
367
+ : options.directory
368
+ ? isPath
369
+ ? pathEntityKey(root, path)
370
+ : `task:${options.directory}`
371
+ : undefined
372
+ let actionId = options.action
373
+ if (session && options.directory && isPath && !selectedKey && !actionId)
374
+ session.activateTab("workbase")
375
+ state.view = !selectedKey && !actionId ? "home" : "flow"
376
+ if (
377
+ actionId &&
378
+ !actionGroups.some((group) => group.actions.some((id) => id === actionId))
379
+ )
346
380
  return yield* Effect.fail(
347
- new Error("No active work items found in this workbase"),
381
+ new Error(
382
+ `Unknown action '${actionId}'; use agency act --json to discover actions`,
383
+ ),
348
384
  )
349
- }
350
-
351
- const requestedKey =
352
- selectedEntityKey(options) ??
353
- pathEntityKey(options.directory, isDirectory, root, startPath)
354
- const matchingNodes = requestedKey
355
- ? nodes.filter((node) => entityKey(node) === requestedKey)
356
- : nodes
357
- if (requestedKey && matchingNodes.length === 0) {
385
+ const globalAction = globals.find((action) => action.id === actionId)
386
+ if (selectedKey && globalAction)
358
387
  return yield* Effect.fail(
359
- new Error(`Selected work item '${requestedKey}' was not found`),
388
+ new Error("Workbase actions do not accept an item selector"),
360
389
  )
390
+ if (
391
+ selectedKey &&
392
+ !catalog.has(selectedKey) &&
393
+ state.item === selectedKey
394
+ ) {
395
+ state.item = undefined
396
+ selectedKey = undefined
397
+ state.view = "home"
361
398
  }
399
+ if (selectedKey && !catalog.has(selectedKey))
400
+ return yield* Effect.fail(
401
+ new Error(`Selected work item '${selectedKey}' was not found`),
402
+ )
362
403
  if (options.json) {
404
+ const matches = (action: ActAction) => !actionId || action.id === actionId
363
405
  log(
364
406
  JSON.stringify(
365
- {
366
- targets: matchingNodes.map((node) =>
367
- targetOutput(node, executions, options),
368
- ),
369
- },
407
+ yield* Schema.decodeUnknown(ActDiscovery)({
408
+ workbase: {
409
+ root,
410
+ repositories: graph.nodes
411
+ .filter((node) => node.kind === "repository")
412
+ .map((node) => node.key),
413
+ actions: globals
414
+ .filter(matches)
415
+ .map((action) => actionOutput(action, options.auto)),
416
+ },
417
+ currentWork: nodes
418
+ .filter((node) => node.status === "working")
419
+ .map(entitySummary),
420
+ targets: globalAction
421
+ ? []
422
+ : nodes
423
+ .filter((node) => !selectedKey || node.id === selectedKey)
424
+ .map((node) => {
425
+ const actions = catalog.get(node.id)!.filter(matches)
426
+ return {
427
+ ...entitySummary(node),
428
+ actions: actions
429
+ .filter(available)
430
+ .map((action) => actionOutput(action, options.auto)),
431
+ blockedActions: actions
432
+ .filter((action) => !available(action))
433
+ .map((action) => actionOutput(action, options.auto)),
434
+ }
435
+ }),
436
+ }),
370
437
  null,
371
438
  2,
372
439
  ),
373
440
  )
374
441
  return
375
442
  }
376
-
377
- const selectableNodes = nodes.filter(
378
- (node) => actionChoices(node, executions).length > 0,
379
- )
380
- if (!requestedKey && selectableNodes.length === 0) {
381
- return yield* Effect.fail(
382
- new Error(
383
- "No work items with available actions found in this workbase",
384
- ),
443
+ const select = <T>(prompt: string, choices: readonly Choice<T>[]) =>
444
+ Effect.gen(function* () {
445
+ const answer = yield* ui.select(prompt, choices, config.chooserCommand)
446
+ if (answer === null) return yield* Effect.fail(new ActCancelled())
447
+ return answer
448
+ })
449
+ if (!selectedKey && !actionId) {
450
+ const goals = actionGroups.map(({ id, label, icon }) => ({
451
+ key: id,
452
+ label: `${icon} ${label}`,
453
+ plainLabel: label,
454
+ value: id,
455
+ segments: [
456
+ { text: `${icon} `, color: macchiato.overlay1 },
457
+ { text: label },
458
+ ],
459
+ }))
460
+ let goal: string | undefined = state.goal
461
+ if (goal) {
462
+ state.view = "flow"
463
+ } else if (ui.tabs) {
464
+ type HomeChoice = { kind: "goal" | "item"; id: string }
465
+ const chosen = yield* ui.tabs<HomeChoice>([
466
+ {
467
+ ...actTabs[0],
468
+ emptyLabel: "No tasks or phases yet",
469
+ choices: entityChoices(
470
+ nodes.filter(
471
+ (node) => node.kind === "task" || node.kind === "phase",
472
+ ),
473
+ true,
474
+ ).map((choice) => ({
475
+ ...choice,
476
+ value: { kind: "item", id: choice.value },
477
+ })),
478
+ },
479
+ {
480
+ ...actTabs[1],
481
+ choices: goals
482
+ .filter(
483
+ (choice) =>
484
+ !["browse", "split", "handoff"].includes(choice.value),
485
+ )
486
+ .map((choice) => ({
487
+ ...choice,
488
+ value: { kind: "goal", id: choice.value },
489
+ })),
490
+ },
491
+ ])
492
+ if (!chosen) return yield* Effect.fail(new ActCancelled())
493
+ state.view = "flow"
494
+ if (chosen.kind === "item") selectedKey = chosen.id
495
+ else goal = chosen.id
496
+ } else {
497
+ goal = yield* select("Choose an action", goals)
498
+ state.view = "flow"
499
+ }
500
+ if (goal === "browse") {
501
+ if (!nodes.length)
502
+ return yield* Effect.fail(
503
+ new Error("No work items yet; choose Create a task first"),
504
+ )
505
+ selectedKey = yield* select("Choose an item", entityChoices(nodes))
506
+ } else if (goal) {
507
+ const group = actionGroups.find((group) => group.id === goal)!
508
+ const all = [...globals, ...Array.from(catalog.values()).flat()]
509
+ const choices = group.actions.flatMap((id) => {
510
+ const action = all.find((action) => action.id === id)
511
+ return action ? [action] : []
512
+ })
513
+ if (!choices.length)
514
+ return yield* Effect.fail(
515
+ new Error("No work items yet; choose Create a task first"),
516
+ )
517
+ if (session && choices.length > 1) state.goal = goal
518
+ state.view = "goal-menu"
519
+ actionId =
520
+ choices.length === 1
521
+ ? choices[0]!.id
522
+ : yield* select(group.label, actionChoices(choices))
523
+ state.view = "flow"
524
+ }
525
+ }
526
+ if (
527
+ !selectedKey &&
528
+ actionId &&
529
+ !globals.some((action) => action.id === actionId)
530
+ ) {
531
+ const eligible = nodes.filter((node) =>
532
+ catalog
533
+ .get(node.id)!
534
+ .some((action) => action.id === actionId && available(action)),
385
535
  )
536
+ if (!eligible.length)
537
+ return yield* Effect.fail(
538
+ new Error(
539
+ `No eligible items for '${actionId}'; use agency act --action ${actionId} --json for blocked reasons`,
540
+ ),
541
+ )
542
+ selectedKey = yield* select("Choose an item", entityChoices(eligible))
386
543
  }
387
- const selectedKey =
388
- requestedKey ??
389
- (yield* interaction.select(
390
- "Act on",
391
- entityChoices(selectableNodes, graph.edges),
392
- config.chooserCommand,
393
- ))
394
- if (selectedKey === null) return
395
- const selected = nodes.find((node) => entityKey(node) === selectedKey)
396
- if (!selected) {
397
- return yield* Effect.fail(
398
- new Error("Selected work item is no longer available"),
544
+ const selected = nodes.find((node) => node.id === selectedKey)
545
+ if (selected && session) {
546
+ state.goal = undefined
547
+ state.item = selected.id
548
+ session.activateTab("workstream")
549
+ }
550
+ const actions = selected ? catalog.get(selected.id)! : globals
551
+ if (!actionId) {
552
+ state.view = "item-menu"
553
+ actionId = yield* select(
554
+ `Act on ${selected!.kind} ${selected!.key}`,
555
+ actionChoices(actions.filter(available)),
399
556
  )
557
+ state.view = "flow"
400
558
  }
401
- const offeredActions = actionChoices(selected, executions)
402
- if (offeredActions.length === 0) {
559
+ const action = actions.find((action) => action.id === actionId)
560
+ if (!action || action.blockedReason)
403
561
  return yield* Effect.fail(
404
562
  new Error(
405
- `No actions are currently available for ${selected.kind} '${selected.key}'`,
563
+ `Action '${actionId}' is unavailable: ${action?.blockedReason ?? "not found"}`,
406
564
  ),
407
565
  )
408
- }
409
- const action = yield* interaction.select(
410
- `Act on ${selected.kind} ${selected.key}`,
411
- offeredActions,
412
- config.chooserCommand,
413
- )
414
- if (action === null) return
415
-
416
- const refreshed = yield* graphs.get({ cwd })
417
- const refreshedExecutions = executionNodes(refreshed.nodes)
418
- const current = refreshed.nodes.find(
419
- (node): node is EntityNode =>
420
- (node.kind === "epic" ||
421
- node.kind === "task" ||
422
- node.kind === "phase") &&
423
- entityKey(node) === selectedKey,
424
- )
425
- if (!current) {
426
- return yield* Effect.fail(
427
- new Error(
428
- "Selected work item changed or was removed; run agency act again",
566
+ const prepare = (interaction: ActInteraction) =>
567
+ action.prepare(
568
+ actionPrompts(
569
+ interaction,
570
+ graph.nodes
571
+ .filter((node) => node.kind === "repository")
572
+ .map((node) => node.key),
573
+ nodes.filter((node) => node.kind !== "epic").map((node) => node.key),
574
+ config.chooserCommand,
429
575
  ),
430
576
  )
431
- }
432
- if (
433
- current.data.sha256 !== selected.data.sha256 ||
434
- !sameActions(offeredActions, actionChoices(current, refreshedExecutions))
435
- ) {
436
- return yield* Effect.fail(
437
- new Error("Selected work item changed; run agency act again"),
577
+ const plan = yield* session
578
+ ? wizardInputs(
579
+ ui,
580
+ prepare,
581
+ () => !session.quitRequested && !session.navigationRequested,
582
+ (message) => session.notice(message ? `󰀦 ${message}` : state.notice),
583
+ )
584
+ : prepare(ui)
585
+ if (selected) {
586
+ const fresh = yield* graphs.get({ cwd: root })
587
+ const current = fresh.nodes.find(
588
+ (node): node is ActEntity =>
589
+ node.id === selected.id &&
590
+ (node.kind === "task" ||
591
+ node.kind === "phase" ||
592
+ node.kind === "epic"),
593
+ )
594
+ if (
595
+ !current ||
596
+ current.data.sha256 !== selected.data.sha256 ||
597
+ !actActions(fresh.nodes, nativeOptions, work, current).some(
598
+ (candidate) => candidate.id === actionId && available(candidate),
599
+ )
438
600
  )
601
+ return yield* Effect.fail(
602
+ new Error("Selected work item changed; run agency act again"),
603
+ )
439
604
  }
440
- const { taskId, phaseId } = entityParts(current)
441
605
  if (options.dryRun) {
442
- log(shellCommand(actionCommand(current, action, options)))
606
+ for (const command of [plan.command, ...(plan.followUpCommands ?? [])])
607
+ if (session) recap.push(` Preview: ${shellCommand(command)}`)
608
+ else log(shellCommand(command))
609
+ state.notice = ` Preview: ${shellCommand(plan.command)}`
443
610
  return
444
611
  }
445
-
446
- switch (action) {
447
- case "work":
448
- yield* work({
449
- ...(current.kind === "epic"
450
- ? { epicId: current.key }
451
- : { taskId, ...(phaseId ? { phaseId } : {}) }),
452
- auto: options.auto,
453
- cwd,
454
- inputAllowed: options.inputAllowed,
455
- silent: options.silent,
456
- verbose: options.verbose,
457
- })
458
- return
459
- case "pr":
460
- yield* createPullRequest({
461
- taskId,
462
- phaseId,
463
- draft: options.draft,
464
- cwd,
465
- silent: options.silent,
466
- verbose: options.verbose,
467
- })
468
- return
469
- case "reopen":
470
- if (phaseId) {
471
- yield* phaseCommand({
472
- subcommand: "status",
473
- args: [taskId, phaseId, "open"],
474
- cwd,
475
- silent: options.silent,
476
- verbose: options.verbose,
477
- })
478
- } else {
479
- yield* taskCommand({
480
- subcommand: "status",
481
- args: [taskId, "open"],
482
- cwd,
483
- silent: options.silent,
484
- verbose: options.verbose,
485
- })
486
- }
487
- return
488
- case "drop":
489
- if (phaseId) {
490
- yield* phaseCommand({
491
- subcommand: "status",
492
- args: [taskId, phaseId, "dropped"],
493
- cwd,
494
- silent: options.silent,
495
- verbose: options.verbose,
496
- })
497
- } else {
498
- yield* taskCommand({
499
- subcommand: "status",
500
- args: [taskId, "dropped"],
501
- cwd,
502
- silent: options.silent,
503
- verbose: options.verbose,
504
- })
612
+ session?.show(`󰔟 ${action.label}…`)
613
+ // An interactive worker must receive a restored terminal.
614
+ if (action.id === "work" && session) {
615
+ yield* session.close()
616
+ state.session = undefined
617
+ }
618
+ yield* (
619
+ session && action.id !== "work"
620
+ ? Effect.raceFirst(plan.run, session.cancelled)
621
+ : plan.run
622
+ ).pipe(
623
+ Effect.tapError((error) =>
624
+ Effect.sync(() => {
625
+ if (session) {
626
+ recap.push(
627
+ `󰅖 ${action.label} ${error instanceof ActCancelled ? "interrupted; check item state before retrying" : "failed"}${selected ? ` — ${selected.key}` : ""}`,
628
+ )
629
+ state.notice = recap[recap.length - 1]!
630
+ }
631
+ }),
632
+ ),
633
+ )
634
+ if (session) {
635
+ recap.push(`󰄬 ${action.label}${selected ? ` — ${selected.key}` : ""}`)
636
+ state.notice = recap[recap.length - 1]!
637
+ if (plan.next) {
638
+ recap.push(
639
+ `Item: ${plan.next.taskId}${plan.next.phaseId ? `/${plan.next.phaseId}` : ""}`,
640
+ )
641
+ state.notice += ` — ${plan.next.taskId}${plan.next.phaseId ? `/${plan.next.phaseId}` : ""}`
642
+ }
643
+ recap.push(`Command: ${shellCommand(plan.command)}`)
644
+ if (action.id === "current-work") {
645
+ const current = nodes.filter((node) => node.status === "working")
646
+ state.notice = current.length
647
+ ? ` Working: ${current.map((node) => node.key).join(", ")}`
648
+ : " No work is in progress."
649
+ recap.push(
650
+ ...(current.length
651
+ ? current.map(
652
+ (node) =>
653
+ `${node.kind} ${node.key} · ${node.repositories.join(", ")}${"description" in node.data && node.data.description ? ` — ${node.data.description}` : ""}`,
654
+ )
655
+ : ["No work is in progress."]),
656
+ )
657
+ }
658
+ }
659
+ if (plan.next) {
660
+ const next = yield* select("Created. What next?", [
661
+ {
662
+ key: "finish",
663
+ label: session
664
+ ? "Keep for later — return to tabs"
665
+ : "Finish — keep this item for later",
666
+ value: "finish",
667
+ },
668
+ { key: "work", label: "Work on the new item now", value: "work" },
669
+ ])
670
+ if (next === "work") {
671
+ if (session) {
672
+ yield* session.close()
673
+ state.session = undefined
505
674
  }
506
- return
507
- case "archive":
508
- yield* archiveCommand({
509
- type: current.kind,
510
- args: current.kind === "phase" ? [taskId, phaseId!] : [current.key],
511
- cwd,
512
- silent: options.silent,
513
- verbose: options.verbose,
514
- })
675
+ yield* work({ ...nativeOptions, silent: options.silent, ...plan.next })
676
+ if (session) recap.push("󰄬 Work handoff completed")
677
+ } else if (session) recap.push("Kept for later — work was not started.")
515
678
  }
516
679
  })
517
680
 
518
681
  export const help = `
519
- Usage: agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]
682
+ Usage: agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--action <id>] [--dry-run | --json] [--auto] [--draft]
520
683
 
521
- Interactively choose an active work item and a state-aware lifecycle action.
522
- An existing positional directory selects its containing epic, task, or phase;
523
- otherwise the positional value is a task ID. Selectors skip work-item selection.
524
- --dry-run prints the selected action's exact
525
- Agency command without executing it. --json lists targets, available actions,
526
- and command argv without prompting or executing.
684
+ Choose a task/phase in Workstream, or press Tab for Workbase actions. Guided
685
+ creation offers an explicit Work choice afterward; creation alone never starts work.
686
+ Item actions stay on that item. Escape clears input, then steps back through
687
+ wizard prompts, the action menu, and Workstream,
688
+ or exits from the front screen; Ctrl-C quits. A recap is printed when you exit.
689
+ An existing directory or file selects its containing epic, task, or phase.
690
+ A workbase path opens Workbase actions. Otherwise the positional value is a
691
+ task ID. Selectors skip item selection.
527
692
 
528
693
  Options:
529
- --epic <id> Select an epic
530
- --task <id> Select a task
531
- --phase <id> Select a phase; requires --task
532
- --dry-run Select an action and print its command without executing
533
- --json List available actions and command argv as JSON
694
+ --action <id> Start an action or filter discovery (IDs from --json)
695
+ --epic <id> Select an epic
696
+ --task <id> Select a task
697
+ --phase <id> Select a phase; requires --task
698
+ --dry-run Collect inputs and print commands without executing
699
+ --json Discover actions, required inputs, argv, and blocked reasons
534
700
  --auto Pass --auto when starting or continuing work
535
701
  --draft Create a draft pull request
536
702
  `