/ale-board: a live task board for an Agentic Label Engineering (ALE) run above the prompt and in a docked pane. Reads `python3 -m ale status --json` and the…

Typed task labels, an append-only board, and a zero-LLM watchdog for multi-agent coding work.
When several coding agents work from one plan, the weak point is the word "done". An agent can say it finished while the tests fail, drift outside the files it was meant to touch, or stall without anyone noticing. ALE moves those judgments out of the agents and into code:
ale verify can mark a task accepted, and only after it runs the acceptance commands itself. No agent can mark its own work done.A visual explainer is published at https://yodem.github.io/agentic-label-engineering/ (source: docs/index.html).
PLAN.md ──bake──▶ labels ──init-run──▶ board (events.jsonl)
│
dispatch ─▶ claim ─▶ heartbeat ─▶ submit ─▶ ale verify ─┬─▶ accepted ─▶ integrate
└─▶ rejected ─▶ fix task
Status: pre-1.0. The executor protocol works with Claude Code, Pi and Codex. Interfaces may still change.
Requirements: Python 3.9 or newer, git, and a POSIX system (macOS or Linux).
Install the ale CLI from GitHub, into a virtualenv or as a uv tool:
python3 -m venv ~/.venvs/ale && . ~/.venvs/ale/bin/activate
pip install git+https://github.com/yodem/agentic-label-engineering.git
ale --help
uv tool install git+https://github.com/yodem/agentic-label-engineering.git # alternative
ale --version prints the installed version.
The package bundles the whole plugin (agent catalog, hooks, launchers), so it works outside a checkout, and workers it starts load the same hooks as the Claude Code plugin. To hack on ALE itself, install from a clone with pip install -e . (see CONTRIBUTING.md).
The plugin adds the executor hooks, the /label-layer and /ale:board skills, and the /ale-board status Mod. It calls the ale CLI, so install that first.
/plugin marketplace add yodem/agentic-label-engineering
/plugin install ale@agentic-label-engineering
The /ale-board Mod also needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; without it the rest of the plugin works and the Mod stays silent. See mod/README.md.
adapters/pi/ale.ts as an extension.adapters/codex/hooks.json into your Codex hook config.This five-minute walkthrough plays both roles, orchestrator and executor, so you can watch the protocol refuse a false "done". Run it with ale on your PATH (see Install).
1. A throwaway project with a two-task plan. .ale/ holds run state and stays out of git.
mkdir /tmp/ale-demo && cd /tmp/ale-demo
git init -q && printf '.ale/\n' > .gitignore && git add .gitignore && git commit -qm init
ale setup
cat > PLAN.md <<'EOF'
# Plan
## Task 1: Add a greeting module
**Files:** Create: `src/greet.py`
Write `greet(name)` returning `Hello, <name>!`.
Run: `test -f src/greet.py`
Run: `python3 -c "import sys; sys.path.insert(0, 'src'); from greet import greet; assert greet('Ada') == 'Hello, Ada!'"`
## Task 2: Document the greeting
**Files:** Modify: `README.md`
Depends on Task 1.
Run: `test -f README.md`
Run: `grep -q greet README.md`
EOF
2. Bake labels. ALE reads the plan literally: Files: lines become allowed paths, Run: lines become acceptance checks, and Depends on Task N becomes a dependency. It writes an ale-label block under each task heading and a PLAN.md.ale-provenance.json record, then exits 1 and lists the gaps. That's expected.
ale plan bake PLAN.md --write # exit 1: gaps listed
T1: lane
T1: lane_reason
T2: lane
T2: lane_reason
Lane (inline, workflow or pane) and the reason for it are always the planner's call; no classifier guesses them. Fill them in, check the plan, and commit it:
sed -i.bak 's/"lane":null/"lane":"inline"/; s/"lane_reason": null/"lane_reason": "Small and watched, so inline."/' PLAN.md && rm PLAN.md.bak
ale plan bake PLAN.md
git add -A && git commit -qm plan # PLAN.md and its .ale-provenance.json
3. Start the run and dispatch. init-run freezes the labels. ready lists tasks whose dependencies are met. dispatch --no-exec creates a git worktree for T1 without launching an agent. It prints a spawn request (the prompt an agent would get), saved here to a file.
ale init-run --plan PLAN.md --set-current
ale ready
ale dispatch --no-exec > .ale/dispatch.jsonl
AGENT=$(python3 -c "import json; print(json.loads(open('.ale/dispatch.jsonl').readline())['agent_id'])")
WT=.ale/runs/PLAN/wt/T1
4. Act as the executor, and claim done without doing the work.
ale claim --task T1 --agent "$AGENT"
ale heartbeat --task T1 --agent "$AGENT" --step "writing greet.py"
ale submit --task T1 --agent "$AGENT" --summary "done"
ale verify --task T1 --cwd "$WT" # acceptance failed: A1, A2 (exit 1)
ale status # T1 rejected
ale wait --run-dir .ale/runs/PLAN --until submitted,accepted
5. Do the work, then verify again. reopen is the lead's decision to retry verification.
mkdir -p "$WT/src" && printf 'def greet(name):\n return "Hello, %%s!" %% name\n' > "$WT/src/greet.py"
ale reopen --task T1 --reason "greet.py written"
ale verify --task T1 --cwd "$WT" # exit 0
ale integrate --task T1 # commits the allowed changes and merges branch ale/PLAN/T1
ale status # T1 accepted, integrated=yes; T2 ready
ale watchdog # [] - no breaches
With an agent, you write the plan, fill the gaps, and the runner does steps 3 to 5 for every task:
ale run PLAN.md # or, in Claude Code: /label-layer PLAN.md
ale run dispatches through the executors in your roster, verifies submissions, integrates accepted work, and opens up to two focused fix tasks per rejection. Watch it with ale status, ale timeline, ale meta, or the web board (ale board --open, or /ale:board in Claude Code).
labels (closed vocabulary from roster.json: role, model_tier, lane, risk, effort, locality), context (spec, pointers, allowed paths, dependencies), acceptance (2 to 5 commands), watch thresholds.(role, model_tier) -> executor, model table. A new model is a one-line change here. ale setup writes one to .ale/roster.json.events.jsonl, append only. State is computed from it. Handoff files under handoff/ are rendered views for humans and successor agents.ale verify writes accepted, after running the acceptance commands itself.verified, accepted, rejected, failed, canceled, lease_expired, released, input_answered) are applied only when written with no agent id. Executor events (claim, heartbeat, submit, input-required, note) must come from the task's current owner. Usage is recorded by the orchestrator or an adapter, not the executor, and is therefore not owner-guarded.A label's executor names a harness: claude, codex, pi, or any harness the roster declares under harnesses. Code, not a model, then decides how and where it runs (see docs/label-layer.md):
| Mode | When | Runs |
|---|---|---|
in-session | inline or workflow lane, a harness that can run in the lead's session (claude) and an Anthropic model | an in-session subagent the lead starts; dispatch creates and records its worktree |
headless | any other inline or workflow task on the local host | the harness's headless argv (for example codex exec --json --skip-git-repo-check …) wrapped by bin/ale-exec |
pane | a pane lane, or any non-in-session task on the roster's remote_host | a herdr pane of the harness's kind, through the launcher in ALE_HERDR_EXEC |
Work that does not run in-session goes to the roster's remote_host unless the label's locality is local; it needs a provisioned remote worktree (ALE_REMOTE_WORKTREE_<TASK>), otherwise dispatch releases the task instead of running it locally. A roster-declared harness looks like:
"harnesses": {"gemini": {"headless": ["gemini", "-m", "{model}", "-p", "{prompt}"], "herdr_kind": "gemini"}}
The older ids claude-headless, codex-exec, pi-print, claude-subagent (and claude_code) and herdr-pane still load: each names a harness and fixes its mode. ale plan route <plan> --task T --json prints the harness, model, mode and host code picks for a task.
ale setup creates .ale/roster.json. With --answers FILE, interactively on a terminal, or through the /ale:setup skill (which asks you each question), it also configures the harnesses it finds on PATH (with their --version), the model per harness and tier, the CandleKeep handbook refs file, the remote host from herdr-exec.toml, the judge mode (off by default for private repositories) and free-text project notes that every prompt carries. ale setup --questions --json lists the questions; ale setup --check reports what is configured and what is broken. Setup never stores credentials: it prints the login commands for you to run.
ALE can collect second-opinion votes on labels from an external judge command. It is off by default, runs in shadow mode (it never changes a decision), and any executable that speaks a small JSON contract works. See docs/judge.md.
0 ok, 1 check failed, 2 usage, 3 claim lost, 4 lease lost, 5 needs sign-off, 6 breaches found.
ALE scores its own runs. ale init-run indexes each run under ~/.ale/ (or $ALE_HOME/.ale/). ale analyze grades every indexed run against bars committed in ale/schema/analyze_thresholds.json, writes a dated report and findings.json, and exits 1 on a breached check. ale eval cases --ci replays evalcases/cases.jsonl, a regression suite where each case is a real run failure, and exits 1 on a failed or regressed case. Both append to one eval ledger. See docs/analyze.md.
ale watchdog scans open tasks for breaches: a stale lease (no heartbeat within heartbeat_timeout_s), a claim stuck without progress past stuck_after_s, a run past max_duration_s, a task left in submitted longer than heartbeat_timeout_s (breach type unverified: nobody ran ale verify on it in time), and attempts past max_attempts. Run it from a loop or scheduler; exit 6 means it found at least one breach.
The status in each cell describes the shipped integration, not a claim about what the underlying harness could support in a future adapter.
| Rule | Claude Code hooks | Codex hooks | Pi extension | ale-exec wrapper |
|---|---|---|---|---|
| Path guard | Enforced: PreToolUse edit denial | Enforced: PreToolUse denial | Enforced: edit and write events | Not possible: verify only |
| Auto heartbeat | Enforced: PostToolUse, 60 s throttle | Not possible: current adapter has no PostToolUse entry | Enforced: post-tool hook | Enforced: timer heartbeat |
| Stop/submit gate | Enforced: Stop acceptance gate | Not possible: current adapter has no Stop entry | Advisory: settlement runs the gate but print mode cannot block | Enforced: exit-time check then submit or input-required |
| Usage capture | Enforced: transcript IDs are deduplicated | Advisory: --usage-from codex-json on wrapper | Enforced: assistant message usage | Enforced: printed JSON usage when configured |
| Session context | Enforced: SessionStart stdout | Not possible: current adapter has no SessionStart entry | Enforced: session start injection | Not possible: wrapper has no context injection |
The Claude Code and Codex hook contracts, Pi event limits, and transcript fields are recorded in docs/harness-facts.md. Shell commands can write anywhere, so ale verify --base remains the containment backstop.
shell=True. Labels are code. Only run labels you or your orchestrator wrote. See SECURITY.md.verify --base expects one task per working tree. Use a git worktree per executor.lane is never chosen by a classifier. The planner answers three questions and records lane_reason.agent_id is self-asserted. The log guards against accidents and honest mistakes, not against a malicious local process that forges events.ale integrate needs no uncommitted changes to tracked files (untracked files are ignored). Keep .ale/ and virtualenvs in .gitignore.cost_gate.max_concurrent is part of the roster schema but is not enforced yet.| Read | For |
|---|---|
| Explainer page | A visual walkthrough of the whole idea |
| docs/label-layer.md | Plan format, label fields, worktrees, fix tasks, the run loop |
| docs/analyze.md | The eval loop: ale analyze, ale eval cases, the ledger, fix records |
| docs/agents.md | Agent taxonomy, catalog lookup, rule enforcement |
| docs/hooks.md | What the hooks enforce, per harness |
| docs/labeling.md | The label cascade and judge shadow decisions |
| docs/judge.md | The optional judge command and its contract |
| docs/harness-facts.md | Verified hook facts for Claude Code, Pi and Codex |
| EXECUTOR.md | The protocol an executor agent follows |
| AGENTS.md, CLAUDE.md | Instructions for coding agents that work on this repository |
| CHANGELOG.md | Release history |
See CONTRIBUTING.md. Tests: uvx --python 3.9 pytest -q and cd mod && bun test.
MIT. See LICENSE. Parts of agents/ and catalog/refs/ derive from OrchestKit under MIT; see NOTICE.
mod/register.tsx 464 lines1// ale-board: never name a local `h` in this file — every JSX tag compiles to
2// a call of `h`, the JSX factory global. This is the first rule of
3// writing function-hook plugins: a local `h` breaks the first draw.
4//
5// This module wires Claude Code's function-hook events to the pure helpers
6// in ./lib.ts. It draws two sites: the AbovePrompt band (one line, while a
7// run exists and the board is open) and a docked/inline Pane (the full task
8// board, grouped into columns). It is a read-only observation plane: it
9// only ever runs the plugin's bundled ALE status command and reads label files under
10// the run directory with `$.fs`. It
11// never writes into the run directory and never submits a prompt.
12import type { On, Register } from 'claude-code'
13import {
14 type LabelInfo,
15 type ListedRun,
16 type RawStatus,
17 type Section,
18 bandLine,
19 budgetRows,
20 bundledAleArgv,
21 createRefreshErrorLogger,
22 chooseRun,
23 displayState,
24 formatTokens,
25 formatOtherRunsLine,
26 noRunMessage,
27 parseStatusOutput,
28 resolveRunDirectory,
29 runDirFromCurrent,
30 truncateTo,
31 renderBoard,
32 unknownRunArgumentMessage,
33} from './lib.ts'
34
35const PLUGIN = 'ale'
36const COMMAND = 'ale-board'
37const PANE_ID = 'ale-board'
38const ARGUMENT_HINT = '[close | refresh | <run_id>]'
39
40const STORE_OPEN_KEY = 'ale-board:isOpen'
41
42const REFRESH_DEBOUNCE_MS = 1500
43const POLL_INTERVAL_MS = 10_000
44const STATUS_TIMEOUT_MS = 15_000
45const FIXED_HEADER_ROWS = 2 // title row + counts/refresh-time row
46const logRefreshErrorOnce = createRefreshErrorLogger()
47
48/** What we display: a resolved run's tasks, or an explanation of why not. */
49type BoardModel =
50 | { kind: 'no-run' }
51 | { kind: 'error'; runLabel: string; runPath: string; source: string; message: string }
52 | { kind: 'ok'; runLabel: string; runPath: string; source: string; status: RawStatus; labels: Record<string, LabelInfo>; runs: ListedRun[]; refreshedAtS: number }
53
54type Timer = { cancel: () => void }
55
56/** The host adapter for async helpers. Its engine calls are bound inline in
57 * `session.start` so every `$` API access remains literal at the call site. */
58type Host = {
59 cwd: string
60 pluginRoot: string
61 liveCwd: () => Promise<string>
62 envGetAleRunDir: () => Promise<string | undefined>
63 envGetAleRoster: () => Promise<string | undefined>
64 fsStat: (path: string, options: { resolve: boolean }) => Promise<{ kind: 'file' | 'dir' | 'other'; isLink?: boolean; realPath?: string; mtimeMs?: number }>
65 fsRead: (path: string) => Promise<string>
66 fsExists: (path: string) => Promise<boolean>
67 storeGet: (key: string) => Promise<unknown>
68 storeSet: (key: string, value: unknown) => Promise<void>
69 invalidate: () => void
70 uiLog: (text: string) => void
71 uiOpen: (title: string) => Promise<void>
72 uiClose: () => Promise<void>
73 clockAfter: (ms: number, fn: () => void) => Timer
74 clockEvery: (ms: number, fn: () => void) => Timer
75 processRun: (argv: readonly string[], options?: { cwd?: string; timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string; stderr: string }>
76}
77
78let host: Host | undefined
79let isOpen = false
80let selectedRun: { runDir: string; source: 'arg' } | undefined
81let model: BoardModel = { kind: 'no-run' }
82let refreshing: Promise<BoardModel> | undefined
83let refreshTimer: Timer | undefined
84let pollTimer: Timer | undefined
85
86function setOpen(engine: Host, open: boolean): void {
87 isOpen = open
88 void engine.storeSet(STORE_OPEN_KEY, open).catch(() => undefined)
89 if (open) {
90 startPolling(engine)
91 } else {
92 pollTimer?.cancel()
93 pollTimer = undefined
94 }
95 engine.invalidate()
96}
97
98function startPolling(engine: Host): void {
99 if (pollTimer) return
100 pollTimer = engine.clockEvery(POLL_INTERVAL_MS, () => {
101 if (!isOpen) return
102 void refresh(engine)
103 })
104}
105
106async function nearestRunsDir(engine: Host, start: string): Promise<string | undefined> {
107 let dir = start
108 while (dir) {
109 const runsDir = `${dir}/.ale/runs`
110 if (await engine.fsExists(runsDir).catch(() => false)) return runsDir
111 const parent = dir.slice(0, dir.lastIndexOf('/'))
112 if (!parent || parent === dir) break
113 dir = parent
114 }
115 return undefined
116}
117
118async function discoverRun(engine: Host): Promise<{ runDir: string; source: 'env' | 'latest'; runs: ListedRun[] } | undefined> {
119 const envDir = await engine.envGetAleRunDir().catch(() => undefined)
120 if (envDir) return { runDir: envDir, source: 'env', runs: [] }
121 const liveCwd = await engine.liveCwd().catch(() => engine.cwd)
122 const runsDir = await nearestRunsDir(engine, liveCwd) ?? await nearestRunsDir(engine, engine.cwd)
123 if (!runsDir) return undefined
124 const repoRoot = runsDir.slice(0, -'/.ale/runs'.length)
125 let result: { exitCode: number; stdout: string; stderr: string }
126 try {
127 result = await engine.processRun(bundledAleArgv(engine.pluginRoot, ['runs', '--json', '--runs-dir', runsDir]), { cwd: repoRoot, timeoutMs: STATUS_TIMEOUT_MS })
128 } catch (err) {
129 const message = `ale runs failed: ${err instanceof Error ? err.message : String(err)}`
130 logRefreshErrorOnce(text => engine.uiLog(`${PLUGIN}: ${text}`), message)
131 return undefined
132 }
133 if (result.exitCode !== 0) {
134 const message = `ale runs failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`
135 logRefreshErrorOnce(text => engine.uiLog(`${PLUGIN}: ${text}`), message)
136 return undefined
137 }
138 let runs: ListedRun[]
139 try {
140 const parsed: unknown = JSON.parse(result.stdout)
141 if (!Array.isArray(parsed)) throw new Error('expected a JSON array')
142 runs = parsed.filter((row): row is ListedRun => !!row && typeof row === 'object' && typeof (row as ListedRun).dir === 'string' && typeof (row as ListedRun).last_event_ts === 'number')
143 } catch (err) {
144 const message = `ale runs failed: ${err instanceof Error ? err.message : String(err)}`
145 logRefreshErrorOnce(text => engine.uiLog(`${PLUGIN}: ${text}`), message)
146 return undefined
147 }
148 const resolved = chooseRun({ runs })
149 return resolved ? { runDir: resolved.runDir, source: 'latest', runs } : undefined
150}
151
152async function resolveRosterPath(engine: Host, repoRoot = engine.cwd): Promise<string> {
153 const envRoster = await engine.envGetAleRoster().catch(() => undefined)
154 if (envRoster) return envRoster
155 const atCwd = `${repoRoot}/roster.json`
156 if (await engine.fsExists(atCwd).catch(() => false)) return atCwd
157 return `${repoRoot}/.ale/roster.json`
158}
159
160function repositoryRootForRun(runDir: string): string | undefined {
161 const marker = '/.ale/runs/'
162 const index = runDir.lastIndexOf(marker)
163 return index > 0 ? runDir.slice(0, index) : undefined
164}
165
166function runLabelOf(runDir: string): string {
167 const parts = runDir.split('/').filter(Boolean)
168 return parts[parts.length - 1] ?? runDir
169}
170
171async function loadLabels(engine: Host, runDir: string, taskIds: readonly string[]): Promise<Record<string, LabelInfo>> {
172 const out: Record<string, LabelInfo> = {}
173 await Promise.all(
174 taskIds.map(async id => {
175 const path = `${runDir}/labels/${id}.json`
176 const text = await engine.fsRead(path).catch(() => undefined)
177 if (text === undefined) return
178 try {
179 const parsed = JSON.parse(text) as Record<string, unknown>
180 const title = typeof parsed.title === 'string' ? parsed.title : undefined
181 const labels = parsed.labels
182 const role = labels && typeof labels === 'object' && labels !== null && typeof (labels as Record<string, unknown>).role === 'string' ? ((labels as Record<string, unknown>).role as string) : undefined
183 out[id] = { title, role }
184 } catch {
185 // a malformed label file just yields no title/role/tier for this task
186 }
187 }),
188 )
189 return out
190}
191
192async function latestEventTs(engine: Host, runDir: string): Promise<number | undefined> {
193 const contents = await engine.fsRead(`${runDir}/events.jsonl`).catch(() => undefined)
194 if (!contents) return undefined
195 let latest: number | undefined
196 for (const line of contents.split(/\r?\n/)) {
197 try {
198 const ts = JSON.parse(line)?.ts
199 if (typeof ts === 'number' && Number.isFinite(ts) && (latest === undefined || ts > latest)) latest = ts
200 } catch { /* Ignore incomplete or malformed event lines. */ }
201 }
202 return latest
203}
204
205/** Reads ALE's authoritative status and the labels used for display names. */
206async function refresh(engine: Host): Promise<BoardModel> {
207 if (refreshing) return refreshing
208 refreshing = (async (): Promise<BoardModel> => {
209 const discovered = selectedRun ? undefined : await discoverRun(engine)
210 const resolved = selectedRun ?? discovered
211 if (resolved === undefined) {
212 const next: BoardModel = { kind: 'no-run' }
213 model = next
214 engine.invalidate()
215 return next
216 }
217 const runDir = resolved.runDir
218 const runLabel = runLabelOf(runDir)
219 const repoRoot = repositoryRootForRun(runDir)
220 const cwd = repoRoot ?? engine.cwd
221 const repoRosterExists = await engine.fsExists(`${cwd}/.ale/roster.json`).catch(() => false)
222 const args = ['status', '--json', '--run-dir', runDir]
223 if (!repoRosterExists) args.push('--roster', await resolveRosterPath(engine, cwd))
224 const result = await engine.processRun(bundledAleArgv(engine.pluginRoot, args), { cwd, timeoutMs: STATUS_TIMEOUT_MS })
225 const parsed = parseStatusOutput(result.exitCode, result.stdout, result.stderr)
226 if (!parsed.ok) {
227 const next: BoardModel = { kind: 'error', runLabel, runPath: runDir, source: resolved.source, message: parsed.error }
228 model = next
229 logRefreshErrorOnce(message => engine.uiLog(`${PLUGIN}: ${message}`), next.message)
230 engine.invalidate()
231 return next
232 }
233 const labels = await loadLabels(engine, runDir, Object.keys(parsed.status.tasks))
234 const lastEventTs = await latestEventTs(engine, runDir)
235 if (lastEventTs !== undefined) parsed.status.run.last_event_ts = lastEventTs
236 const next: BoardModel = { kind: 'ok', runLabel, runPath: runDir, source: resolved.source, status: parsed.status, labels, runs: discovered?.runs ?? [], refreshedAtS: Date.now() / 1000 }
237 model = next
238 engine.invalidate()
239 return next
240 })().catch(err => {
241 const next: BoardModel = {
242 kind: 'error',
243 runLabel: selectedRun ? runLabelOf(selectedRun.runDir) : 'unknown run',
244 runPath: selectedRun?.runDir ?? '',
245 source: selectedRun?.source ?? 'launch',
246 message: err instanceof Error ? err.message : String(err),
247 }
248 model = next
249 logRefreshErrorOnce(message => engine.uiLog(`${PLUGIN}: ${message}`), next.message)
250 engine.invalidate()
251 return next
252 })
253 try {
254 return await refreshing
255 } finally {
256 refreshing = undefined
257 }
258}
259
260function scheduleRefresh(engine: Host): void {
261 refreshTimer?.cancel()
262 refreshTimer = engine.clockAfter(REFRESH_DEBOUNCE_MS, () => {
263 refreshTimer = undefined
264 void refresh(engine)
265 })
266}
267
268// ---- View: pure-ish string layout, called from the render hooks only ----
269
270function bandText(): string | undefined {
271 if (model.kind !== 'ok') return undefined
272 if (model.status.run.finished === true) return undefined
273 return `${bandLine(model.runLabel, model.status.tasks, model.status.run)} · source:${model.source}`
274}
275
276function buildSections(status: RawStatus, labels: Record<string, LabelInfo>, columns: number): Section[] {
277 const nowS = Date.now() / 1000
278 const order = ['Needs you', 'Running', 'Waiting', 'Done']
279 const rows = Object.entries(status.tasks).map(([id, task]) => {
280 const info = displayState(task, nowS)
281 const reason = taskReason(task, status.tasks, nowS)
282 const title = labels[id]?.title ?? id
283 const parent = labels[id]?.fixes ? `↳ ${labels[id]?.fixes} ` : ''
284 return { id, section: info.section, text: truncateTo(`${info.glyph} ${id.padEnd(8)} ${info.label} ${parent}${title} ${reason}`, columns) }
285 })
286 return order.map(name => ({ name, rows: rows.filter(row => row.section === name).sort((a, b) => a.id.localeCompare(b.id)).map(row => row.text) }))
287}
288
289function taskReason(task: RawStatus['tasks'][string], tasks: RawStatus['tasks'], nowS: number): string {
290 const info = displayState(task, nowS)
291 if (info.raw === 'input-required') return `Needs your answer: ${String(task.waiting_on ?? '')}`
292 if (info.raw === 'released-fail') return `Failed to start: ${String((task.outcome as Record<string, unknown> | undefined)?.reason ?? '').split('\n').at(0)?.slice(0, 120) ?? ''}`
293 if (info.raw === 'rejected') return `Rejected: ${String(task.last_reject_reason ?? 'needs a fix')}`
294 if (info.raw === 'failed') return `Failed: ${String((task.outcome as Record<string, unknown> | undefined)?.reason ?? 'executor failed')}`
295 if (info.raw === 'stale') return 'No heartbeat. The executor may have died.'
296 const unmet = (Array.isArray(task.depends_on) ? task.depends_on : []).filter(dep => tasks[String(dep)]?.state !== 'accepted')
297 if (unmet.length) return `Waiting on ${unmet.join(', ')}`
298 if (info.raw === 'fixing') return `Waiting on fix ${(Array.isArray(task.fixed_by) ? task.fixed_by : []).join(', ')}`
299 if (info.section === 'Running') return `Running: ${String(task.last_step ?? 'executor active')}`
300 if (info.raw === 'ready') return 'Ready, not picked up yet.'
301 if (info.raw === 'released') return `Retrying: attempt ${String(task.attempt ?? 1)} of ${String(task.max_attempts ?? '?')}.`
302 return ''
303}
304
305const SECTION_TITLE: Record<string, string> = {
306 'input-required': 'input-required',
307 working: 'working / claimed',
308 submitted: 'submitted',
309 ready: 'ready',
310 attention: 'rejected / stale / released',
311 planned: 'planned',
312 accepted: 'accepted',
313 failed: 'failed',
314 canceled: 'canceled',
315}
316
317// ---- register ----
318
319export const register: Register = (on: On) => {
320 on('session.start', async ($, e, next) => {
321 host = {
322 cwd: e.cwd,
323 pluginRoot: $.plugin.root,
324 liveCwd: () => $.session.cwd(),
325 // $.env.get names must be string literals (book rule 4), so one method per variable.
326 envGetAleRunDir: () => $.env.get('ALE_RUN_DIR'),
327 envGetAleRoster: () => $.env.get('ALE_ROSTER'),
328 fsStat: (path, statOptions) => $.fs.stat(path, statOptions),
329 fsRead: path => $.fs.read(path),
330 fsExists: path => $.fs.exists(path),
331 storeGet: key => $.store.get(key),
332 storeSet: (key, value) => $.store.set(key, value),
333 invalidate: () => $.ui.invalidate('ui.render'),
334 uiLog: text => $.ui.log(text),
335 uiOpen: title =>
336 $.ui.open({ id: PANE_ID, title, closeOnEscape: true, holdToasts: true, rows: 18 }),
337 uiClose: () => $.ui.close({ id: PANE_ID }),
338 clockAfter: (ms, fn) => $.clock.after(ms, fn),
339 clockEvery: (ms, fn) => $.clock.every(ms, fn),
340 processRun: (argv, processOptions) => $.process.run(argv, processOptions),
341 }
342 const engine = host
343 isOpen = (await engine.storeGet(STORE_OPEN_KEY).catch(() => false)) === true
344 selectedRun = undefined
345 await $.command
346 .register({
347 name: COMMAND,
348 description: 'ALE task board: tasks and their status, grouped into columns (ale-board)',
349 argumentHint: ARGUMENT_HINT,
350 immediate: true,
351 })
352 .catch(err => $.ui.log(`${PLUGIN}: /${COMMAND} not registered: ${err}`))
353 if (isOpen) {
354 startPolling(engine)
355 await $.ui.open({ id: PANE_ID, title: 'ALE task board', closeOnEscape: true, holdToasts: true, rows: 18 }).catch(() => undefined)
356 void refresh(engine)
357 }
358 return next(e)
359 })
360
361 on('command.run', { command: COMMAND }, async ($, e) => {
362 try {
363 if (!host) return { text: 'not ready yet, try again in a moment' }
364 const engine = host
365 const arg = e.args.trim()
366 const lower = arg.toLowerCase()
367
368 if (lower === 'close' || lower === 'stop') {
369 setOpen(engine, false)
370 await engine.uiClose().catch(() => undefined)
371 return { text: 'closed' }
372 }
373 if (lower === 'refresh') {
374 setOpen(engine, true)
375 await $.ui.open({ id: PANE_ID, title: 'ALE task board', closeOnEscape: true, holdToasts: true, rows: 18 }).catch(() => undefined)
376 const after = await refresh(engine)
377 return { text: after.kind === 'error' ? `refresh failed · ${after.message}` : 'refreshed' }
378 }
379 if (lower.startsWith('run ') || arg) {
380 const runArg = lower.startsWith('run ') ? arg.slice('run '.length).trim() : arg
381 const liveCwd = await engine.liveCwd().catch(() => engine.cwd)
382 const runsDir = await nearestRunsDir(engine, liveCwd) ?? await nearestRunsDir(engine, engine.cwd)
383 const resolved = resolveRunDirectory({ arg: runArg, cwdRunsDir: runsDir })
384 if (!resolved || !await engine.fsExists(resolved.runDir).catch(() => false)) return { text: unknownRunArgumentMessage(runArg || arg) }
385 selectedRun = { runDir: resolved.runDir, source: 'arg' }
386 model = { kind: 'no-run' }
387 setOpen(engine, true)
388 await $.ui.open({ id: PANE_ID, title: 'ALE task board', closeOnEscape: true, holdToasts: true, rows: 18 }).catch(() => undefined)
389 const after = await refresh(engine)
390 return { text: after.kind === 'error' ? `run ${runArg}: ${after.message}` : `board on ${runArg}` }
391 }
392 setOpen(engine, true)
393 await $.ui.open({ id: PANE_ID, title: 'ALE task board', closeOnEscape: true, holdToasts: true, rows: 18 }).catch(() => undefined)
394 let current = model
395 if (current.kind === 'no-run' || current.kind === 'error') {
396 current = await refresh(engine)
397 } else {
398 engine.invalidate()
399 scheduleRefresh(engine)
400 }
401 return { text: current.kind === 'error' ? `board above the prompt (last refresh failed) · /${COMMAND} close closes` : `board above the prompt · /${COMMAND} close closes` }
402 } catch (err) {
403 return { text: `ale-board error: ${err instanceof Error ? err.message : String(err)}` }
404 }
405 })
406
407 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
408 if (host && e.origin.kind === 'person') setOpen(host, false)
409 return next(e)
410 })
411
412 on('turn.complete', async ($, e, next) => {
413 if (e.agentId === undefined && host && isOpen) scheduleRefresh(host)
414 return next(e)
415 })
416
417 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
418 if (!isOpen || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
419 const line = bandText()
420 if (line === undefined) return next(e)
421 const { Box, Text } = $.ui.resolve(e)
422 return (
423 <Box flexDirection="column">
424 <Text>{truncateTo(line, e.props.bodyColumns)}</Text>
425 {await next(e)}
426 </Box>
427 )
428 })
429
430 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
431 if (e.requestId !== PANE_ID) return next(e)
432 const { Box, Text } = $.ui.resolve(e)
433 const columns = e.props.bodyColumns
434 const maxRows = e.props.scroll.bodyRows
435
436 if (model.kind === 'no-run') {
437 return (
438 <Box flexDirection="column">
439 <Text bold>ale-board</Text>
440 <Text>{truncateTo(noRunMessage(), columns)}</Text>
441 </Box>
442 )
443 }
444 if (model.kind === 'error') {
445 return (
446 <Box flexDirection="column">
447 <Text bold>{truncateTo(`ale-board · ${model.runLabel} · ${model.runPath} · source:${model.source}`, columns)}</Text>
448 <Text>{truncateTo(`error: ${model.message}`, columns)}</Text>
449 </Box>
450 )
451 }
452
453 const lines = renderBoard({ runId: model.runLabel, runPath: model.runPath, tasks: model.status.tasks, run: model.status.run, labels: model.labels, otherRuns: model.runs, boardUrl: typeof (model.status.run as any).board_url === 'string' ? (model.status.run as any).board_url : undefined, nowS: Date.now() / 1000, columns })
454
455 return (
456 <Box flexDirection="column">
457 {lines.map((line, index) => (
458 <Text key={`board-line-${index}`}>{line.map((segment, part) => <Text key={`segment-${part}`} color={segment.color} bold={segment.bold} dimColor={segment.dimColor}>{segment.text}</Text>)}</Text>
459 ))}
460 </Box>
461 )
462 })
463}
464mod/lib.ts 672 lines1// ale-board: the pure helpers behind the board. No JSX and no `claude-code`
2// import, so `bun test` runs them without the early-access types.
3//
4// All parsing, grouping, truncation and row budgeting live here as pure
5// functions with no `$` access, so they are unit-testable in isolation.
6
7export type RawTask = {
8 state?: unknown
9 claimable?: unknown
10 attempt?: unknown
11 owner?: unknown
12 started_ts?: unknown
13 last_heartbeat_ts?: unknown
14 submitted_ts?: unknown
15 last_step?: unknown
16 step_changed_ts?: unknown
17 tokens?: unknown
18 cost_usd?: unknown
19 rejections?: unknown
20 waiting_on?: unknown
21 breaches_seen?: unknown
22 assignees?: unknown
23 integrated?: unknown
24 last_verdict?: unknown
25 worktree?: unknown
26 [key: string]: unknown
27}
28
29export type RawRun = {
30 tokens?: unknown
31 cost_usd?: unknown
32 started_ts?: unknown
33 finished?: unknown
34 breaches_seen?: unknown
35 last_event_ts?: unknown
36}
37
38export type LabelData = Record<string, any>
39export type AleEvent = Record<string, any> & { type: string; ts?: number; task_id?: string | null; agent_id?: string | null }
40
41export type RunResolution = { runDir: string; source: 'env' | 'cwd' | 'launch' | 'arg' }
42export type ListedRun = { dir: string; path?: string; run_id?: string; last_event_ts: number }
43
44/** Resolve an explicit run, the configured run, or the first row returned by ale runs. */
45export function chooseRun(input: { arg?: string; argRunsDir?: string; envDir?: string; runs?: readonly ListedRun[]; runsError?: string }): { runDir: string; source: 'arg' | 'env' | 'latest' } | undefined {
46 const arg = input.arg?.trim()
47 if (arg) return { runDir: arg.startsWith('/') ? arg : input.argRunsDir ? `${input.argRunsDir}/${arg}` : arg, source: 'arg' }
48 if (input.envDir) return { runDir: input.envDir, source: 'env' }
49 if (input.runsError) return undefined
50 const first = input.runs?.[0]
51 return first ? { runDir: first.path ?? first.dir, source: 'latest' } : undefined
52}
53
54/** Format recent alternatives as a single terminal-safe line. */
55export function formatOtherRunsLine(selectedRunDir: string, runs: readonly ListedRun[], nowS: number, columns: number): string | undefined {
56 const recent = runs.filter(run => run.path !== selectedRunDir && run.dir !== selectedRunDir && nowS - run.last_event_ts <= 86400 && nowS >= run.last_event_ts)
57 .slice(0, 3)
58 if (!recent.length) return undefined
59 const parts = recent.map(run => `${run.run_id ?? run.dir} ${ageOf(run.last_event_ts, nowS)}`)
60 return truncateTo(`Other runs: ${parts.join(' · ')}`, columns)
61}
62
63export function runDirFromCurrent(input: {
64 runsDir: string
65 kind: 'file' | 'dir' | 'other'
66 realPath?: string
67 text?: string
68}): string | undefined {
69 if (input.kind === 'dir') return input.realPath ?? `${input.runsDir}/current`
70 if (input.kind !== 'file') return undefined
71 const text = input.text?.trim() ?? ''
72 if (!text) return undefined
73 if (text.startsWith('/')) return text
74 if (text.includes('/')) return undefined
75 return `${input.runsDir}/${text}`
76}
77
78export function resolveRunDirectory(input: {
79 envDir?: string
80 cwdRunDir?: string
81 launchRunDir?: string
82 arg?: string
83 cwdRunsDir?: string
84 launchRunsDir?: string
85}): RunResolution | undefined {
86 const arg = input.arg?.trim()
87 if (arg) {
88 if (arg.startsWith('/')) return { runDir: arg, source: 'arg' }
89 if (arg.includes('/')) return undefined
90 const runsDir = input.cwdRunsDir ?? input.launchRunsDir
91 if (runsDir) return { runDir: `${runsDir}/${arg}`, source: 'arg' }
92 return undefined
93 }
94 if (input.envDir) return { runDir: input.envDir, source: 'env' }
95 if (input.cwdRunDir) return { runDir: input.cwdRunDir, source: 'cwd' }
96 if (input.launchRunDir) return { runDir: input.launchRunDir, source: 'launch' }
97 return undefined
98}
99
100export function unknownRunArgumentMessage(arg: string): string {
101 return `unknown argument "${arg}" · /ale-board [close | refresh | <run_id> | <absolute_run_dir>]`
102}
103
104const AUTHORITY_EVENTS = new Set(['verified', 'accepted', 'rejected', 'failed', 'canceled', 'lease_expired', 'released', 'input_answered', 'task_added', 'label_changed', 'label_removed', 'spawned', 'integrated', 'monitor_verdict'])
105const OPEN_STATES = new Set(['planned', 'released', 'rejected'])
106const LIVE_STATES = new Set(['claimed', 'working', 'input-required'])
107const TERMINAL_STATES = new Set(['accepted', 'failed', 'canceled'])
108
109function freshTask(): Record<string, any> {
110 return { state: 'planned', claimable: false, attempt: 1, owner: null, started_ts: null, last_heartbeat_ts: null, submitted_ts: null, last_step: null, step_changed_ts: null, steps: [], files_modified: [], pending: [], next_steps: [], waiting_on: null, summary: null, notes: [], tokens: 0, cost_usd: 0, rejections: 0, last_reject_reason: null, evidence: null, breaches_seen: [], assignees: [], integrated: false, last_verdict: null, blocked_by: [], worktree: null }
111}
112
113export function reduceEvents(events: AleEvent[], initialLabels: Record<string, LabelData>): { run: RawRun; tasks: Record<string, RawTask>; labels: Record<string, LabelData> } {
114 const labels = structuredClone(initialLabels)
115 const tasks: Record<string, Record<string, any>> = Object.fromEntries(Object.keys(labels).filter(id => id !== '__roster__').map(id => [id, freshTask()]))
116 const run: Record<string, any> = { tokens: 0, cost_usd: 0, started_ts: null, finished: false, breaches_seen: [] }
117 const depsOk = (id: string) => (labels[id]?.context?.depends_on ?? []).every((dep: string) => tasks[dep]?.state === 'accepted')
118 for (let index = 0; index < events.length; index += 1) {
119 const event = events[index]
120 const kind = event.type
121 const id = event.task_id
122 if (kind === 'run_started') { run.started_ts = event.ts; continue }
123 if (kind === 'run_finished') { run.finished = true; continue }
124 if (kind === 'usage') {
125 const tokens = Number(event['gen_ai.usage.input_tokens'] || 0) + Number(event['gen_ai.usage.output_tokens'] || 0)
126 run.tokens += tokens
127 run.cost_usd += Number(event.cost_usd || 0)
128 }
129 if (kind === 'breach' && id == null) { run.breaches_seen.push(event.breach); continue }
130 if (AUTHORITY_EVENTS.has(kind) && event.agent_id != null) continue
131 if (kind === 'task_added' && id && isRecord(event.label)) { labels[id] = event.label; tasks[id] = freshTask(); continue }
132 if (kind === 'label_removed' && id && OPEN_STATES.has(tasks[id]?.state)) { delete labels[id]; delete tasks[id]; continue }
133 if (kind === 'label_changed' && id && labels[id] && typeof event.field === 'string') {
134 const field = event.field === 'lane' ? 'labels.lane' : event.field
135 const allowed = ['labels.', 'assignments', 'watch.', 'context.allowed_paths', 'context.depends_on']
136 if (!allowed.some(prefix => field === prefix || field.startsWith(prefix))) continue
137 const parts = field.split('.')
138 if (parts.length === 1) labels[id][field] = event.new
139 else if (parts.length === 2) { labels[id][parts[0]] ??= {}; labels[id][parts[0]][parts[1]] = event.new }
140 }
141 const task = id ? tasks[id] : undefined
142 if (!task) continue
143 if (kind === 'accepted' && task.state === 'rejected') {
144 const acceptedFix = Object.entries(labels).some(([fixId, label]) => label.fixes === id && events.slice(0, index).some(earlier => earlier.type === 'accepted' && earlier.task_id === fixId))
145 if (acceptedFix) task.state = 'submitted'
146 }
147 const agent = event.agent_id
148 const owner = task.owner != null && agent === task.owner
149 const ts = event.ts ?? 0
150 if (kind === 'claimed' && task.owner == null && OPEN_STATES.has(task.state) && event.attempt === task.attempt && depsOk(id!)) {
151 Object.assign(task, { state: 'claimed', owner: agent, started_ts: ts, last_heartbeat_ts: ts, step_changed_ts: ts, submitted_ts: null })
152 if (agent && !task.assignees.includes(agent)) task.assignees.push(agent)
153 } else if (kind === 'heartbeat' && owner && LIVE_STATES.has(task.state)) {
154 if (task.state !== 'input-required') task.state = 'working'
155 task.last_heartbeat_ts = ts
156 if (event.step !== task.last_step) { task.last_step = event.step; task.step_changed_ts = ts; task.steps.push(event.step) }
157 for (const path of event.files_modified ?? []) if (!task.files_modified.includes(path)) task.files_modified.push(path)
158 for (const key of ['pending', 'next_steps']) if (event[key] != null) task[key] = [...event[key]]
159 } else if (kind === 'input_required' && owner && LIVE_STATES.has(task.state)) Object.assign(task, { state: 'input-required', waiting_on: event.question })
160 else if (kind === 'input_answered' && task.state === 'input-required') Object.assign(task, { state: 'working', waiting_on: null, last_heartbeat_ts: ts, step_changed_ts: ts })
161 else if (kind === 'submitted' && owner && LIVE_STATES.has(task.state)) Object.assign(task, { state: 'submitted', summary: event.summary, submitted_ts: ts })
162 else if (kind === 'verified') task.evidence = event.evidence
163 else if (kind === 'accepted' && task.state === 'submitted') Object.assign(task, { state: 'accepted', owner: null, evidence: event.evidence })
164 else if (kind === 'rejected' && task.state === 'submitted') { Object.assign(task, { state: 'rejected', owner: null, evidence: event.evidence, last_reject_reason: event.reason }); task.rejections += 1; task.attempt += 1 }
165 else if ((kind === 'failed' || kind === 'canceled') && !TERMINAL_STATES.has(task.state)) Object.assign(task, { state: kind, owner: null })
166 else if (kind === 'lease_expired' && LIVE_STATES.has(task.state)) Object.assign(task, { state: 'stale', owner: null })
167 else if (kind === 'released' && task.state === 'stale') task.state = 'released'
168 else if (kind === 'usage') { task.tokens += Number(event['gen_ai.usage.input_tokens'] || 0) + Number(event['gen_ai.usage.output_tokens'] || 0); task.cost_usd += Number(event.cost_usd || 0) }
169 else if (kind === 'breach') task.breaches_seen.push([event.breach, task.attempt])
170 else if (kind === 'spawned') {
171 if (event.agent_id_minted && !task.assignees.includes(event.agent_id_minted)) task.assignees.push(event.agent_id_minted)
172 task.worktree = typeof event.worktree === 'string' ? event.worktree : null
173 } else if (kind === 'integrated') task.integrated = true
174 else if (kind === 'monitor_verdict') task.last_verdict = { agent_id_minted: event.agent_id_minted, verdict: event.verdict, text: event.text }
175 }
176 for (const [id, task] of Object.entries(tasks)) {
177 const missing = (labels[id]?.context?.depends_on ?? []).filter((dep: string) => !(dep in tasks))
178 task.blocked_by = missing
179 task.claimable = !task.owner && OPEN_STATES.has(task.state) && depsOk(id) && missing.length === 0
180 if (missing.length) task.breaches_seen.push(['orphaned_dependency', task.attempt])
181 if (task.state === 'planned' && task.claimable) task.state = 'ready'
182 }
183 for (const [fixId, label] of Object.entries(labels)) {
184 const parent = label.fixes
185 if (!parent || !tasks[parent] || !tasks[fixId]) continue
186 if (tasks[fixId].state !== 'accepted') tasks[parent].state = 'fixing'
187 else if (tasks[parent].state !== 'accepted') {
188 const rejects = events.map((e, i) => [e, i] as const).filter(([e]) => e.task_id === parent && e.type === 'rejected').map(([, i]) => i)
189 const accepts = events.map((e, i) => [e, i] as const).filter(([e]) => e.task_id === fixId && e.type === 'accepted').map(([, i]) => i)
190 if (accepts.length && (!rejects.length || Math.max(...accepts) > Math.max(...rejects))) tasks[parent].state = 'submitted'
191 }
192 }
193 return { run, tasks, labels }
194}
195
196export type RawStatus = {
197 run: RawRun
198 tasks: Record<string, RawTask>
199}
200
201export type LabelInfo = {
202 title?: string
203 role?: string
204 tier?: string
205 fixes?: string
206 sub?: string
207 phase?: string
208 risk?: string
209 effort?: string
210 lane?: string
211}
212
213export type BoardMetadata = { pid?: unknown; url?: unknown; instance_id?: unknown; updated_ts?: unknown }
214
215export const STATE_DISPLAY = [
216 { raw: 'input-required', label: 'Needs your answer', glyph: '?', tone: 'needs', section: 'Needs you' },
217 { raw: 'released-fail', label: 'Failed to start', glyph: '✗', tone: 'fail', section: 'Needs you' },
218 { raw: 'out-of-attempts', label: 'Out of attempts', glyph: '■', tone: 'fail', section: 'Needs you' },
219 { raw: 'rejected', label: 'Rejected', glyph: '✕', tone: 'fail', section: 'Needs you' },
220 { raw: 'failed', label: 'Failed', glyph: '✗', tone: 'fail', section: 'Needs you' },
221 { raw: 'stale', label: 'No heartbeat', glyph: '~', tone: 'needs', section: 'Needs you' },
222 { raw: 'working', label: 'Running', glyph: '●', tone: 'run', section: 'Running' },
223 { raw: 'claimed', label: 'Running', glyph: '●', tone: 'run', section: 'Running' },
224 { raw: 'submitted', label: 'Verifying', glyph: '»', tone: 'verify', section: 'Running' },
225 { raw: 'fixing', label: 'Waiting on fix', glyph: '↻', tone: 'wait', section: 'Waiting' },
226 { raw: 'ready', label: 'Ready', glyph: '○', tone: 'ready', section: 'Waiting' },
227 { raw: 'released', label: 'Retrying', glyph: '↺', tone: 'ready', section: 'Waiting' },
228 { raw: 'planned', label: 'Waiting', glyph: '·', tone: 'wait', section: 'Waiting' },
229 { raw: 'accepted', label: 'Done', glyph: '✓', tone: 'done', section: 'Done' },
230 { raw: 'canceled', label: 'Canceled', glyph: '⦸', tone: 'cancel', section: 'Done' },
231] as const
232
233export type DisplayState = typeof STATE_DISPLAY[number] | { raw: string; label: string; glyph: string; tone: 'cancel'; section: 'Waiting' }
234
235export function displayState(task: RawTask, nowS = Date.now() / 1000): DisplayState {
236 const raw = asString(task.state, 'unknown')
237 if (raw === 'released' && asString((task.outcome as Record<string, unknown> | undefined)?.reason).toLowerCase().startsWith('spawn failed')) return STATE_DISPLAY[1]
238 if (raw === 'rejected' && asNumber(task.attempt) > asNumber(task.max_attempts, Number.POSITIVE_INFINITY) && !(Array.isArray(task.fixed_by) && task.fixed_by.some(id => String(id)))) return STATE_DISPLAY[2]
239 if ((raw === 'working' || raw === 'claimed') && asNumber(task.lease_expires_ts) < nowS) return STATE_DISPLAY[5]
240 return STATE_DISPLAY.find(row => row.raw === raw) ?? { raw, label: `Unknown: ${raw}`, glyph: '◇', tone: 'cancel', section: 'Waiting' }
241}
242
243function boardState(id: string, task: RawTask, tasks: Record<string, RawTask>, nowS: number): DisplayState {
244 if (task.state === 'rejected') {
245 const parent = Object.entries(tasks).find(([, candidate]) =>
246 candidate.state === 'accepted' && Array.isArray(candidate.fixes) && candidate.fixes.includes(id))
247 if (parent) return { raw: 'superseded', label: 'Superseded', glyph: '→', tone: 'done', section: 'Done' }
248 }
249 return displayState(task, nowS)
250}
251
252export function formatTokens(value: unknown): string {
253 const n = asNumber(value)
254 if (n < 1000) return Math.round(n).toLocaleString('en')
255 if (n < 10000) return `${(n / 1000).toFixed(1)}k`
256 if (n < 1000000) return `${Math.round(n / 1000)}k`
257 if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`
258 return `${Math.round(n / 1000000)}M`
259}
260
261function formatAge(seconds: number): string {
262 if (seconds < 60) return `${Math.floor(seconds)}s`
263 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
264 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
265 return `${Math.floor(seconds / 86400)}d`
266}
267
268export function attentionOrder(tasks: Record<string, RawTask>): string[] {
269 const need = Object.entries(tasks).filter(([, task]) => displayState(task).section === 'Needs you')
270 return need.sort(([a, left], [b, right]) => {
271 const leftSeverity = displayState(left).tone === 'fail' ? 0 : 1
272 const rightSeverity = displayState(right).tone === 'fail' ? 0 : 1
273 return leftSeverity - rightSeverity || a.localeCompare(b)
274 }).map(([id]) => id)
275}
276
277export function boardUrl(metadata: BoardMetadata | undefined, currentPid: number, nowS = Date.now() / 1000): string | undefined {
278 if (!metadata || asNumber(metadata.pid) !== currentPid || typeof metadata.instance_id !== 'string' || !metadata.instance_id) return undefined
279 if (typeof metadata.updated_ts === 'number' && nowS - metadata.updated_ts > 30) return undefined
280 if (typeof metadata.url !== 'string') return undefined
281 try {
282 const parsed = new URL(metadata.url)
283 if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || !parsed.port || !parsed.pathname.endsWith('/')) return undefined
284 return metadata.url
285 } catch { return undefined }
286}
287
288export type ParsedStatus = { ok: true; status: RawStatus } | { ok: false; error: string }
289
290export function isRecord(value: unknown): value is Record<string, unknown> {
291 return typeof value === 'object' && value !== null && !Array.isArray(value)
292}
293
294export function asString(value: unknown, fallback = ''): string {
295 return typeof value === 'string' ? value : fallback
296}
297
298export function asNumber(value: unknown, fallback = 0): number {
299 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
300}
301
302export function asBool(value: unknown, fallback = false): boolean {
303 return typeof value === 'boolean' ? value : fallback
304}
305
306export function asBreachCount(value: unknown): number {
307 return Array.isArray(value) ? value.length : 0
308}
309
310/**
311 * `python3 -m ale status --json`'s stdout to a `RawStatus`, or an error a
312 * board row can show without throwing. Never throws.
313 */
314export function parseStatusOutput(exitCode: number, stdout: string, stderr: string): ParsedStatus {
315 if (exitCode !== 0) {
316 const line = stderr.trim().split('\n').pop() ?? ''
317 return { ok: false, error: line ? `ale status failed (exit ${exitCode}): ${line}` : `ale status failed (exit ${exitCode})` }
318 }
319 let parsed: unknown
320 try {
321 parsed = JSON.parse(stdout)
322 } catch (err) {
323 return { ok: false, error: `ale status: malformed JSON (${err instanceof Error ? err.message : String(err)})` }
324 }
325 if (!isRecord(parsed)) return { ok: false, error: 'ale status: expected a JSON object' }
326 const tasksRaw = parsed.tasks
327 if (!isRecord(tasksRaw)) return { ok: false, error: 'ale status: missing "tasks"' }
328 const runRaw = isRecord(parsed.run) ? parsed.run : {}
329 const tasks: Record<string, RawTask> = {}
330 for (const [id, value] of Object.entries(tasksRaw)) {
331 if (isRecord(value)) tasks[id] = value
332 }
333 return { ok: true, status: { run: runRaw, tasks } }
334}
335
336/** Runs the plugin's bundled ALE package while passing every path as an argv item. */
337export function bundledAleArgv(pluginRoot: string, args: readonly string[]): string[] {
338 const bootstrap = 'import sys; sys.path.insert(0, sys.argv[1]); from ale.cli import main; sys.exit(main(sys.argv[2:]))'
339 return ['python3', '-c', bootstrap, pluginRoot, ...args]
340}
341
342/** Logs each distinct refresh error once for the lifetime of this module. */
343export function createRefreshErrorLogger(): (log: (message: string) => void, message: string) => void {
344 const seen = new Set<string>()
345 return (log, message) => {
346 if (seen.has(message)) return
347 seen.add(message)
348 log(message)
349 }
350}
351
352/** The board's section order, and which task states fall in each. */
353export const GROUP_ORDER: readonly string[] = [
354 'input-required',
355 'working',
356 'submitted',
357 'ready',
358 'attention',
359 'planned',
360 'accepted',
361 'failed',
362 'canceled',
363]
364
365const GROUP_STATES: Record<string, readonly string[]> = {
366 'input-required': ['input-required'],
367 working: ['working', 'claimed'],
368 submitted: ['submitted'],
369 ready: ['ready'],
370 attention: ['rejected', 'stale', 'released', 'fixing'],
371 planned: ['planned'],
372 accepted: ['accepted'],
373 failed: ['failed'],
374 canceled: ['canceled'],
375}
376
377const STATE_TO_GROUP: Record<string, string> = (() => {
378 const out: Record<string, string> = {}
379 for (const group of GROUP_ORDER) {
380 for (const state of GROUP_STATES[group] ?? []) out[state] = group
381 }
382 return out
383})()
384
385export function groupNameFor(state: string): string | undefined {
386 return STATE_TO_GROUP[state]
387}
388
389/**
390 * Task ids bucketed into board sections, in `GROUP_ORDER`, each bucket's ids
391 * sorted for determinism. A task whose state is not in the vocabulary is
392 * dropped from every bucket (it cannot be drawn safely).
393 */
394export function groupTaskIds(tasks: Record<string, RawTask>): { name: string; ids: string[] }[] {
395 const buckets: Record<string, string[]> = {}
396 for (const name of GROUP_ORDER) buckets[name] = []
397 for (const [id, task] of Object.entries(tasks)) {
398 const state = asString(task.state)
399 const group = STATE_TO_GROUP[state]
400 if (group === undefined) continue
401 buckets[group]?.push(id)
402 }
403 for (const name of GROUP_ORDER) buckets[name]?.sort()
404 return GROUP_ORDER.map(name => ({ name, ids: buckets[name] ?? [] }))
405}
406
407/** Truncates to `width` columns, with a trailing ellipsis when it must cut. */
408export function truncateTo(text: string, width: number): string {
409 if (width <= 0) return ''
410 const chars = [...text]
411 if (chars.length <= width) return text
412 if (width === 1) return chars[0] ?? ''
413 return `${chars.slice(0, width - 1).join('')}…`
414}
415
416const GLYPH_OF: Record<string, string> = {
417 'input-required': '?',
418 working: '●',
419 claimed: '●',
420 submitted: '»',
421 ready: '○',
422 planned: '·',
423 rejected: '✕',
424 stale: '~',
425 released: '↺',
426 accepted: '✓',
427 failed: '✗',
428 canceled: '⦸',
429 fixing: '↻',
430}
431
432/** One glyph per state (rule 27: glyphs, not only colour — survives `tmux capture-pane -p`). */
433export function glyphFor(state: string): string {
434 return GLYPH_OF[state] ?? '◇'
435}
436
437export type BoardSegment = { text: string; color?: string; bold?: boolean; dimColor?: boolean }
438export type RenderBoardInput = { runId: string; runPath?: string; tasks: Record<string, RawTask>; run: RawRun; labels: Record<string, LabelInfo>; otherRuns?: readonly ListedRun[]; boardUrl?: string; nowS: number; columns: number }
439
440const toneColor: Record<string, string> = { needs: 'yellow', fail: 'red', run: 'blue', verify: 'magenta', ready: 'cyan', done: 'green' }
441const sectionOrder = ['Needs you', 'Running', 'Waiting', 'Done'] as const
442
443function plural(n: number, one: string, many = `${one}s`): string { return `${n} ${n === 1 ? one : many}` }
444function rowReason(task: RawTask, tasks: Record<string, RawTask>, nowS: number, supersededBy?: string): string {
445 if (supersededBy) return `Superseded: parent ${supersededBy} accepted`
446 const info = displayState(task, nowS)
447 if (info.raw === 'input-required') return `Needs your answer: "${String(task.waiting_on ?? '')}"`
448 if (info.raw === 'rejected') return `Rejected: ${String(task.last_reject_reason ?? 'needs a fix')}`
449 if (info.raw === 'failed') return `Failed: ${String(task.reason ?? 'executor failed')}`
450 if (info.raw === 'stale') return 'No heartbeat. The executor may have died.'
451 const unmet = (Array.isArray(task.depends_on) ? task.depends_on : []).filter(dep => tasks[String(dep)]?.state !== 'accepted')
452 if (unmet.length) return `Waiting on ${unmet.join(', ')} (blocked by ${unmet[0]}, needs you)`
453 if (info.raw === 'fixing') return `Waiting on fix ${(Array.isArray(task.fixed_by) ? task.fixed_by : []).join(', ')}`
454 if (info.raw === 'ready') return 'Ready, not picked up yet.'
455 if (info.raw === 'released') return `Retrying: attempt ${String(task.attempt ?? 1)} of ${String(task.max_attempts ?? '?')}.`
456 return ''
457}
458
459function boardLine(text: string, columns: number, color?: string, bold?: boolean): BoardSegment[] {
460 const safe = truncateTo(text, columns)
461 return [{ text: safe, ...(color ? { color } : {}), ...(bold ? { bold: true } : {}) }]
462}
463
464export function renderBoard(input: RenderBoardInput): BoardSegment[][] {
465 const { tasks, labels, nowS, columns } = input
466 const entries = Object.entries(tasks).map(([id, task]) => ({ id, task, state: boardState(id, task, tasks, nowS) }))
467 const inSection = (section: string) => entries.filter(row => row.state.section === section).sort((a, b) => section === 'Done'
468 ? Number(b.state.raw === 'superseded') - Number(a.state.raw === 'superseded') || asNumber(b.task.submitted_ts) - asNumber(a.task.submitted_ts) || a.id.localeCompare(b.id)
469 : a.id.localeCompare(b.id))
470 const needs = inSection('Needs you'), running = inSection('Running'), waiting = inSection('Waiting'), done = inSection('Done')
471 const completed = done.filter(row => row.state.raw !== 'superseded')
472 const total = entries.length
473 const runId = input.runId
474 const headline = running.length === 0 && needs.length > 0
475 ? `Nothing is running. ${needs.length === 1 ? '1 needs you' : `${needs.length} need you`}; ${waiting.length === 1 ? '1 is' : `${waiting.length} are`} waiting behind them.`
476 : `${running.length} running, ${waiting.length} waiting, ${completed.length} of ${total} done.`
477 const ribbon = total > 40
478 ? `✓ ${done.length} ● ${running.length} ? ${needs.length} · ${waiting.length}`
479 : [...done.map(() => '✓'), ...running.map(r => r.state.glyph), ...needs.map(r => r.state.glyph), ...waiting.map(r => r.state.glyph)].join('')
480 const lastEvent = asNumber(input.run.last_event_ts)
481 const idleAgeHours = lastEvent > 0 ? Math.floor((input.nowS - lastEvent) / 3600) : 0
482 const idleLabel = running.length === 0 && idleAgeHours > 1 ? `Idle, last activity ${idleAgeHours}h ago` : 'Idle'
483 const doneLabel = input.run.finished === true ? 'Finished' : needs.length ? 'Stalled: needs you' : running.length ? 'Running' : idleLabel
484 const doneColor = needs.length && !running.length ? 'red' : running.length ? 'blue' : 'green'
485 const runTokens = asNumber(input.run.tokens)
486 const cost = asNumber(input.run.cost_usd)
487 const rows: BoardSegment[][] = [
488 [{ text: 'ALE ' }, ...(!input.boardUrl ? [{ text: 'web: /ale:board ' }] : []), { text: `${runId} ` }, { text: `■ ${doneLabel}`, color: doneColor, ...(doneColor === 'red' ? { bold: true } : {}) }],
489 ...(input.runPath ? [boardLine(`Run directory: ${input.runPath}`, columns)] : []),
490 ...(lastEvent > 0 ? [boardLine(`Last event ${formatAge(Math.max(0, input.nowS - lastEvent))} ago`, columns)] : []),
491 ...(formatOtherRunsLine(input.runPath ?? '', input.otherRuns ?? [], input.nowS, columns) ? [boardLine(formatOtherRunsLine(input.runPath ?? '', input.otherRuns ?? [], input.nowS, columns)!, columns)] : []),
492 boardLine(headline, columns),
493 boardLine(`${ribbon} ${completed.length} of ${total} done ${formatTokens(runTokens)} tok, run total ${cost ? `$${cost.toFixed(2)}` : runTokens ? 'cost not reported' : '$0.00'}`, columns),
494 ]
495 const prefix = (id: string, state: DisplayState, boldId = false): BoardSegment[] => [
496 { text: `${state.glyph} `, color: toneColor[state.tone] ?? undefined, ...(['wait', 'cancel'].includes(state.tone) ? { dimColor: true } : {}) },
497 { text: id.padEnd(8), ...(boldId ? { bold: true } : {}) },
498 { text: ` ${state.label.padEnd(17)} `, color: toneColor[state.tone] ?? undefined, ...(['wait', 'cancel'].includes(state.tone) ? { dimColor: true } : {}) },
499 ]
500 const plainRow = (id: string, task: RawTask, state: DisplayState, section: string): BoardSegment[][] => {
501 const title = labels[id]?.title ?? id
502 const p = prefix(id, state, section === 'Needs you')
503 const base = p.map(s => s.text).join('')
504 const supersededBy = state.raw === 'superseded'
505 ? Object.entries(tasks).find(([, parent]) => parent.state === 'accepted' && Array.isArray(parent.fixes) && parent.fixes.includes(id))?.[0]
506 : undefined
507 const reason = rowReason(task, tasks, nowS, supersededBy)
508 if (section === 'Needs you') {
509 const first = `${base}${title}`
510 const lines = [p.concat([{ text: truncateTo(`${title}`, Math.max(0, columns - [...base].length)) }])]
511 if (reason) lines.push([{ text: ' '.repeat(11) }, { text: truncateTo(reason, Math.max(0, columns - 11)) }])
512 lines.push([{ text: ' '.repeat(11) }, { text: `Blocks ${entries.filter(other => (Array.isArray(other.task.depends_on) && other.task.depends_on.includes(id))).length} ${entries.filter(other => Array.isArray(other.task.depends_on) && other.task.depends_on.includes(id)).length === 1 ? 'task' : 'tasks'}` }])
513 if (columns >= 120 && asNumber(task.tokens)) lines[lines.length - 1].push({ text: ` ${formatTokens(task.tokens)} tok` })
514 return lines
515 }
516 const titleWidth = columns >= 120 ? 32 : columns >= 80 ? 16 : Math.max(0, columns - 29)
517 const titleText = truncateTo(title, titleWidth)
518 if (columns < 80) return [p.concat([{ text: titleText }])]
519 const reasonText = state.raw === 'superseded' ? reason : section === 'Done' ? `Done ${ageOf(task.submitted_ts, nowS)} ago` : reason
520 const tokenMeta = section === 'Done' && columns >= 120 && asNumber(task.tokens) ? `${formatTokens(task.tokens)} tok` : ''
521 const paddedTitle = titleText.padEnd(titleWidth)
522 const reasonWidth = Math.max(0, columns - [...base + paddedTitle].length - 1 - (tokenMeta ? [...tokenMeta].length + 1 : 0))
523 const reasonPart = truncateTo(reasonText, reasonWidth)
524 const fill = tokenMeta ? Math.max(1, columns - [...base + paddedTitle + ' ' + reasonPart + tokenMeta].length) : 0
525 return [p.concat([{ text: `${paddedTitle} ` }, { text: reasonPart }, ...(tokenMeta ? [{ text: `${' '.repeat(fill)}${tokenMeta}` }] : [])])]
526 }
527 rows.push(boardLine(`Needs you ${needs.length}`, columns))
528 for (const row of needs) rows.push(...plainRow(row.id, row.task, row.state, 'Needs you'))
529 rows.push(boardLine(`Running ${running.length}`, columns))
530 if (!running.length) rows.push(boardLine(' Nothing is running.', columns))
531 else for (const row of running) rows.push(...plainRow(row.id, row.task, row.state, 'Running'))
532 rows.push(boardLine(`Waiting ${waiting.length}`, columns))
533 const firstWaiting = waiting[0]
534 if (firstWaiting) {
535 const dependencies = Array.isArray(firstWaiting.task.depends_on) ? firstWaiting.task.depends_on : []
536 const firstDep = dependencies.length ? String(dependencies[0] ?? '') : ''
537 const blockerTask = firstDep ? tasks[firstDep] : undefined
538 const blocker = blockerTask ? displayState(blockerTask, nowS) : firstWaiting.state
539 rows.push(boardLine(` Blocked by ${firstDep || firstWaiting.id} (${blocker.label}), ${plural(waiting.length, 'task')}`, columns))
540 }
541 for (const row of waiting) rows.push(...plainRow(row.id, row.task, row.state, 'Waiting'))
542 rows.push(boardLine(`Done ${done.length}`, columns))
543 for (const row of done.slice(0, 3)) rows.push(...plainRow(row.id, row.task, row.state, 'Done'))
544 if (done.length > 3) rows.push(boardLine(` +${done.length - 3} more done`, columns))
545 if (input.boardUrl) rows.push(boardLine(`board: ${input.boardUrl} (copy into a browser) · /ale-board <run> switches`, columns))
546 else rows.push(boardLine('/ale-board <run> switches', columns))
547 return rows.map(line => {
548 const text = line.map(segment => segment.text).join('')
549 return [...text].length > columns ? boardLine(text, columns) : line
550 })
551}
552
553/** A short human age, from a `ts` (epoch seconds) to `nowS` (epoch seconds). */
554export function ageOf(ts: unknown, nowS: number): string {
555 if (typeof ts !== 'number' || !Number.isFinite(ts)) return '—'
556 const deltaS = Math.max(0, Math.round(nowS - ts))
557 if (deltaS < 60) return `${deltaS}s`
558 const minutes = Math.floor(deltaS / 60)
559 if (minutes < 60) return `${minutes}m`
560 const hours = Math.floor(minutes / 60)
561 const restMinutes = minutes % 60
562 if (hours < 24) return `${hours}h ${restMinutes}m`
563 const days = Math.floor(hours / 24)
564 const restHours = hours % 24
565 return `${days}d ${restHours}h`
566}
567
568export type TaskRow = {
569 id: string
570 state: string
571 text: string
572 breached: boolean
573}
574
575/**
576 * One board row, already truncated to `columns`. Format:
577 * `<glyph> <id> <title> <role>/<tier> <owner> #<attempt> <tokens>tok <age> <lastStep>`
578 */
579export function formatTaskRow(id: string, task: RawTask, label: LabelInfo | undefined, nowS: number, columns: number): TaskRow {
580 const state = asString(task.state)
581 const glyph = glyphFor(state)
582 const title = truncateTo(asString(label?.title, ''), 28)
583 const role = asString(label?.role, '')
584 const tier = asString(label?.tier, '')
585 const roleTier = role || tier ? `${role}${role && tier ? '/' : ''}${tier}` : ''
586 const assignees = Array.isArray(task.assignees) ? task.assignees.filter((x): x is string => typeof x === 'string').join(',') : ''
587 const owner = asString(task.owner, '') || assignees || '—'
588 const attempt = asNumber(task.attempt, 1)
589 const tokens = asNumber(task.tokens, 0)
590 const heartbeatTs = task.last_heartbeat_ts ?? task.started_ts
591 const age = ageOf(heartbeatTs, nowS)
592 const lastStep = asString(task.last_step, '')
593 const breached = asBreachCount(task.breaches_seen) > 0
594 const breachText = Array.isArray(task.breaches_seen) ? task.breaches_seen.map(value => Array.isArray(value) ? String(value[0]) : String(value)).join(',') : ''
595 const worktree = asString(task.worktree, '')
596 const worktreeLabel = worktree ? `wt/${worktree.split('/').filter(Boolean).pop()}` : ''
597 const integrated = task.integrated === true ? 'integrated' : ''
598 const verdict = isRecord(task.last_verdict) ? asString(task.last_verdict.verdict) : ''
599
600 const parts = [
601 `${glyph}${breached ? '!' : ' '}`,
602 id,
603 title,
604 roleTier,
605 owner,
606 `#${attempt}`,
607 `${tokens}tok`,
608 worktreeLabel,
609 integrated,
610 verdict ? `monitor:${verdict}` : '',
611 breachText ? `breach:${breachText}` : '',
612 age,
613 lastStep,
614 ].filter(part => part !== '')
615
616 return { id, state, text: truncateTo(parts.join(' '), columns), breached }
617}
618
619export type Section = { name: string; rows: string[] }
620
621/**
622 * Budgets sections against `maxRows` (each section costs one header row plus
623 * one row per task). When the total would scroll, collapse `accepted`
624 * first, then `planned`, to a single count line — the priority order the
625 * brief specifies. Sections with no rows are left as-is (they cost only
626 * their header line, which the caller may choose to skip when empty).
627 */
628export function budgetRows(sections: readonly Section[], maxRows: number): Section[] {
629 const totalRows = (secs: readonly Section[]): number => secs.reduce((n, s) => n + 1 + s.rows.length, 0)
630
631 const collapse = (name: string, secs: readonly Section[]): Section[] =>
632 secs.map(s => (s.name === name && s.rows.length > 1 ? { name: s.name, rows: [`${s.rows.length} more`] } : { ...s }))
633
634 let out: Section[] = sections.map(s => ({ ...s }))
635 if (totalRows(out) > maxRows) out = collapse('accepted', out)
636 if (totalRows(out) > maxRows) out = collapse('planned', out)
637 return out
638}
639
640/** The message a board with no discovered run shows. */
641export function noRunMessage(): string {
642 return 'no ALE run found · `ale init-run` to create one · `ale status --json` once a run exists'
643}
644
645/**
646 * The one-line AbovePrompt band: `ale <run> · N working · N input-required ·
647 * N accepted · N breaches`.
648 */
649export function bandLine(runId: string, tasks: Record<string, RawTask>, run: RawRun): string {
650 const counts: Record<string, number> = {}
651 let breaches = asBreachCount(run.breaches_seen)
652 for (const task of Object.values(tasks)) {
653 const state = asString(task.state)
654 counts[state] = (counts[state] ?? 0) + 1
655 breaches += asBreachCount(task.breaches_seen)
656 }
657 const summary = ['working', 'claimed', 'input-required', 'submitted', 'ready', 'planned', 'fixing', 'rejected', 'stale', 'released', 'accepted', 'failed', 'canceled']
658 .filter(state => counts[state])
659 .map(state => `${counts[state]} ${state}`).join(' · ')
660 return `ale ${runId}${summary ? ` · ${summary}` : ''} · ${asNumber(run.tokens)} tokens · ${breaches} breaches`
661}
662
663/** Picks the most recently modified directory entry, or undefined for none. */
664export function pickLatestRunDir(entries: readonly { name: string; mtimeMs: number }[]): string | undefined {
665 if (entries.length === 0) return undefined
666 let best = entries[0]
667 for (const entry of entries) {
668 if (best === undefined || entry.mtimeMs > best.mtimeMs) best = entry
669 }
670 return best?.name
671}
672