SLOPSHOPPER

flow

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

panebandspinnerrowsguardcommand
v0.8.3Apache-2.0updated 2026-10-13fifersheep/flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flow
│ ┃ flow ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Progress 2: Plan 3: Decisions 4: De │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ No active flow task on this checkout. ⏺ Update(src/auth.ts) │ ┃ /flow <description> starts one. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flow-status │ ⛔ flow: no snapshot │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · flow
1: Progress 2: Plan 3: Decisions 4: Deviations 5: Ta ──────────────────────────────────────────────────────── No active flow task on this checkout. /flow <description> starts one.
Command output
⛔ flow: no snapshot
README

flow

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.

The flow rail above Claude Code's prompt: a task mid-implementation, 3 of 7 steps done, one deviation recorded

Why flow

  • Gates, not good intentions. A PreToolUse hook refuses code edits before you approve the plan (gate 1), and refuses skipping a phase. It isn't a line in a prompt that a long session can forget.
  • State lives in the repo. Each task is a folder with workflow.md (phase, decisions, progress, deviations) and plan.md. Close the session, compact, switch machines: /flow resumes where the file says.
  • Plans that say what they assumed. Gate 1 also requires the plan to list its assumptions, so implementation never starts on a guess nobody saw.
  • You can see where you are. A coloured rail above the prompt, a pane with the plan and checklist, and toasts when anything moves.

Approving gate 1: the straw-framed rail offers approve, which drafts the reply; Enter sends it, flow records the gate and the rail turns to implementation

How it works

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]
  • Full mode runs every phase. Lite mode, for small, well-understood work, skips research and the report; gate 2 then follows code review. Setup recommends one and you confirm.
  • Gate 1 comes after a critic agent has attacked the plan once. Gate 2 comes with a report built from real command output and an open PR.
  • Each task gets its own branch, and a worktree if your .flow.md asks for one.
  • Approval is always your own message. Flow records it in the decision log, and the validator checks that line before it lets the phase move on.

See where you are

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.

The rail at gate 1: framed in straw, with open plan, approve and request changes

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.

Claude tries to edit src/server.ts before gate 1; the flow validator refuses it, and the rail shows gate 1 waiting

/flow-pane. Progress, the plan, the decision log, deviations, and every task across the repo's worktrees, on tabs 1 to 5.

ProgressTasks
The pane's Progress tab: phases, next action, checklist and a deviationThe pane's Tasks tab: two active tasks, one at gate 1 in another worktree, and one complete

/flow-status. The same picture as text, with no model turn.

/flow-status output: each phase with its state, the next action, a deviation, and other active tasks

Work in the worktree

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.

A terminal split opened beside Claude Code by pressing t, in the task's worktree

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.

/flow-term setup: a checklist confirming iTerm, its Python API, the helper package and a connection

Install

/plugin marketplace add fifersheep/flow
/plugin install flow@flow

Then, in a repo:

/flow add per-user rate limits to the public API

Requirements

  • Claude Code with mods; tested on 2.1.296. Without mods you lose the pictures, not the workflow.
  • Python 3.9 or later, and git.
  • For the terminal button: macOS with iTerm, Terminal or Warp, or any OS inside tmux. iTerm splits other than right or below at half need iTerm's Python API (Settings → General → Magic → Enable Python API).

Commands

CommandWhat it does
/flow <description>Start a task: branch, worktree, task folder, mode
/flowResume the task on this branch, or choose between several
/flow statusList active tasks
/flow shape <idea>Brainstorm an idea into agreed tracker tickets (before any task exists)
/flow-statusWhere 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

Configuration

Per repo: a .flow.md at the root, checked in. Copy templates/flow-config-example.md. It sets:

  • the verify command and where task folders and worktrees go
  • optional tracker sync: moves the ticket to In Progress, In Review and Done as the task moves
  • optional per-phase shell commands (## on), run once when a task enters a phase

Per person: the plugin's settings, in /config.

SettingDefaultOptions
Terminaliterm-splititerm-split, iterm-tab, iterm-window, terminal, warp, tmux
Editorvscodevscode, cursor, zed
Split siderightright, left, top, bottom
Split size (%)5010–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.

What the gates do and don't cover

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.

Development

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.

License

Apache License 2.0. Fork it and change it as you like; keep the NOTICE file with anything you redistribute.

Source 8 files
hooks/mod/register.tsx 301 lines
1// 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}
301
hooks/mod/band.tsx 71 lines
1// 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}
71
hooks/mod/look.tsx 154 lines
1// 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}
154
hooks/mod/model.ts 265 lines
1// 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}
265
hooks/mod/pane.tsx 245 lines
1// 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}
245
hooks/mod/spinner.tsx 26 lines
1// 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}
26
hooks/mod/status.tsx 32 lines
1// 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}
32
types/index.d.ts 73 lines
1// 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