@markjaquith/agency 3.5.2 → 3.6.1

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.
@@ -4,6 +4,55 @@ This directory is an Agency workbase. Epics, tasks, and phases are durable
4
4
  Markdown documents; repository aliases and generated Git worktrees provide code
5
5
  access.
6
6
 
7
+ ## Discover Actions First
8
+
9
+ After the `agency context . --json` bootstrap, use `agency act --json` as the
10
+ primary way to discover what Agency can do. Prefer its live action catalog over
11
+ searching command help, reading implementation code, or guessing commands from
12
+ memory. It reports available actions, blocked reasons, required inputs, exact
13
+ argv/templates, document revisions, and explicit follow-up choices.
14
+
15
+ Keep discovery focused on the work at hand:
16
+
17
+ - Current task: `agency act --task <context-task-id> --json`.
18
+ - Current phase: `agency act --task <context-task-id> --phase <context-phase-id> --json`.
19
+ - One operation: add `--action <action-id>` to either selector.
20
+ - Workbase-level creation or repository actions: use
21
+ `agency act --action <action-id> --json` without an item selector.
22
+ - Unsure which action or item applies: `agency act --json`.
23
+
24
+ Use selectors returned by context, not IDs inferred from checkout or branch
25
+ names. Execute an available `command` as argv from the returned workbase root.
26
+ For a `commandTemplate`, substitute required input placeholders; append optional
27
+ inputs using their declared `option`. Preserve multiline input as one argv value.
28
+ Re-discover after mutations because availability and revisions may change.
29
+
30
+ Discovery is read-only and does not grant consent. `followUpCommands` describe
31
+ the action's bookkeeping; `nextActions` require a separate explicit choice.
32
+ Creation never implies starting Work. Respect blocked reasons and the consent
33
+ boundaries below. The command fast paths remain useful when the exact operation
34
+ and arguments are already established; do not rediscover between every known
35
+ step or use help unless discovery does not cover the operation.
36
+
37
+ ### Current Work and Branch Context
38
+
39
+ When the request concerns this execution unit, target its context task/phase
40
+ rather than browsing the entire workbase. Use `authority.writable.checkoutPath`
41
+ for repository work and the declared delivery branch for publication.
42
+
43
+ For new work that builds on the current implementation (a follow-up, additional
44
+ phase, or explicitly requested continuation), prefer the current execution's
45
+ declared branch as `--base` when that matches the user's intent. Do not blindly
46
+ copy `main` from examples or accept a wizard default. Confirm the branch from
47
+ Agency context; when checking the live Git branch is necessary, do so in the
48
+ writable checkout, not the workbase root. An explicit user base takes precedence.
49
+ Unrelated new work should use its intended integration base instead.
50
+
51
+ Branch ancestry is not a completion dependency. Choosing the current branch as
52
+ the base does not add `--depends-on`, reuse the current item for explicitly new
53
+ work, switch branches, or expand write authority. Pass the chosen base as the
54
+ discovered `base` input to the native creation/handoff command.
55
+
7
56
  ## Command Fast Paths
8
57
 
9
58
  When a request clearly matches one of these intents, use the exact recipe without
@@ -68,7 +68,7 @@ const statuses: Record<
68
68
  > = {
69
69
  open: { icon: "󰄱", color: macchiato.overlay1 },
70
70
  working: { icon: "󰔟", color: macchiato.blue },
71
- delegated: { icon: "󰁕", color: macchiato.mauve },
71
+ delegated: { icon: "󰁕", color: macchiato.blue },
72
72
  done: { icon: "󰄬", color: macchiato.green },
73
73
  dropped: { icon: "󰅖", color: macchiato.red },
74
74
  }
@@ -77,11 +77,13 @@ const kinds: Record<
77
77
  WorkTarget["kind"],
78
78
  { readonly icon: string; readonly color: string }
79
79
  > = {
80
- epic: { icon: "", color: macchiato.mauve },
81
- task: { icon: "󰗡", color: macchiato.sapphire },
82
- phase: { icon: "󰔚", color: macchiato.yellow },
80
+ epic: { icon: "", color: macchiato.overlay1 },
81
+ task: { icon: "󰗡", color: macchiato.overlay1 },
82
+ phase: { icon: "", color: macchiato.overlay1 },
83
83
  }
84
84
 
85
+ export { statuses as workStatusStyle, kinds as workKindStyle }
86
+
85
87
  const hexToAnsi = (color: string) => {
86
88
  const value = color.slice(1)
87
89
  return [0, 2, 4]
@@ -1,496 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import {
4
- createCliRenderer,
5
- type CliRenderer,
6
- type CliRendererConfig,
7
- type TextareaRenderable,
8
- type TextNodeOptions,
9
- } from "@opentui/core"
10
- import {
11
- render,
12
- useKeyboard,
13
- useTerminalDimensions,
14
- type JSX,
15
- } from "@opentui/solid"
16
- import { createMemo, createSignal, For } from "solid-js"
17
- import type { ChoiceSegment } from "./chooser"
18
- import { macchiato } from "./theme"
19
-
20
- export interface InteractiveChoice {
21
- readonly key: string
22
- readonly label: string
23
- readonly depth?: number
24
- readonly segments?: readonly ChoiceSegment[]
25
- }
26
-
27
- export const interactiveRendererConfig = {
28
- screenMode: "split-footer",
29
- footerHeight: 5,
30
- externalOutputMode: "capture-stdout",
31
- consoleMode: "disabled",
32
- clearOnShutdown: false,
33
- exitOnCtrlC: false,
34
- useMouse: false,
35
- autoFocus: false,
36
- openConsoleOnError: false,
37
- } satisfies CliRendererConfig
38
-
39
- export const interactiveSelectRendererConfig = {
40
- ...interactiveRendererConfig,
41
- screenMode: "alternate-screen",
42
- externalOutputMode: "passthrough",
43
- } satisfies CliRendererConfig
44
-
45
- interface PromptProps<T> {
46
- readonly prompt: string
47
- readonly onDone: (value: T | null) => void
48
- }
49
-
50
- const isCancel = (key: { name: string; ctrl: boolean }) =>
51
- key.name === "escape" || (key.ctrl && key.name === "c")
52
-
53
- interface PromptKey {
54
- readonly name: string
55
- readonly ctrl: boolean
56
- preventDefault(): void
57
- stopPropagation(): void
58
- }
59
-
60
- const removedText = (before: string, after: string) => {
61
- let start = 0
62
- while (
63
- start < before.length &&
64
- start < after.length &&
65
- before[start] === after[start]
66
- ) {
67
- start++
68
- }
69
-
70
- let end = 0
71
- while (
72
- before[before.length - end - 1] === after[after.length - end - 1] &&
73
- end < before.length - start &&
74
- end < after.length - start
75
- ) {
76
- end++
77
- }
78
-
79
- return before.slice(start, before.length - end)
80
- }
81
-
82
- const createReadlineEditing = (
83
- getInput: () => TextareaRenderable | undefined,
84
- onInput?: (value: string) => void,
85
- ) => {
86
- let value = ""
87
- let killBuffer = ""
88
- let beforeKill: string | undefined
89
-
90
- return {
91
- get value() {
92
- return value
93
- },
94
- handleInput(next: string) {
95
- if (beforeKill !== undefined) {
96
- const killed = removedText(beforeKill, next)
97
- if (killed) killBuffer = killed
98
- beforeKill = undefined
99
- }
100
- value = next
101
- onInput?.(next)
102
- },
103
- handleKey(key: PromptKey) {
104
- const current = getInput()?.plainText
105
- if (current !== undefined && current !== value) this.handleInput(current)
106
- if (!key.ctrl) return false
107
- if (key.name === "y") {
108
- key.preventDefault()
109
- key.stopPropagation()
110
- if (killBuffer) getInput()?.insertText(killBuffer)
111
- return true
112
- }
113
- if (key.name === "u" || key.name === "k" || key.name === "w") {
114
- beforeKill = value
115
- }
116
- return false
117
- },
118
- }
119
- }
120
-
121
- export const InteractiveTextPrompt = (props: PromptProps<string>) => {
122
- let input: TextareaRenderable | undefined
123
- const editing = createReadlineEditing(() => input)
124
- useKeyboard((key) => {
125
- if (isCancel(key)) {
126
- key.preventDefault()
127
- key.stopPropagation()
128
- props.onDone(null)
129
- return
130
- }
131
- if (editing.handleKey(key)) return
132
- if (key.name !== "return") return
133
- key.preventDefault()
134
- key.stopPropagation()
135
- props.onDone(editing.value)
136
- })
137
-
138
- return (
139
- <box
140
- flexDirection="column"
141
- width="100%"
142
- height="100%"
143
- backgroundColor={macchiato.base}
144
- >
145
- <text fg={macchiato.blue}>{props.prompt}</text>
146
- <textarea
147
- focused
148
- height={2}
149
- wrapMode="word"
150
- backgroundColor={macchiato.mantle}
151
- focusedBackgroundColor={macchiato.surface0}
152
- textColor={macchiato.text}
153
- focusedTextColor={macchiato.text}
154
- cursorColor={macchiato.rosewater}
155
- keyBindings={[{ name: "return", action: "submit" }]}
156
- onContentChange={() => {
157
- editing.handleInput(input?.plainText ?? "")
158
- }}
159
- ref={(next) => {
160
- input = next
161
- queueMicrotask(() => {
162
- if (input && !input.isDestroyed) input.focus()
163
- })
164
- }}
165
- />
166
- <text fg={macchiato.overlay1} wrapMode="none">
167
- enter submit | esc cancel | ctrl-y yank
168
- </text>
169
- </box>
170
- )
171
- }
172
-
173
- interface SelectPromptProps extends PromptProps<string> {
174
- readonly choices: readonly InteractiveChoice[]
175
- }
176
-
177
- const isWordBoundary = (value: string, index: number) =>
178
- index === 0 || /[\s/_.:-]/.test(value[index - 1]!)
179
-
180
- const fuzzyScore = (value: string, query: string) => {
181
- const candidate = value.toLowerCase()
182
- const needle = query.toLowerCase()
183
- let previous = new Float64Array(candidate.length)
184
- let current = new Float64Array(candidate.length)
185
- previous.fill(Number.NEGATIVE_INFINITY)
186
- let bestScore = Number.NEGATIVE_INFINITY
187
-
188
- for (let queryIndex = 0; queryIndex < needle.length; queryIndex++) {
189
- current.fill(Number.NEGATIVE_INFINITY)
190
- let bestEarlier = Number.NEGATIVE_INFINITY
191
- bestScore = Number.NEGATIVE_INFINITY
192
- for (let index = 0; index < candidate.length; index++) {
193
- if (queryIndex > 0 && index > 0) {
194
- bestEarlier = Math.max(bestEarlier, previous[index - 1]! + index - 1)
195
- }
196
- if (candidate[index] !== needle[queryIndex]) continue
197
-
198
- const boundaryBonus = isWordBoundary(candidate, index) ? 8 : 0
199
- if (queryIndex === 0) {
200
- current[index] = 10 + boundaryBonus - index
201
- bestScore = Math.max(bestScore, current[index]!)
202
- continue
203
- }
204
-
205
- const contiguous =
206
- index > 0 ? previous[index - 1]! + 12 : Number.NEGATIVE_INFINITY
207
- const gapped = bestEarlier - index + 1
208
- current[index] = Math.max(contiguous, gapped) + 10 + boundaryBonus
209
- bestScore = Math.max(bestScore, current[index]!)
210
- }
211
- if (!Number.isFinite(bestScore)) return null
212
- const swap = previous
213
- previous = current
214
- current = swap
215
- }
216
-
217
- return bestScore - candidate.length / 1000
218
- }
219
-
220
- export const fuzzyChoices = (
221
- choices: readonly InteractiveChoice[],
222
- query: string,
223
- ) => {
224
- if (!query) return choices
225
- return choices
226
- .map((choice, index) => ({
227
- choice,
228
- index,
229
- score: fuzzyScore(choice.label, query),
230
- }))
231
- .filter(
232
- (
233
- match,
234
- ): match is typeof match & {
235
- score: number
236
- } => match.score !== null,
237
- )
238
- .sort((left, right) => right.score - left.score || left.index - right.index)
239
- .map((match) => match.choice)
240
- }
241
-
242
- const choiceDepth = (choice: InteractiveChoice) => choice.depth ?? 0
243
-
244
- const hasSibling = (
245
- choices: readonly InteractiveChoice[],
246
- index: number,
247
- direction: -1 | 1,
248
- ) => {
249
- const depth = choiceDepth(choices[index]!)
250
- for (
251
- let siblingIndex = index + direction;
252
- siblingIndex >= 0 && siblingIndex < choices.length;
253
- siblingIndex += direction
254
- ) {
255
- const siblingDepth = choiceDepth(choices[siblingIndex]!)
256
- if (siblingDepth < depth) return false
257
- if (siblingDepth === depth) return true
258
- }
259
- return false
260
- }
261
-
262
- export const hierarchyPrefix = (
263
- choices: readonly InteractiveChoice[],
264
- index: number,
265
- ) => {
266
- const choice = choices[index]
267
- if (!choice || choice.depth === undefined) return ""
268
- const depth = choiceDepth(choice)
269
- let prefix = ""
270
- let ancestorIndex = index
271
-
272
- for (let ancestorDepth = depth - 1; ancestorDepth >= 0; ancestorDepth--) {
273
- for (ancestorIndex--; ancestorIndex >= 0; ancestorIndex--) {
274
- if (choiceDepth(choices[ancestorIndex]!) === ancestorDepth) break
275
- }
276
- prefix = `${ancestorIndex >= 0 && hasSibling(choices, ancestorIndex, 1) ? "│ " : " "}${prefix}`
277
- }
278
-
279
- const hasPrevious = hasSibling(choices, index, -1)
280
- const hasNext = hasSibling(choices, index, 1)
281
- return `${prefix}${!hasPrevious && hasNext ? "╭" : hasNext ? "├" : "╰"}─ `
282
- }
283
-
284
- export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
285
- let input: TextareaRenderable | undefined
286
- const dimensions = useTerminalDimensions()
287
- const [query, setQuery] = createSignal("")
288
- const editing = createReadlineEditing(() => input, setQuery)
289
- const [selected, setSelected] = createSignal(0)
290
- const choices = createMemo(() => fuzzyChoices(props.choices, query()))
291
- const displaySegments = (choice: InteractiveChoice) =>
292
- choice.segments ?? [{ text: choice.label }]
293
- const move = (offset: -1 | 1) => {
294
- const count = choices().length
295
- if (count === 0) return
296
- setSelected((current) => (current + offset + count) % count)
297
- }
298
- useKeyboard((key) => {
299
- if (key.name === "escape" && query()) {
300
- key.preventDefault()
301
- key.stopPropagation()
302
- input?.clear()
303
- editing.handleInput("")
304
- setQuery("")
305
- setSelected(0)
306
- return
307
- }
308
- if (isCancel(key)) {
309
- key.preventDefault()
310
- key.stopPropagation()
311
- props.onDone(null)
312
- return
313
- }
314
- if (key.name === "up" || (key.ctrl && key.name === "p")) {
315
- key.preventDefault()
316
- key.stopPropagation()
317
- move(-1)
318
- return
319
- }
320
- if (key.name === "down" || (key.ctrl && key.name === "n")) {
321
- key.preventDefault()
322
- key.stopPropagation()
323
- move(1)
324
- return
325
- }
326
- if (editing.handleKey(key)) return
327
- if (key.name !== "return") return
328
- key.preventDefault()
329
- key.stopPropagation()
330
- const choice = choices()[selected()]
331
- if (choice) props.onDone(choice.key)
332
- })
333
-
334
- const visible = () => {
335
- const visibleCount = Math.max(dimensions().height - 3, 1)
336
- const start = Math.min(
337
- Math.max(selected() - Math.floor(visibleCount / 2), 0),
338
- Math.max(choices().length - visibleCount, 0),
339
- )
340
- return choices()
341
- .slice(start, start + visibleCount)
342
- .map((choice, offset) => ({
343
- choice,
344
- index: start + offset,
345
- originalIndex: props.choices.indexOf(choice),
346
- }))
347
- }
348
-
349
- return (
350
- <box
351
- flexDirection="column"
352
- width="100%"
353
- height="100%"
354
- backgroundColor={macchiato.base}
355
- >
356
- <box flexDirection="row" width="100%">
357
- <text fg={macchiato.blue} flexShrink={1} wrapMode="none">
358
- {props.prompt}
359
- </text>
360
- <text fg={macchiato.blue}>{" > "}</text>
361
- <textarea
362
- focused
363
- flexGrow={1}
364
- minWidth={8}
365
- height={2}
366
- wrapMode="word"
367
- placeholder="filter"
368
- placeholderColor={macchiato.overlay0}
369
- backgroundColor={macchiato.mantle}
370
- focusedBackgroundColor={macchiato.surface0}
371
- textColor={macchiato.text}
372
- focusedTextColor={macchiato.text}
373
- cursorColor={macchiato.rosewater}
374
- keyBindings={[{ name: "return", action: "submit" }]}
375
- onContentChange={() => {
376
- editing.handleInput(input?.plainText ?? "")
377
- setSelected(0)
378
- }}
379
- ref={(next) => {
380
- input = next
381
- queueMicrotask(() => {
382
- if (input && !input.isDestroyed) input.focus()
383
- })
384
- }}
385
- />
386
- </box>
387
- <box flexDirection="column" flexGrow={1}>
388
- <For
389
- each={visible()}
390
- fallback={<text fg={macchiato.overlay1}>No matches</text>}
391
- >
392
- {({ choice, index, originalIndex }) => (
393
- <box
394
- width="100%"
395
- height={1}
396
- backgroundColor={
397
- index === selected() ? macchiato.surface1 : macchiato.base
398
- }
399
- >
400
- <text
401
- fg={index === selected() ? macchiato.text : macchiato.subtext0}
402
- bg={index === selected() ? macchiato.surface1 : macchiato.base}
403
- wrapMode="none"
404
- >
405
- <span
406
- style={
407
- {
408
- fg: index === selected() ? macchiato.mauve : undefined,
409
- } as TextNodeOptions
410
- }
411
- >
412
- {index === selected() ? "▌ " : " "}
413
- </span>
414
- <span style={{ fg: macchiato.overlay1 } as TextNodeOptions}>
415
- {query() ? "" : hierarchyPrefix(props.choices, originalIndex)}
416
- </span>
417
- <For each={displaySegments(choice)}>
418
- {(segment) => (
419
- <span style={{ fg: segment.color } as TextNodeOptions}>
420
- {segment.text}
421
- </span>
422
- )}
423
- </For>
424
- </text>
425
- </box>
426
- )}
427
- </For>
428
- </box>
429
- <text fg={macchiato.overlay1} wrapMode="none">
430
- enter select | esc clear/cancel | arrows/ctrl-n/p | ctrl-y yank
431
- </text>
432
- </box>
433
- )
434
- }
435
-
436
- const shutdown = async (renderer: CliRenderer) => {
437
- await renderer.idle().catch(() => undefined)
438
- if (renderer.externalOutputMode === "capture-stdout") {
439
- renderer.externalOutputMode = "passthrough"
440
- }
441
- if (renderer.screenMode === "split-footer")
442
- renderer.screenMode = "main-screen"
443
- if (!renderer.isDestroyed) renderer.destroy()
444
- }
445
-
446
- async function runInteractive<T>(
447
- view: (finish: (value: T | null) => void) => JSX.Element,
448
- config: CliRendererConfig = interactiveRendererConfig,
449
- ) {
450
- let finish!: (value: T | null) => void
451
- let settled = false
452
- const result = new Promise<T | null>((resolve) => {
453
- finish = (value) => {
454
- if (settled) return
455
- settled = true
456
- resolve(value)
457
- }
458
- })
459
- let renderer: CliRenderer | undefined
460
- try {
461
- renderer = await createCliRenderer({
462
- ...config,
463
- onDestroy: () => finish(null),
464
- })
465
- await render(() => view(finish), renderer)
466
- renderer.requestRender()
467
- return await result
468
- } finally {
469
- if (renderer) {
470
- await shutdown(renderer)
471
- }
472
- }
473
- }
474
-
475
- export const promptText = async (prompt: string) => {
476
- const result = await runInteractive<string>((finish) => (
477
- <InteractiveTextPrompt prompt={prompt} onDone={finish} />
478
- ))
479
- if (result === null) throw new Error("Interactive input cancelled")
480
- return result
481
- }
482
-
483
- export const promptSelect = (
484
- prompt: string,
485
- choices: readonly InteractiveChoice[],
486
- ) =>
487
- runInteractive<string>(
488
- (finish) => (
489
- <InteractiveSelectPrompt
490
- prompt={prompt}
491
- choices={choices}
492
- onDone={finish}
493
- />
494
- ),
495
- interactiveSelectRendererConfig,
496
- )