SLOPSHOPPER

ale

/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…

newpanebandcommandprocesstimer
v0.4.5MITupdated 2026-10-06yodem/agentic-label-engineering
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ale
│ ┃ ALE task board ✕ › fix the failing auth test and add an audit log call │ ┃ ale-board │ ┃ no ALE run found · `ale init-run` to create ⏺ Read(src/auth.ts) │ ┃ one · `ale … ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /ale-board │ ⎿ ale: board above the prompt · /ale-board close closes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ALE task board
ale-board no ALE run found · `ale init-run` to create one · `ale …
README

Agentic Label Engineering (ALE)

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:

  • An orchestrator writes one label per task: who should do it, which files it may change, and the 2 to 5 shell commands that prove it is finished.
  • Every claim, heartbeat and submission goes into an append-only event log. State is computed from the log, never stored.
  • Only 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 watchdog with no model in it flags stale claims, stuck tasks and overruns.

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.

Install

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).

Claude Code plugin

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.

Pi and Codex

Quick start: one task by hand, no agent

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).

Concepts

  • Label: 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.
  • Roster: your vocabulary plus the (role, model_tier) -> executor, model table. A new model is a one-line change here. ale setup writes one to .ale/roster.json.
  • Board: events.jsonl, append only. State is computed from it. Handoff files under handoff/ are rendered views for humans and successor agents.
  • Lease: a claim lives while heartbeats arrive. The watchdog releases dead claims.
  • Completion barrier: only ale verify writes accepted, after running the acceptance commands itself.
  • Stacked tasks: a task with one dependency can start from that dependency's accepted commit. See docs/label-layer.md.
  • Event authorship: system events (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.

Executors

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):

ModeWhenRuns
in-sessioninline or workflow lane, a harness that can run in the lead's session (claude) and an Anthropic modelan in-session subagent the lead starts; dispatch creates and records its worktree
headlessany other inline or workflow task on the local hostthe harness's headless argv (for example codex exec --json --skip-git-repo-check …) wrapped by bin/ale-exec
panea pane lane, or any non-in-session task on the roster's remote_hosta 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.

Setup

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.

Optional judge

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.

Exit codes

0 ok, 1 check failed, 2 usage, 3 claim lost, 4 lease lost, 5 needs sign-off, 6 breaches found.

Eval loop

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.

Watchdog

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.

Harness support

The status in each cell describes the shipped integration, not a claim about what the underlying harness could support in a future adapter.

RuleClaude Code hooksCodex hooksPi extensionale-exec wrapper
Path guardEnforced: PreToolUse edit denialEnforced: PreToolUse denialEnforced: edit and write eventsNot possible: verify only
Auto heartbeatEnforced: PostToolUse, 60 s throttleNot possible: current adapter has no PostToolUse entryEnforced: post-tool hookEnforced: timer heartbeat
Stop/submit gateEnforced: Stop acceptance gateNot possible: current adapter has no Stop entryAdvisory: settlement runs the gate but print mode cannot blockEnforced: exit-time check then submit or input-required
Usage captureEnforced: transcript IDs are deduplicatedAdvisory: --usage-from codex-json on wrapperEnforced: assistant message usageEnforced: printed JSON usage when configured
Session contextEnforced: SessionStart stdoutNot possible: current adapter has no SessionStart entryEnforced: session start injectionNot 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.

Limits and trust boundary

  • POSIX only. Local filesystems only: the append guarantee does not hold on network mounts.
  • Acceptance commands run with 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.
  • Changed paths are normalised before containment is checked; absolute paths and paths that escape the project are always violations.
  • 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.

Documentation

ReadFor
Explainer pageA visual walkthrough of the whole idea
docs/label-layer.mdPlan format, label fields, worktrees, fix tasks, the run loop
docs/analyze.mdThe eval loop: ale analyze, ale eval cases, the ledger, fix records
docs/agents.mdAgent taxonomy, catalog lookup, rule enforcement
docs/hooks.mdWhat the hooks enforce, per harness
docs/labeling.mdThe label cascade and judge shadow decisions
docs/judge.mdThe optional judge command and its contract
docs/harness-facts.mdVerified hook facts for Claude Code, Pi and Codex
EXECUTOR.mdThe protocol an executor agent follows
AGENTS.md, CLAUDE.mdInstructions for coding agents that work on this repository
CHANGELOG.mdRelease history

Contributing

See CONTRIBUTING.md. Tests: uvx --python 3.9 pytest -q and cd mod && bun test.

License

MIT. See LICENSE. Parts of agents/ and catalog/refs/ derive from OrchestKit under MIT; see NOTICE.

Source 2 files
mod/register.tsx 464 lines
1// 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}
464
mod/lib.ts 672 lines
1// 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