Phased development workflow: setup → research → plan → gated plan review → implement → review-against-plan → gated report → wind-down. Durable file-based…

A phased development workflow for Claude Code, with gates a hook enforces. Research, plan, approve, implement, review against the plan, approve the merge, wind down: each step leaves its state in a file, so any session can pick the work up, and Claude can't edit code until you've approved the plan.

workflow.md (phase, decisions, progress, deviations) and plan.md. Close the session, compact, switch machines: /flow resumes where the file says.
flowchart LR
S[setup] --> R[research] --> P[planning] --> PR[plan-review]
PR --> G1{{"gate 1 · you approve the plan"}}
G1 --> I[implementation] --> CR[code-review] --> RP[reporting]
RP --> G2{{"gate 2 · you approve the merge"}}
G2 --> W[wind-down] --> C[complete]
.flow.md asks for one.All of this is drawn by a Claude Code mod in hooks/mod/. It's display only: with mods switched off, flow's phases and gates work exactly the same.
The rail. Every phase and gate, the progress bar, and the next action, above the prompt. Periwinkle is where the task is, sage is done, straw is waiting on you, rose is a deviation or a block.

At a gate the frame turns straw and offers open plan, approve and request changes. They only draft the reply into the prompt; you press Enter.
Blocked, visibly. When the validator refuses an edit, the reason is in the transcript and in a toast.

/flow-pane. Progress, the plan, the decision log, deviations, and every task across the repo's worktrees, on tabs 1 to 5.
| Progress | Tasks |
|---|---|
![]() | ![]() |
/flow-status. The same picture as text, with no model turn.

Press t on the rail (or the pane, or run /flow-term [task]) and flow opens a terminal in that task's worktree, split beside Claude Code. e (or /flow-code [task]) opens the worktree in your editor. Nothing opens unless you ask.

By default that's an iTerm split on the right and VS Code. The terminal, editor, split side and split size are settings. For any split iTerm's AppleScript can't make, flow uses iTerm's Python API, and /flow-term setup checks what that needs and tells you how to fix anything missing.

/plugin marketplace add fifersheep/flow
/plugin install flow@flow
Then, in a repo:
/flow add per-user rate limits to the public API
| Command | What it does | |
|---|---|---|
/flow <description> | Start a task: branch, worktree, task folder, mode | |
/flow | Resume the task on this branch, or choose between several | |
/flow status | List active tasks | |
/flow shape <idea> | Brainstorm an idea into agreed tracker tickets (before any task exists) | |
/flow-status | Where the task stands, with no model turn | |
/flow-pane [tab] | Open the pane: progress, plan, decisions, deviations, tasks | |
| `/flow-term [task \ | setup]` | Terminal in a task's worktree, or the split's setup checklist |
/flow-code [task] | Open a task's worktree in your editor |
Per repo: a .flow.md at the root, checked in. Copy templates/flow-config-example.md. It sets:
## on), run once when a task enters a phasePer person: the plugin's settings, in /config.
| Setting | Default | Options |
|---|---|---|
| Terminal | iterm-split | iterm-split, iterm-tab, iterm-window, terminal, warp, tmux |
| Editor | vscode | vscode, cursor, zed |
| Split side | right | right, left, top, bottom |
| Split size (%) | 50 | 10–90, of Claude Code's pane |
Escape hatch: force: true in a task's workflow.md frontmatter lifts its gates. It's for you, not Claude: flow's skills never set it, though the validator doesn't stop an edit that does.
The validator watches Claude's Write and Edit tools and its shell commands. Through Write and Edit, a code edit before gate 1, or while gate 2 is pending, is refused, as is a phase change that skips a step or lacks its gate line. A shell command is read before it runs, and the common ways of writing a file (a redirect, tee, sed -i, mv/cp/rm/touch, git apply, git checkout -- <path>, a python -c that opens a file for writing) are held to the same rule. Reads, test runs, git, installs and writes outside the repo pass.
That reading is a heuristic. A path in a variable, a script, xargs or a build tool can still change a file, and a command it can't parse is let through. The gates stop the normal path and its common detours; they aren't a sandbox.
More detail: docs/reference.md, and the reasoning behind the design in docs/design.md.
python3 -m unittest discover tests -v # hooks and scripts
claude plugin test . # the mod
claude plugin validate . # manifests and mod scan
python3 scripts/check_version_bump.py main # version is bumped
Every PR bumps the version (see CLAUDE.md). The screenshots are captured from a live session in a made-up repo by scripts/demo/shoot.py, loading flow from your checkout, so they can be retaken when the UI changes: scripts/demo/shoot.py --check, then scripts/demo/shoot.py. CLAUDE.md says which image shows what.
Apache License 2.0. Fork it and change it as you like; keep the NOTICE file with anything you redistribute.
hooks/mod/register.tsx 301 lines1// The flow mod: five views of a task's state, all display, none of them a gate.
2//
3// 1. band.tsx the phase rail above the prompt
4// 2. pane.tsx /flow-pane: progress, plan, decisions, deviations, tasks
5// 3. status.tsx /flow-status, answered without a model turn
6// 4. here toasts when a phase moves, a gate opens, or the validator blocks
7// 5. here + spinner.tsx the status line and the spinner carry the phase
8//
9// and, from the band, the pane, /flow-term and /flow-code, a terminal or the
10// editor opened in a task's worktree (hooks/flow_open.py does the opening).
11//
12// Every view reads one snapshot, printed by hooks/flow_status.py on the same
13// parsing the validator uses. Mods can be switched off (policy, a rollout,
14// `claude -p`), so nothing here may be load-bearing: the gates stay in
15// flow_validate.py, and flow without this module works exactly as before.
16//
17// The other files build trees and never touch `$`; every engine call is in
18// this file, as the engine's static scan of a hooks module requires.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register, ResolveInput } from 'claude-code'
22
23import type { PaneTab, Snapshot } from '../../types'
24import { bandTree } from './band'
25import { PANE } from './look'
26import type { Actions, Els } from './look'
27import { blockReason, changes, EDITOR_NAME, focusTask, GLYPH, hereTask, openTarget, paletteFor, statusPin, statusReport } from './model'
28import type { OpenKind } from './model'
29import { paneTree } from './pane'
30import { spinnerTree } from './spinner'
31import { COMMAND, FENCE, statusRow } from './status'
32
33const TICK_MS = 1000
34const POLL_TICKS = 5 // a task in the repo: catch other sessions' edits within 5 s
35const QUIET_TICKS = 30 // no flow tasks here: look again every 30 s
36const TABS: readonly PaneTab[] = ['progress', 'plan', 'decisions', 'deviations', 'tasks']
37
38// The session's values: a drawing that reads one redraws when it changes.
39// /clear, /resume and /branch reset them, and the next refresh fills them in.
40const snapshot = atom({ plugin: 'flow', key: 'snapshot' } as const, null)
41const paneTab = atom({ plugin: 'flow', key: 'tab' } as const, 'progress')
42const picked = atom({ plugin: 'flow', key: 'picked' } as const, null)
43
44let isDirty = true
45let hasTasks = false
46let running: Promise<void> | null = null
47let pinned: string | undefined | null = null // null: nothing pinned by this load of the module
48let terminalApp = 'iterm-split' // the plugin's settings, read as the module loads
49let editorApp = 'vscode'
50let splitSide = 'right'
51let splitSize = 50
52
53async function load($: EngineInterface): Promise<void> {
54 const cwd = await $.session.cwd()
55 const run = await $.process.run(['python3', `${$.plugin.root}/hooks/flow_status.py`, '--cwd', cwd], { timeoutMs: 15000 })
56 if (run.exitCode !== 0) return
57 let next: Snapshot
58 try {
59 next = JSON.parse(run.stdout) as Snapshot
60 } catch {
61 return
62 }
63 hasTasks = next.tasks.length > 0
64 const pin = statusPin(hereTask(next))
65 if (pin !== pinned) $.ui.status(pin) // mod 5; a reload pins it afresh once
66 pinned = pin
67 const prev = await read($, snapshot)
68 if (JSON.stringify(prev) === JSON.stringify(next)) return
69 await update($, snapshot, () => next)
70 for (const line of changes(prev, next)) $.ui.toast(line) // mod 4
71}
72
73/** One snapshot at a time; a call while one runs shares it. */
74function refresh($: EngineInterface): Promise<void> {
75 isDirty = false
76 running ??= load($)
77 .catch(() => undefined)
78 .finally(() => {
79 running = null
80 })
81 return running
82}
83
84async function openPane($: EngineInterface, tab?: PaneTab, folder?: string): Promise<void> {
85 if (tab) await update($, paneTab, () => tab)
86 if (folder !== undefined) await update($, picked, () => folder)
87 await $.ui.open({ id: PANE, title: 'flow', focus: true, closeOnEscape: true })
88}
89
90async function setTab($: EngineInterface, tab: PaneTab): Promise<void> {
91 await update($, paneTab, () => tab)
92}
93
94async function pick($: EngineInterface, folder: string): Promise<void> {
95 await update($, picked, () => folder)
96 await update($, paneTab, () => 'progress')
97}
98
99async function draft($: EngineInterface, text: string): Promise<void> {
100 const { isFilled } = await $.prompt.fill({ text })
101 if (!isFilled) $.ui.toast('flow: answer the open dialog first, then try again')
102}
103
104/** The surface's elements, with the palette that surface draws roles in. */
105function kit($: EngineInterface, e: ResolveInput & { surface: string }): Els {
106 return { ...$.ui.resolve(e), paint: paletteFor(e.surface) }
107}
108
109type Opened = { ok: boolean; message: string; setup?: boolean; needsSetup?: boolean; lines?: string[] }
110
111/** flow_open.py, with the settings: `setup` or a kind and a folder. */
112async function opener($: EngineInterface, args: string[], timeoutMs = 30000): Promise<Opened> {
113 const settings = ['--side', splitSide, '--size', String(splitSize)]
114 try {
115 const run = await $.process.run(['python3', `${$.plugin.root}/hooks/flow_open.py`, ...args, ...settings], { timeoutMs })
116 return JSON.parse(run.stdout) as Opened
117 } catch {
118 return { ok: false, message: 'flow_open.py could not be run' }
119 }
120}
121
122async function openIn($: EngineInterface, kind: OpenKind, dir: string): Promise<Opened> {
123 const args = [kind, dir, '--app', kind === 'terminal' ? terminalApp : editorApp]
124 if (kind === 'terminal' && (await opener($, [...args, '--dry-run'])).setup) {
125 $.ui.toast(`flow ${GLYPH.next} setting up iTerm's Python API for this split — one time, a few seconds…`)
126 }
127 // the first split AppleScript can't make installs the helper package: allow for it
128 return opener($, args, 300000)
129}
130
131/** A button's open: the outcome is said in a toast, a fallback or a failure marked. */
132async function openFromButton($: EngineInterface, kind: OpenKind, dir: string): Promise<void> {
133 const r = await openIn($, kind, dir)
134 if (!r.ok) $.ui.toast(`${GLYPH.blocked} flow: ${r.message}`)
135 else $.ui.toast(`flow ${r.needsSetup ? GLYPH.deviation : GLYPH.next} ${r.message}`)
136}
137
138/** /flow-term setup: the checklist the terminal setting needs, in a fenced block. */
139async function terminalSetup($: EngineInterface): Promise<{ text: string }> {
140 const r = await opener($, ['setup', '--app', terminalApp], 300000)
141 const lines = r.lines ?? [`${GLYPH.blocked} ${r.message}`]
142 return { text: [FENCE, ...lines, FENCE].join('\n') }
143}
144
145/** /flow-term and /flow-code: the named task's worktree, else the session's. */
146async function openFromCommand($: EngineInterface, kind: OpenKind, args: string): Promise<{ text: string }> {
147 if (kind === 'terminal' && args.trim().toLowerCase() === 'setup') return terminalSetup($)
148 await refresh($)
149 const at = openTarget(await read($, snapshot), args)
150 if ('error' in at) return { text: `${GLYPH.blocked} ${at.error}` }
151 const r = await openIn($, kind, at.dir)
152 const note = at.note ? ` (${at.note})` : ''
153 const glyph = !r.ok ? GLYPH.blocked : r.needsSetup ? GLYPH.deviation : GLYPH.next
154 return { text: `${glyph} ${r.message}${r.ok ? note : ''}` }
155}
156
157function actions($: EngineInterface): Actions {
158 return {
159 openPane: (tab, folder) => void openPane($, tab, folder),
160 setTab: tab => void setTab($, tab),
161 pick: folder => void pick($, folder),
162 draft: text => void draft($, text),
163 open: (kind, dir) => void openFromButton($, kind, dir),
164 editorName: EDITOR_NAME[editorApp] ?? editorApp,
165 }
166}
167
168// Nothing here guards anything, so each .catch fails open: a hook that throws
169// lets the event go on as if the mod weren't there, and where it had already
170// called next(e), next(e) replays that answer, so nothing runs twice.
171export const register: Register = (on, options) => {
172 if (typeof options.terminal === 'string' && options.terminal) terminalApp = options.terminal
173 if (typeof options.editor === 'string' && options.editor) editorApp = options.editor
174 if (typeof options.splitSide === 'string' && options.splitSide) splitSide = options.splitSide
175 if (typeof options.splitSize === 'number') splitSize = options.splitSize
176
177 on('session.start', async ($, e, next) => {
178 const started = await next(e)
179 await $.command.register({
180 name: COMMAND,
181 description: 'Where the flow task stands: phases, gates, progress (no model turn)',
182 immediate: true,
183 })
184 await $.command.register({
185 name: 'flow-pane',
186 description: 'Open the flow pane: progress, plan, decisions, deviations, tasks',
187 argumentHint: '[progress|plan|decisions|deviations|tasks]',
188 immediate: true,
189 })
190 await $.command.register({
191 name: 'flow-term',
192 description: "Open a terminal in the flow task's worktree, beside this session (an iTerm split unless the plugin's settings say otherwise)",
193 argumentHint: '[task | setup]',
194 immediate: true,
195 })
196 await $.command.register({
197 name: 'flow-code',
198 description: "Open the flow task's worktree in the editor (VS Code unless the plugin's settings say otherwise)",
199 argumentHint: '[task]',
200 immediate: true,
201 })
202 if (!e.isInteractive) return started
203 await refresh($)
204 let ticks = 0
205 $.clock.every(TICK_MS, () => {
206 ticks += 1
207 if (isDirty || ticks % (hasTasks ? POLL_TICKS : QUIET_TICKS) === 0) void refresh($)
208 })
209 return started
210 })
211
212 // /clear, /resume and /branch reset $.state; the next tick fills it again
213 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, ($, e, next) => {
214 isDirty = true
215 return next(e)
216 }).catch(($, e, next) => next(e))
217
218 on('turn.complete', ($, e, next) => {
219 isDirty = true
220 return next(e)
221 })
222
223 // Edits, shell commands and worktree moves can change a task. Mod 4: a
224 // refusal from the validator is said where the person will see it.
225 on('tool.call', async ($, e, next) => {
226 const result = await next(e)
227 if (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'NotebookEdit' || e.tool === 'Bash'
228 || e.tool === 'EnterWorktree' || e.tool === 'ExitWorktree') {
229 isDirty = true
230 }
231 if (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'Bash') {
232 const why = blockReason(result.deny ?? (result.isError ? result.text : undefined), e.tool)
233 if (why) $.ui.toast(`${GLYPH.blocked} flow blocked: ${why}`)
234 }
235 return result
236 }).catch(($, e, next) => next(e))
237
238 // Mod 1: the band
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 if (e.props.hasSurvey) return next(e)
241 const mine = bandTree(kit($, e), await read($, snapshot), e.props, actions($))
242 if (!mine) return next(e)
243 const { Box } = $.ui.resolve(e)
244 return (
245 <Box flexDirection="column">
246 {mine}
247 {await next(e)}
248 </Box>
249 )
250 })
251
252 // Mod 2: the pane
253 on('command.run', { command: 'flow-pane' }, async ($, e) => {
254 const asked = e.args.trim().toLowerCase()
255 const tab = TABS.find(t => t === asked)
256 await update($, picked, () => null)
257 await refresh($)
258 await openPane($, tab)
259 return asked && !tab ? { text: `flow-pane: no tab "${asked}" — try ${TABS.join(', ')}` } : {}
260 })
261
262 on('ui.render', { component: 'Pane', requestId: 'flow' }, async ($, e) =>
263 paneTree(kit($, e), await read($, snapshot), await read($, paneTab), await read($, picked), e.props.bodyColumns, actions($)),
264 )
265
266 // a closed pane follows the session's task again next time it opens
267 on('ui.close', { id: 'flow' }, async ($, e, next) => {
268 await update($, picked, () => null)
269 return next(e)
270 }).catch(($, e, next) => next(e))
271
272 // Mod 3: /flow-status
273 on('command.run', { command: 'flow-status' }, async $ => {
274 await refresh($)
275 return { text: [FENCE, ...statusReport(await read($, snapshot)), FENCE].join('\n') }
276 }).catch(() => ({ text: `${GLYPH.blocked} flow-status: the task state could not be read` }))
277
278 // /flow-status, and /flow-term setup's checklist, coloured line by line
279 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
280 const isMine = e.props.command.endsWith(COMMAND) || (e.props.command.endsWith('flow-term') && e.props.text.includes(FENCE))
281 if (!isMine || e.props.isErrored) return next(e)
282 return statusRow(kit($, e), e.props.text)
283 })
284
285 // A terminal or the editor in a worktree, by command; the buttons are in look.tsx
286 on('command.run', { command: 'flow-term' }, async ($, e) => {
287 return openFromCommand($, 'terminal', e.args)
288 }).catch(() => ({ text: `${GLYPH.blocked} flow-term: the terminal could not be opened` }))
289
290 on('command.run', { command: 'flow-code' }, async ($, e) => {
291 return openFromCommand($, 'editor', e.args)
292 }).catch(() => ({ text: `${GLYPH.blocked} flow-code: the editor could not be opened` }))
293
294 // Mod 5: the spinner (the status line is set by each refresh)
295 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
296 const task = focusTask(await read($, snapshot))
297 if (!task) return next(e)
298 return spinnerTree(kit($, e), task, await next(e))
299 })
300}
301hooks/mod/band.tsx 71 lines1// Mod 1: the phase rail in the band above the prompt. Always there while the
2// session's branch has an active task; framed in periwinkle, or in straw
3// while a gate waits on the person.
4
5import type { RenderElement, RenderPropsOf } from 'claude-code'
6
7import type { Snapshot } from '../../types'
8import { Bar, GateActions, GateTitle, ink, OpenButtons, Rail } from './look'
9import type { Actions, Els } from './look'
10import { GLYPH, hereTask, openTarget, plural, statusLine, waitingGate } from './model'
11
12const ROWS = 5 // frame (2) + heading, rail, next; a waiting gate adds its actions' row
13
14/** The band's tree, or null to leave the band as it is. */
15export function bandTree(E: Els, s: Snapshot | null, props: RenderPropsOf['AbovePrompt'], act: Actions): RenderElement | null {
16 const task = hereTask(s)
17 const elsewhere = s?.tasks.filter(t => t.isActive && !t.isHere) ?? []
18 if (!task && elsewhere.length === 0) return null
19
20 if (!task) {
21 // nothing on this branch: one quiet line, so other work isn't forgotten
22 return (
23 <E.Box flexDirection="row" columnGap={2}>
24 <E.Text dimColor>{`flow · ${plural(elsewhere.length, 'active task')} on other branches`}</E.Text>
25 <E.Button key="band-tasks" label="tasks" hotkey="o" plain dimColor onPress={() => act.openPane('tasks')} />
26 {OpenButtons(E, act, openTarget(s), 'band')}
27 </E.Box>
28 )
29 }
30
31 const gate = waitingGate(task)
32 if (props.maxRows < ROWS + (gate ? 1 : 0)) {
33 // no room for the frame: the status line's words, in the rail's colour
34 return <E.Text {...ink(E, gate ? 'you' : 'current')} bold wrap="truncate-end">{statusLine(task)}</E.Text>
35 }
36
37 return (
38 <E.Box flexDirection="column" borderStyle="round" borderColor={gate ? E.paint.you : E.paint.current} paddingX={1}>
39 <E.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
40 <E.Text wrap="truncate-end">
41 <E.Text color={E.paint.current} bold>flow</E.Text>
42 <E.Text dimColor>{' │ '}</E.Text>
43 <E.Text bold>{task.slug}</E.Text>
44 <E.Text dimColor>{` ${[task.mode, task.ticketId].filter(Boolean).join(' · ')}`}</E.Text>
45 {task.deviations.length > 0 && (
46 <E.Text color={E.paint.wrong}>{` ${GLYPH.deviation} ${plural(task.deviations.length, 'deviation')}`}</E.Text>
47 )}
48 </E.Text>
49 {/* the bar keeps its width; the title is what gives way */}
50 <E.Box flexShrink={0}>{Bar(E, task)}</E.Box>
51 </E.Box>
52 {Rail(E, task, Math.max(20, props.bodyColumns - 4))}
53 <E.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
54 {gate ? (
55 GateTitle(E, gate)
56 ) : (
57 <E.Text wrap="truncate-end">
58 <E.Text color={E.paint.current} bold>{`${GLYPH.next} next `}</E.Text>
59 <E.Text>{task.nextAction || '—'}</E.Text>
60 </E.Text>
61 )}
62 <E.Box flexDirection="row" columnGap={2} flexShrink={0}>
63 {!gate && <E.Button key="band-open" label="pane" hotkey="o" plain dimColor onPress={() => act.openPane('progress', task.folder)} />}
64 {OpenButtons(E, act, openTarget(s), 'band')}
65 </E.Box>
66 </E.Box>
67 {gate && GateActions(E, act, task, gate)}
68 </E.Box>
69 )
70}
71hooks/mod/look.tsx 154 lines1// Drawing helpers every flow surface shares, so the rail, the bar and the gate
2// row look the same in the band, the pane and /flow-status.
3
4import type { Elements, RenderElement, TextProps } from 'claude-code'
5
6import type { Gate, PaneTab, Task } from '../../types'
7import { bar, GLYPH, glyphOf, gateWord, showsProgress, steps } from './model'
8import type { OpenKind, OpenTarget, Palette, Role, Step } from './model'
9
10/** A surface's elements, and the palette that surface draws the roles in. */
11export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown'> & { paint: Palette }
12
13export const PANE = 'flow'
14
15/**
16 * What a drawing's controls do. The trees are built here, away from `$`;
17 * register.tsx makes these from its own functions, where the engine calls are.
18 */
19export interface Actions {
20 openPane(tab?: PaneTab, folder?: string): void
21 setTab(tab: PaneTab): void
22 pick(folder: string): void
23 /**
24 * Approve / request changes put the words in the prompt and stop there: the
25 * person presses Enter, so the approval the skill records is their own
26 * message, and the validator stays the only thing that opens a gate.
27 */
28 draft(text: string): void
29 /** A terminal or the editor in `dir`, the apps the plugin's settings name. */
30 open(kind: OpenKind, dir: string): void
31 /** The editor setting as the button reads it: `vs code`. */
32 editorName: string
33}
34
35/** Text props for a role: one of the four colours, or dim for not-yet. */
36export function ink(E: Els, role: Role | 'plain'): TextProps {
37 if (role === 'todo') return { dimColor: true }
38 if (role === 'plain') return {}
39 return { color: E.paint[role] }
40}
41
42function stepLabel(step: Step, size: 'long' | 'short' | 'glyph'): string {
43 const glyph = glyphOf(step)
44 if (step.kind === 'gate') return size === 'glyph' ? glyph : `${glyph}${step.gate.n}`
45 if (size === 'glyph') return glyph
46 return `${glyph} ${size === 'long' ? step.phase : step.short}`
47}
48
49/** The horizontal rail, at the widest label set that fits `width`. */
50export function Rail(E: Els, task: Task, width: number): RenderElement {
51 const all = steps(task)
52 const sep = (size: 'long' | 'short' | 'glyph') => (size === 'glyph' ? '' : ' ─ ')
53 const fits = (size: 'long' | 'short' | 'glyph') =>
54 all.map(s => stepLabel(s, size)).join(sep(size)).length <= width
55 const size = fits('long') ? 'long' : fits('short') ? 'short' : 'glyph'
56 const parts: RenderElement[] = []
57 all.forEach((step, i) => {
58 if (i > 0 && sep(size)) parts.push(<E.Text dimColor>{sep(size)}</E.Text>)
59 parts.push(
60 <E.Text {...ink(E, step.role)} bold={step.role === 'current' || step.role === 'you'}>
61 {stepLabel(step, size)}
62 </E.Text>,
63 )
64 })
65 if (size === 'glyph') {
66 const now = all.find(s => s.role === 'current' || s.role === 'you')
67 if (now) parts.push(<E.Text {...ink(E, now.role)} bold>{' ' + (now.kind === 'gate' ? `gate ${now.gate.n}` : now.phase)}</E.Text>)
68 }
69 return <E.Text wrap="truncate-end">{parts}</E.Text>
70}
71
72/** `2/4 [##########----------] 50%`: the filled part coloured, the rest plain. */
73export function Bar(E: Els, task: Task): RenderElement | null {
74 if (!showsProgress(task)) return null
75 const { done, total } = task.progress
76 const b = bar(done, total)
77 const role = done >= total ? 'done' : 'current'
78 return (
79 <E.Text>
80 <E.Text {...ink(E, role)}>{`${done}/${total} [`}</E.Text>
81 <E.Text {...ink(E, role)}>{b.filled}</E.Text>
82 <E.Text>{b.empty}</E.Text>
83 <E.Text {...ink(E, role)}>{`] ${b.percent}%`}</E.Text>
84 </E.Text>
85 )
86}
87
88/** `── Title ─────────`: a coloured heading across the width, for separation. */
89export function Rule(E: Els, title: string, width: number, role: Role | 'plain' = 'current'): RenderElement {
90 const head = `── ${title} `
91 return (
92 <E.Text wrap="truncate-end">
93 <E.Text dimColor>{'── '}</E.Text>
94 <E.Text {...ink(E, role)} bold>{title}</E.Text>
95 <E.Text dimColor>{' ' + '─'.repeat(Math.max(0, width - head.length))}</E.Text>
96 </E.Text>
97 )
98}
99
100export function approvalText(g: Gate): string {
101 return g.n === 1 ? 'Gate 1: I approve the plan.' : 'Gate 2: I approve the merge.'
102}
103
104export function changesText(g: Gate): string {
105 return `Gate ${g.n}: changes requested — `
106}
107
108/** What a waiting gate asks, in straw. */
109export function GateTitle(E: Els, g: Gate): RenderElement {
110 return <E.Text color={E.paint.you} bold wrap="truncate-end">{`${GLYPH.gateWaiting} gate ${g.n} · ${gateWord(g)} is yours to give`}</E.Text>
111}
112
113/** A waiting gate's three actions, together on a row of their own. */
114export function GateActions(E: Els, act: Actions, task: Task, g: Gate): RenderElement {
115 const you = E.paint.you
116 const doc = g.n === 1 ? 'plan' : 'report'
117 return (
118 <E.Box flexDirection="row" columnGap={3} flexWrap="wrap">
119 <E.Button key={`gate-${g.n}-open`} label={`open ${doc}`} hotkey="p" plain
120 onPress={() => act.openPane(g.n === 1 ? 'plan' : 'progress', task.folder)}>
121 <E.Text color={you}>{`open ${doc}`}</E.Text>
122 </E.Button>
123 <E.Button key={`gate-${g.n}-approve`} label="approve" hotkey="a" plain onPress={() => act.draft(approvalText(g))}>
124 <E.Text color={you} bold>approve</E.Text>
125 </E.Button>
126 <E.Button key={`gate-${g.n}-changes`} label="request changes" hotkey="r" plain onPress={() => act.draft(changesText(g))}>
127 <E.Text color={you}>request changes</E.Text>
128 </E.Button>
129 </E.Box>
130 )
131}
132
133/** The straw block a waiting gate draws: what's asked, then the actions. */
134export function GateRow(E: Els, act: Actions, task: Task, g: Gate): RenderElement {
135 return (
136 <E.Box flexDirection="column">
137 {GateTitle(E, g)}
138 {GateActions(E, act, task, g)}
139 </E.Box>
140 )
141}
142
143/**
144 * `t: terminal e: vs code`, opening `at`'s folder; dim until wanted. `where`
145 * keeps the keys apart when the band and the pane both draw a pair.
146 */
147export function OpenButtons(E: Els, act: Actions, at: OpenTarget, where: string): RenderElement[] {
148 if ('error' in at) return []
149 return [
150 <E.Button key={`${where}-terminal`} label="terminal" hotkey="t" plain dimColor onPress={() => act.open('terminal', at.dir)} />,
151 <E.Button key={`${where}-editor`} label={act.editorName} hotkey="e" plain dimColor onPress={() => act.open('editor', at.dir)} />,
152 ]
153}
154hooks/mod/model.ts 265 lines1// Pure reading of a flow snapshot: what each surface says, before any drawing.
2// No `$`, no elements, so every surface agrees on what a task's state *is* and
3// differs only in how it draws it.
4
5import type { Gate, GateState, Snapshot, Task } from '../../types'
6
7// The four colours of the progress bars in the person's own status line
8// (~/.claude/statusline-command.sh), each given one meaning here and nowhere
9// else. Anything not yet reached is drawn dim.
10export const ANSI = {
11 current: 67, // periwinkle: where the task is now
12 done: 108, // sage: finished, approved
13 you: 186, // straw: waiting on the person (a gate)
14 wrong: 210, // rose: blocked, a deviation
15} as const
16
17// The same four as the xterm palette defines them, for surfaces that take hex.
18export const HEX = { current: '#5f87af', done: '#87af87', you: '#d7d787', wrong: '#ff8787' } as const
19
20export type Palette = Record<keyof typeof ANSI, string>
21
22/**
23 * The terminal is given the ANSI-256 index itself: hex there is downsampled,
24 * and #87af87 lands on 145, a grey, not the status line's 108.
25 */
26export function paletteFor(surface: string): Palette {
27 if (surface !== 'terminal') return HEX
28 return {
29 current: `ansi256(${ANSI.current})`,
30 done: `ansi256(${ANSI.done})`,
31 you: `ansi256(${ANSI.you})`,
32 wrong: `ansi256(${ANSI.wrong})`,
33 }
34}
35
36export type Role = keyof typeof ANSI | 'todo'
37
38export const GLYPH = {
39 done: '✓',
40 current: '●',
41 todo: '○',
42 gatePassed: '◆',
43 gateWaiting: '◈',
44 gateAhead: '◇',
45 next: '▸',
46 deviation: '▲',
47 blocked: '⛔',
48} as const
49
50const SHORT: Record<string, string> = {
51 setup: 'setup',
52 research: 'research',
53 planning: 'plan',
54 'plan-review': 'plan-rev',
55 implementation: 'impl',
56 'code-review': 'review',
57 reporting: 'report',
58 'wind-down': 'wrap',
59}
60
61export type PhaseStep = { kind: 'phase'; phase: string; short: string; role: 'done' | 'current' | 'todo' }
62export type GateStep = { kind: 'gate'; gate: Gate; role: 'done' | 'you' | 'todo' }
63export type Step = PhaseStep | GateStep
64
65const GATE_ROLE: Record<GateState, GateStep['role']> = { passed: 'done', waiting: 'you', ahead: 'todo' }
66
67/** The task's chain as the rail draws it: phases with their gates between. */
68export function steps(task: Task): Step[] {
69 const phases = task.chain.filter(p => p !== 'complete')
70 const at = task.phase === 'complete' ? phases.length : task.phaseIndex
71 const out: Step[] = []
72 phases.forEach((phase, i) => {
73 const gate = task.gates.find(g => g.before === phase)
74 if (gate) out.push({ kind: 'gate', gate, role: GATE_ROLE[gate.state] })
75 const role = i < at ? 'done' : i === at ? 'current' : 'todo'
76 out.push({ kind: 'phase', phase, short: SHORT[phase] ?? phase, role })
77 })
78 return out
79}
80
81export function glyphOf(step: Step): string {
82 if (step.kind === 'phase') return GLYPH[step.role]
83 return step.role === 'done' ? GLYPH.gatePassed : step.role === 'you' ? GLYPH.gateWaiting : GLYPH.gateAhead
84}
85
86export function waitingGate(task: Task): Gate | undefined {
87 return task.gates.find(g => g.state === 'waiting')
88}
89
90/** The task a surface shows: the one picked in the pane, else the session's. */
91export function focusTask(s: Snapshot | null, picked: string | null = null): Task | null {
92 if (!s) return null
93 return s.tasks.find(t => t.folder === picked) ?? s.tasks.find(t => t.folder === s.current) ?? null
94}
95
96export function hereTask(s: Snapshot | null): Task | null {
97 return focusTask(s, null)
98}
99
100/** `[##########----------]` split at the fill, the status line's bar exactly. */
101export function bar(done: number, total: number, width = 20): { filled: string; empty: string; percent: number } {
102 const ratio = total > 0 ? Math.min(1, done / total) : 0
103 const n = Math.floor(ratio * width)
104 return { filled: '#'.repeat(n), empty: '-'.repeat(width - n), percent: Math.round(ratio * 100) }
105}
106
107/** Progress shows from implementation on, once there is a checklist to count. */
108export function showsProgress(task: Task): boolean {
109 const at = task.chain.indexOf('implementation')
110 return task.progress.total > 0 && at >= 0 && (task.phase === 'complete' || task.phaseIndex >= at)
111}
112
113export function gateWord(g: Gate): string {
114 return g.n === 1 ? 'plan approval' : 'merge approval'
115}
116
117/** The status line's words, which the engine heads with the plugin's name. */
118export function statusPin(task: Task | null): string | undefined {
119 return statusLine(task)?.replace(/^flow /, '')
120}
121
122/** One line for the spinner and the band's collapsed form. */
123export function statusLine(task: Task | null): string | undefined {
124 if (!task) return undefined
125 const g = waitingGate(task)
126 if (g) return `flow ${GLYPH.gateWaiting} gate ${g.n} · ${gateWord(g)} waiting · ${task.slug}`
127 const count = showsProgress(task) ? ` ${task.progress.done}/${task.progress.total}` : ''
128 return `flow ${GLYPH.current} ${task.phase}${count} · ${task.slug}`
129}
130
131/** The lines /flow-status prints; each one's leading glyph names its colour. */
132export function statusReport(s: Snapshot | null): string[] {
133 if (!s || s.error) return [`${GLYPH.blocked} flow: no snapshot${s?.error ? ` — ${s.error}` : ''}`]
134 const task = hereTask(s)
135 const lines: string[] = []
136 if (!task) {
137 lines.push(`flow · no active task on ${s.branch ?? 'this checkout'}`)
138 } else {
139 lines.push(`▌ ${task.slug}${task.title ? ` — ${task.title}` : ''}`)
140 lines.push(` ${meta(task)}`)
141 for (const step of steps(task)) {
142 if (step.kind === 'gate') {
143 const g = step.gate
144 const tail = g.state === 'passed' ? ` — approved ${g.date}` : g.state === 'waiting' ? ` — ${gateWord(g)} is yours to give` : ''
145 lines.push(` ${glyphOf(step)} gate ${g.n}${tail}`)
146 } else {
147 let tail = ''
148 if (step.role === 'current' && showsProgress(task)) {
149 const b = bar(task.progress.done, task.progress.total)
150 tail = ` — ${task.progress.done}/${task.progress.total} [${b.filled}${b.empty}] ${b.percent}%`
151 }
152 lines.push(` ${glyphOf(step)} ${step.phase}${tail}`)
153 }
154 }
155 if (task.phase === 'complete') lines.push(` ${GLYPH.done} complete`)
156 if (task.nextAction) lines.push(` ${GLYPH.next} next: ${task.nextAction}`)
157 if (task.deviations.length) lines.push(` ${GLYPH.deviation} ${plural(task.deviations.length, 'deviation')}`)
158 }
159 const others = s.tasks.filter(t => t.isActive && t !== task)
160 if (others.length) {
161 lines.push('')
162 lines.push(` other active tasks`)
163 for (const t of others) lines.push(` · ${t.slug} — ${t.phase} · branch ${t.branch}`)
164 }
165 if (!task && !others.length) lines.push(' no flow tasks in this repo — /flow <description> starts one')
166 return lines
167}
168
169/**
170 * A worktree as the Tasks tab names it: beside the repo's root it reads
171 * `../acme-api-audit-log`, and elsewhere in full.
172 */
173export function worktreeLabel(worktree: string, root: string): string {
174 const parent = root.slice(0, root.lastIndexOf('/') + 1)
175 return parent.length > 1 && worktree.startsWith(parent) ? `../${worktree.slice(parent.length)}` : worktree
176}
177
178export function meta(task: Task): string {
179 return [task.mode, task.ticketId, task.branch && `branch ${task.branch}`, task.updated && `updated ${task.updated}`]
180 .filter(Boolean)
181 .join(' · ')
182}
183
184export function plural(n: number, word: string): string {
185 return `${n} ${word}${n === 1 ? '' : 's'}`
186}
187
188/** What changed between two snapshots, as toast lines. None on a first load. */
189export function changes(prev: Snapshot | null, next: Snapshot): string[] {
190 if (!prev) return []
191 const before = new Map(prev.tasks.map(t => [t.folder, t]))
192 const out: string[] = []
193 for (const t of next.tasks) {
194 const was = before.get(t.folder)
195 if (!was) {
196 if (t.isActive) out.push(`flow ${GLYPH.next} new task ${t.slug} · ${t.phase}`)
197 continue
198 }
199 for (const g of t.gates) {
200 const old = was.gates.find(o => o.n === g.n)
201 if (g.state === 'passed' && old?.state !== 'passed') out.push(`${GLYPH.gatePassed} ${t.slug}: gate ${g.n} approved`)
202 if (g.state === 'waiting' && old?.state !== 'waiting') out.push(`${GLYPH.gateWaiting} ${t.slug}: gate ${g.n} — ${gateWord(g)} is yours to give`)
203 }
204 if (t.phase !== was.phase) out.push(`flow ${GLYPH.next} ${t.slug}: ${was.phase} → ${t.phase}`)
205 if (t.deviations.length > was.deviations.length) out.push(`${GLYPH.deviation} ${t.slug}: deviation recorded`)
206 }
207 return out
208}
209
210/**
211 * The validator's refusal text, when a tool result is one. A failed shell
212 * command's own output can say 'flow: ' too (a CI log's "workflow: ci"), so
213 * for Bash only the lockdown's own wording counts.
214 */
215export function blockReason(text: string | undefined, tool = 'Edit'): string | undefined {
216 if (!text) return undefined
217 const line = text.split('\n').find(l => l.includes('flow: ')
218 && (tool !== 'Bash' || l.includes('code edits are locked')))
219 if (!line) return undefined
220 const reason = line.slice(line.indexOf('flow: ') + 'flow: '.length).trim()
221 return reason.length > 140 ? `${reason.slice(0, 139)}…` : reason
222}
223
224/** Which role colours a /flow-status line, read off its leading glyph. */
225export function lineRole(line: string): Role | 'title' | 'plain' {
226 const head = line.trimStart().slice(0, 1)
227 if (head === '▌') return 'title'
228 if (head === GLYPH.done || head === GLYPH.gatePassed) return 'done'
229 if (head === GLYPH.current || head === GLYPH.next) return 'current'
230 if (head === GLYPH.gateWaiting) return 'you'
231 if (head === GLYPH.deviation || head === GLYPH.blocked) return 'wrong'
232 if (head === GLYPH.todo || head === GLYPH.gateAhead) return 'todo'
233 return 'plain'
234}
235
236export type OpenKind = 'terminal' | 'editor'
237
238/** The editor setting's values, as the buttons name them. */
239export const EDITOR_NAME: Record<string, string> = { vscode: 'vs code', cursor: 'cursor', zed: 'zed' }
240
241export type OpenTarget = { dir: string; label: string; note?: string } | { error: string }
242
243/**
244 * Where a terminal or an editor opens: the named task's worktree (by slug,
245 * folder or ticket id), else the session's task's, else the session's own
246 * checkout, so the buttons and /flow-term, /flow-code always have somewhere.
247 */
248export function openTarget(s: Snapshot | null, asked = ''): OpenTarget {
249 if (!s || s.error || !s.root) return { error: 'the repo has not been read yet — try again in a moment' }
250 const a = asked.trim().toLowerCase()
251 if (!a) {
252 const t = hereTask(s)
253 return t ? taskTarget(s, t) : { dir: s.root, label: 'this checkout' }
254 }
255 const t = s.tasks.find(x => [x.slug, x.folder, x.ticketId].some(n => n && n.toLowerCase() === a))
256 if (!t) return { error: `no task "${asked.trim()}" — ${s.tasks.map(x => x.slug).join(', ') || 'this repo has no flow tasks'}` }
257 return taskTarget(s, t)
258}
259
260export function taskTarget(s: Snapshot, t: Task): OpenTarget {
261 // a task whose branch no worktree has checked out lives only in the root
262 const isAway = t.worktree === s.root && !t.isHere && !!t.branch && t.branch !== s.branch
263 return { dir: t.worktree, label: t.slug, ...(isAway ? { note: `branch ${t.branch} isn't checked out here` } : {}) }
264}
265hooks/mod/pane.tsx 245 lines1// Mod 2: the flow pane. /flow-pane (or the band's buttons) opens it; five
2// tabs, each under its own coloured heading: Progress, Plan, Decisions,
3// Deviations, and every task across the repo's worktrees.
4
5import type { RenderElement } from 'claude-code'
6
7import type { PaneTab, Snapshot, Task } from '../../types'
8import { Bar, GateRow, ink, OpenButtons, Rule } from './look'
9import type { Actions, Els } from './look'
10import { bar, focusTask, GLYPH, glyphOf, meta, openTarget, plural, showsProgress, steps, taskTarget, waitingGate, worktreeLabel } from './model'
11
12const TABS: { id: PaneTab; label: string; hotkey: string }[] = [
13 { id: 'progress', label: 'Progress', hotkey: '1' },
14 { id: 'plan', label: 'Plan', hotkey: '2' },
15 { id: 'decisions', label: 'Decisions', hotkey: '3' },
16 { id: 'deviations', label: 'Deviations', hotkey: '4' },
17 { id: 'tasks', label: 'Tasks', hotkey: '5' },
18]
19
20/** The pane's whole body: tabs, a rule, then the open tab. */
21export function paneTree(E: Els, s: Snapshot | null, tab: PaneTab, picked: string | null, columns: number, act: Actions): RenderElement {
22 const task = focusTask(s, picked)
23 const width = Math.max(20, columns)
24 return (
25 <E.Box flexDirection="column">
26 {Tabs(E, act, tab, task)}
27 <E.Text dimColor>{'─'.repeat(width)}</E.Text>
28 {s && OpenRow(E, act, s, task)}
29 {tab !== 'tasks' && task && Heading(E, task)}
30 {tab === 'tasks' ? TasksTab(E, act, s, task, width) : !task ? NoTask(E, s) : body(E, act, tab, task, width)}
31 </E.Box>
32 )
33}
34
35function body(E: Els, act: Actions, tab: PaneTab, task: Task, width: number): RenderElement {
36 if (tab === 'plan') return PlanTab(E, task, width)
37 if (tab === 'decisions') return DecisionsTab(E, task, width)
38 if (tab === 'deviations') return DeviationsTab(E, task, width)
39 return ProgressTab(E, act, task, width)
40}
41
42function Tabs(E: Els, act: Actions, tab: PaneTab, task: Task | null): RenderElement {
43 return (
44 <E.Box flexDirection="row" columnGap={3} flexWrap="wrap">
45 {TABS.map(t => {
46 const isOn = t.id === tab
47 const count = !task ? '' : t.id === 'decisions' ? ` ${task.decisions.length}` : t.id === 'deviations' ? ` ${task.deviations.length}` : ''
48 const alarm = t.id === 'deviations' && (task?.deviations.length ?? 0) > 0
49 return (
50 <E.Button key={`tab-${t.id}`} label={t.label} hotkey={t.hotkey} plain onPress={() => act.setTab(t.id)}>
51 <E.Text {...(isOn ? ink(E, 'current') : { dimColor: true })} bold={isOn} underline={isOn}>{t.label}</E.Text>
52 {count && <E.Text {...(alarm ? ink(E, 'wrong') : { dimColor: true })}>{count}</E.Text>}
53 </E.Button>
54 )
55 })}
56 </E.Box>
57 )
58}
59
60/** Where the shown task's worktree is, and a terminal or the editor there. */
61function OpenRow(E: Els, act: Actions, s: Snapshot, task: Task | null): RenderElement | null {
62 const at = task ? taskTarget(s, task) : openTarget(s)
63 if ('error' in at) return null
64 return (
65 <E.Box flexDirection="row" justifyContent="space-between" columnGap={2} marginBottom={1}>
66 <E.Text dimColor wrap="truncate-start">{at.dir}</E.Text>
67 <E.Box flexDirection="row" columnGap={2} flexShrink={0}>{OpenButtons(E, act, at, 'pane')}</E.Box>
68 </E.Box>
69 )
70}
71
72function Heading(E: Els, task: Task): RenderElement {
73 return (
74 <E.Box flexDirection="column" marginBottom={1}>
75 <E.Text wrap="truncate-end">
76 <E.Text color={E.paint.current} bold>{task.slug}</E.Text>
77 {task.title && <E.Text>{` ${task.title}`}</E.Text>}
78 </E.Text>
79 <E.Text dimColor wrap="truncate-end">{meta(task)}</E.Text>
80 </E.Box>
81 )
82}
83
84function NoTask(E: Els, s: Snapshot | null): RenderElement {
85 const others = s?.tasks.filter(t => t.isActive).length ?? 0
86 return (
87 <E.Box flexDirection="column">
88 <E.Text dimColor>{`No active flow task on ${s?.branch ?? 'this checkout'}.`}</E.Text>
89 <E.Text dimColor>{others ? `${plural(others, 'active task')} elsewhere — see 5: Tasks.` : '/flow <description> starts one.'}</E.Text>
90 </E.Box>
91 )
92}
93
94function ProgressTab(E: Els, act: Actions, task: Task, width: number): RenderElement {
95 const gate = waitingGate(task)
96 const items = task.progress.items
97 const firstOpen = items.findIndex(i => !i.done)
98 return (
99 <E.Box flexDirection="column">
100 {Rule(E, 'Phases', width)}
101 <E.Box flexDirection="column" paddingLeft={1} marginBottom={1}>
102 {steps(task).map(step => {
103 const label = step.kind === 'gate' ? `gate ${step.gate.n}` : step.phase
104 const strong = step.role === 'current' || step.role === 'you'
105 let tail: RenderElement | null = null
106 if (step.kind === 'gate' && step.gate.state === 'passed') tail = <E.Text dimColor>{` approved ${step.gate.date}`}</E.Text>
107 if (step.kind === 'gate' && step.gate.state === 'waiting') tail = <E.Text color={E.paint.you}>{' waiting on you'}</E.Text>
108 if (step.kind === 'phase' && step.role === 'current' && showsProgress(task)) tail = <E.Text>{' '}{Bar(E, task)}</E.Text>
109 return (
110 <E.Text wrap="truncate-end">
111 <E.Text {...ink(E, step.role)} bold={strong}>{`${glyphOf(step)} ${label}`}</E.Text>
112 {tail}
113 </E.Text>
114 )
115 })}
116 {task.phase === 'complete' && <E.Text color={E.paint.done} bold>{`${GLYPH.done} complete`}</E.Text>}
117 </E.Box>
118
119 {Rule(E, gate ? 'Waiting on you' : 'Next', width, gate ? 'you' : 'current')}
120 <E.Box flexDirection="column" paddingLeft={1} marginBottom={1}>
121 <E.Text>{task.nextAction || '—'}</E.Text>
122 {gate && GateRow(E, act, task, gate)}
123 </E.Box>
124
125 {items.length > 0 && Rule(E, `Checklist ${task.progress.done}/${task.progress.total}`, width, task.progress.done === task.progress.total ? 'done' : 'current')}
126 {items.length > 0 && (
127 <E.Box flexDirection="column" paddingLeft={1} marginBottom={1}>
128 {items.map((item, i) => {
129 const role = item.done ? 'done' : i === firstOpen ? 'current' : 'todo'
130 const glyph = item.done ? GLYPH.done : i === firstOpen ? GLYPH.next : GLYPH.todo
131 return (
132 <E.Text wrap="truncate-end">
133 <E.Text {...ink(E, role)} bold={role === 'current'}>{`${glyph} `}</E.Text>
134 <E.Text {...(role === 'todo' ? { dimColor: true } : {})}>{item.text}</E.Text>
135 </E.Text>
136 )
137 })}
138 </E.Box>
139 )}
140
141 {task.deviations.length > 0 && Rule(E, plural(task.deviations.length, 'deviation'), width, 'wrong')}
142 {task.deviations.length > 0 && (
143 <E.Box flexDirection="column" paddingLeft={1}>
144 {task.deviations.map(d => <E.Text color={E.paint.wrong} wrap="truncate-end">{`${GLYPH.deviation} ${d}`}</E.Text>)}
145 </E.Box>
146 )}
147 </E.Box>
148 )
149}
150
151function PlanTab(E: Els, task: Task, width: number): RenderElement {
152 if (!task.plan) {
153 return (
154 <E.Box flexDirection="column">
155 {Rule(E, 'Plan', width, 'todo')}
156 <E.Text dimColor>No plan.md yet — the planning phase writes it.</E.Text>
157 </E.Box>
158 )
159 }
160 return (
161 <E.Box flexDirection="column">
162 {Rule(E, 'Plan', width)}
163 <E.Text dimColor wrap="truncate-start">{task.planPath ?? ''}</E.Text>
164 <E.Markdown key="plan" text={task.plan} />
165 </E.Box>
166 )
167}
168
169function DecisionsTab(E: Els, task: Task, width: number): RenderElement {
170 return (
171 <E.Box flexDirection="column">
172 {Rule(E, 'Decision log', width)}
173 {task.decisions.length === 0 && <E.Text dimColor>Nothing logged yet.</E.Text>}
174 {task.decisions.map(d => {
175 const isGate = /\bgate \d:/.test(d)
176 const isQa = /\bQ:/.test(d)
177 const glyph = isGate ? GLYPH.gatePassed : isQa ? '?' : '•'
178 return (
179 <E.Text wrap="wrap">
180 <E.Text {...ink(E, isGate ? 'done' : isQa ? 'current' : 'todo')} bold={isGate}>{`${glyph} `}</E.Text>
181 <E.Text {...(isGate ? ink(E, 'done') : {})}>{d}</E.Text>
182 </E.Text>
183 )
184 })}
185 </E.Box>
186 )
187}
188
189function DeviationsTab(E: Els, task: Task, width: number): RenderElement {
190 if (task.deviations.length === 0) {
191 return (
192 <E.Box flexDirection="column">
193 {Rule(E, 'Deviations', width, 'done')}
194 <E.Text color={E.paint.done}>{`${GLYPH.done} None recorded — the work matches the plan.`}</E.Text>
195 </E.Box>
196 )
197 }
198 return (
199 <E.Box flexDirection="column">
200 {Rule(E, plural(task.deviations.length, 'deviation'), width, 'wrong')}
201 {task.deviations.map(d => <E.Text color={E.paint.wrong} wrap="wrap">{`${GLYPH.deviation} ${d}`}</E.Text>)}
202 </E.Box>
203 )
204}
205
206/** One glyph per step, coloured: the whole chain at a glance. */
207function MiniRail(E: Els, task: Task): RenderElement[] {
208 return steps(task).map(step => <E.Text {...ink(E, step.role)}>{glyphOf(step)}</E.Text>)
209}
210
211function TasksTab(E: Els, act: Actions, s: Snapshot | null, shown: Task | null, width: number): RenderElement {
212 const tasks = s?.tasks ?? []
213 const active = tasks.filter(t => t.isActive)
214 const done = tasks.filter(t => !t.isActive)
215 const row = (t: Task) => {
216 const isShown = t.folder === shown?.folder
217 const gate = waitingGate(t)
218 // a worktree's folder already names its task, so the branch would only repeat it
219 const where = t.isHere ? 'here' : t.worktree === s?.root ? `branch ${t.branch}` : worktreeLabel(t.worktree, s?.root ?? '')
220 return (
221 <E.Button key={`task-${t.folder}`} label={t.slug} onPress={() => act.pick(t.folder)} plain>
222 <E.Text wrap="truncate-end">
223 <E.Text {...ink(E, isShown ? 'current' : 'todo')} bold>{isShown ? `${GLYPH.next} ` : ' '}</E.Text>
224 {t.isActive ? MiniRail(E, t) : [<E.Text color={E.paint.done}>{GLYPH.done}</E.Text>]}
225 <E.Text bold={isShown}>{` ${t.slug}`}</E.Text>
226 <E.Text {...ink(E, gate ? 'you' : t.isActive ? 'current' : 'done')}>{` ${gate ? `gate ${gate.n} waiting` : t.phase}`}</E.Text>
227 {showsProgress(t) && t.isActive && <E.Text dimColor>{` ${t.progress.done}/${t.progress.total} ${bar(t.progress.done, t.progress.total).percent}%`}</E.Text>}
228 <E.Text dimColor>{` ${where}`}</E.Text>
229 </E.Text>
230 </E.Button>
231 )
232 }
233 return (
234 <E.Box flexDirection="column">
235 {Rule(E, `Active ${active.length}`, width)}
236 <E.Box flexDirection="column" marginBottom={1}>
237 {active.length === 0 ? <E.Text dimColor>None.</E.Text> : active.map(row)}
238 </E.Box>
239 {done.length > 0 && Rule(E, `Complete ${done.length}`, width, 'done')}
240 {done.length > 0 && <E.Box flexDirection="column">{done.map(row)}</E.Box>}
241 <E.Text dimColor>{'Enter on a task shows it in the other tabs.'}</E.Text>
242 </E.Box>
243 )
244}
245hooks/mod/spinner.tsx 26 lines1// Mod 5 (half): the spinner carries the task's phase while Claude works.
2// The other half, $.ui.status, is set by every refresh in register.tsx.
3
4import type { RenderElement } from 'claude-code'
5
6import type { Task } from '../../types'
7import { ink } from './look'
8import type { Els } from './look'
9import { statusLine, waitingGate } from './model'
10
11/**
12 * The engine's spinner, and under it the task's phase in the rail's colour.
13 * Under, not beside: the spinner takes the row's width, so a line beside it
14 * is pushed to the far edge and cut.
15 */
16export function spinnerTree(E: Els, task: Task, theirs: RenderElement): RenderElement {
17 return (
18 <E.Box flexDirection="column">
19 {theirs}
20 <E.Box paddingLeft={2}>
21 <E.Text {...ink(E, waitingGate(task) ? 'you' : 'current')} wrap="truncate-end">{statusLine(task)}</E.Text>
22 </E.Box>
23 </E.Box>
24 )
25}
26hooks/mod/status.tsx 32 lines1// Mod 3: /flow-status — "where was I?" answered with no model turn.
2//
3// The row's text is the whole answer (a fenced block, so it reads the same
4// with no mod drawing it); the CommandOutput hook only colours it, line by
5// line from each line's leading glyph. An old row keeps the state it printed
6// rather than redrawing from today's snapshot.
7
8import type { RenderElement } from 'claude-code'
9
10import { ink } from './look'
11import type { Els } from './look'
12import { lineRole } from './model'
13
14export const COMMAND = 'flow-status'
15export const FENCE = '```'
16const FENCE_LINE = /^(\S+: )?```$/
17
18/** The row /flow-status printed, each line in its glyph's colour. */
19export function statusRow(E: Els, text: string): RenderElement {
20 // the engine heads the row with the plugin's name, so the first fence reads `flow: ```
21 const lines = text.split('\n').filter(l => !FENCE_LINE.test(l))
22 return (
23 <E.Box flexDirection="column">
24 {lines.map(line => {
25 const role = lineRole(line)
26 if (role === 'title') return <E.Text bold wrap="truncate-end">{line}</E.Text>
27 return <E.Text {...ink(E, role)} bold={role === 'current' || role === 'you'} wrap="truncate-end">{line || ' '}</E.Text>
28 })}
29 </E.Box>
30 )
31}
32types/index.d.ts 73 lines1// The flow mod's contract: the snapshot hooks/flow_status.py prints, and the
2// values the mod keeps in $.state. `claude plugin validate` holds every
3// $.state key hooks/mod/ names to the PluginState entry below.
4
5export type GateState = 'passed' | 'waiting' | 'ahead'
6
7export interface Gate {
8 n: number
9 /** the phase this gate opens */
10 before: string
11 /** waiting: the phase before it is current, so the next step is the user's */
12 state: GateState
13 date: string | null
14}
15
16export interface ProgressItem {
17 text: string
18 done: boolean
19}
20
21export interface Task {
22 slug: string
23 /** the task's folder name under the artifacts dir; the key tasks are told apart by */
24 folder: string
25 title: string
26 ticket: string
27 ticketId: string
28 mode: string
29 phase: string
30 phaseIndex: number
31 chain: string[]
32 branch: string
33 worktree: string
34 created: string
35 updated: string
36 isActive: boolean
37 /** on the session's branch, in the session's own checkout */
38 isHere: boolean
39 dir: string
40 workflowPath: string
41 planPath: string | null
42 reportPath: string | null
43 nextAction: string
44 progress: { done: number; total: number; items: ProgressItem[] }
45 gates: Gate[]
46 decisions: string[]
47 deviations: string[]
48 plan: string | null
49}
50
51export interface Snapshot {
52 schema: number
53 root: string
54 branch: string | null
55 /** folder of the active task on the session's branch, if any */
56 current: string | null
57 tasks: Task[]
58 error?: string
59}
60
61export type PaneTab = 'progress' | 'plan' | 'decisions' | 'deviations' | 'tasks'
62
63declare module 'claude-code' {
64 interface PluginState {
65 flow: {
66 snapshot: Snapshot | null
67 tab: PaneTab
68 /** folder the pane shows; null follows the current task */
69 picked: string | null
70 }
71 }
72}
73