SLOPSHOPPER

tiny-spec

A tiny, opinionated spec-driven flow: scope/adopt → create → plan → build, with an independent reviewer per task, plus a live run view (/tiny-spec).

panebandcommandprocesstimer
★ 3v2.5.0MITupdated 2026-10-22GrayMa77er/tiny-spec
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tiny-spec
│ ┃ tiny-spec ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Run 2: Graph 3: Report 4: Explorer │ ┃ ⏺ Read(src/auth.ts) │ ┃ No .spec/ or BREAKDOWN.md in this directory. ⎿ 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 │ │ › /tiny-spec │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tiny-spec
1: Run 2: Graph 3: Report 4: Explorer r: Refresh No .spec/ or BREAKDOWN.md in this directory.
README

<img src="images/logo.png" alt="tiny-spec" width="200">

<h1 align="center">tiny-spec</h1>

<a href="https://pypi.org/project/tiny-spec/"><img src="https://img.shields.io/pypi/v/tiny-spec.svg?color=d97757" alt="PyPI version"></a> <a href="https://pypi.org/project/tiny-spec/"><img src="https://img.shields.io/pypi/dm/tiny-spec.svg?color=d97757" alt="PyPI downloads"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a> <a href="https://docs.claude.com/en/docs/claude-code/overview"><img src="https://img.shields.io/badge/Claude%20Code-skills-d97757.svg" alt="Claude Code"></a> <a href="https://github.com/GrayMa77er/tiny-spec/stargazers"><img src="https://img.shields.io/github/stars/GrayMa77er/tiny-spec?color=d97757" alt="Stars"></a>

<code>uvx tiny-spec install</code>

<!-- DEMO GIF SLOT — images/demo.gif Record the FAIL -> fix -> PASS loop, then uncomment: <img src="images/demo.gif" alt="tiny-spec build loop: the reviewer fails a task, the executor fixes it, the task commits" width="800"> -->

tiny-spec is a four-step workflow for Claude Code that turns a ticket into shipped, reviewed code. You write the intent, it produces a design, a task list, and then builds the work one task at a time. Every task is implemented by one agent and graded by an independent reviewer that runs the real tests before anything is committed.

That core is three skills and two agents. In front of it sit two front doors — pick the one that matches where you're starting. Over the top sits one router, tiny-spec-run, which drives the chain and, when you ask it to, works a whole list of features through to merged code. No config file, no build step.

   GREENFIELD                        BROWNFIELD
   starting from an idea             starting from a codebase
   tiny-spec-scope                   tiny-spec-adopt
   idea → Features                   real code → constitution
   BREAKDOWN.md                      constitution.md
            \                         /
             └───────────┬───────────┘
                         ▼
   tiny-spec-create  →  tiny-spec-plan  →  tiny-spec-build
   intent               design + tasks      per-task loop
   SPEC.md              PLAN.md             plan → implement
                        (+ ## Tasks)        → review → commit

   tiny-spec-run      one router. Walks the chain and stops before build —
                      or, asked to, builds each feature and merges it.
   tiny-spec-design   optional. Wireframes → tokens + gradeable screens.

Pick one front door, once per project. Starting from an idea with no code yet? Run tiny-spec-scope — it interviews the idea into a BREAKDOWN.md, a flat list of well-defined Features with draft acceptance criteria. Working in a codebase that already exists? Run tiny-spec-adopt — it reads your repo and derives the constitution from what's actually there: your real lint and test commands, your real layout, your real conventions. Have a single known ticket in a project that's already set up? Skip both and start at tiny-spec-create.

Already have a PRD? Commit it as PRD.md (or name its path) and ask /tiny-spec-run to build it. The run carves the PRD into features with tiny-spec-scope, then for each feature it cuts a branch, specs it from the PRD and the breakdown (asking you only what they leave open), drafts the plan in plan mode for you to approve, and builds it once you do.

Neither builds anything. They set the project up so the three core skills have something true to work from, and both write a regenerable file you can edit freely.

How small

Every other kit in this space is bigger. That is the whole pitch, so here is the receipt rather than the adjective:

skills / commandsagentsconfigartifacts per feature
tiny-spec7 (3 core + 1 router + 3 optional)2noneSPEC PLAN
GitHub Spec Kit10—specify initspec plan tasks checklist constitution research data-model contracts/ quickstart
OpenSpec12—.openspec.yamlproposal design tasks specs/
BMAD-METHOD585 personas35 × customize.tomlPRD architecture epics stories UX brief sprint-plan

The whole of tiny-spec is 2,195 lines of markdown across 7 skills and 2 agents. No config file, no build step, no orchestrator, no CLI to initialize a project — the skills are the product.

<sub>Counted on 2026-08-03 from each project's own repository, not its marketing copy — Spec Kit's templates/commands/, OpenSpec's skills/, BMAD's SKILL.md and customize.toml files on main. All three are far more popular than tiny-spec, and being smaller is a bet, not a proof of quality — re-run the counts yourself before believing them.</sub>

Why it's small

Most spec frameworks are generous by default: many phases, many agents, many generated documents. tiny-spec makes the opposite bet — keep one safeguard, drop the rest. That safeguard is the independent reviewer, which exercises acceptance criteria end to end, because a green unit test suite is not the same as working software.

The case for staying small:

  • Documents are context, and context isn't free. Every paragraph the agent has to hold is room it no longer has for your actual code. tiny-spec keeps the spine small — a constitution and a short memory — and injects only what each task needs.
  • Real work is a ticket inside a system, not a greenfield repo. tiny-spec binds to a ticket and references your task platform instead of re-describing the world.
  • Rigid pipelines fight the user. Mandatory phases impose ceremony on work that doesn't need it. The extra structure here is optional — add shape where it pays.
  • More moving parts is more to maintain. Orchestrators, ownership contracts, and config files are themselves a system you have to keep in sync. Two agents are not.
  • Generated docs can fake rigor. A folder of polished planning artifacts looks like progress, but the proof is the reviewer running your real tests before each commit.

That's the whole trade: where larger kits add machinery, tiny-spec adds one independent reviewer and stops.

Does the trade hold up?

Smaller is easy to claim, so the suite ships a harness that measures it. docs/eval/ runs tiny-spec headlessly on benchmark tasks in hermetic sandboxes, grades the produced code with held-out tests the suite never sees, and checks the reviewer's own verdict against that ground truth. The number that matters is false-PASS rate — how often the gate blesses code that is actually broken.

Most recent run — 5 benchmark tasks on v1.0.0:

held-out pass rate100% (5/5) — the code works, judged by tests it never saw
suite/truth agreement100% — the reviewer's verdict matched the held-out grader every time
false-PASS rate0% — it never called broken code done
blocker rate0%

Two earlier versions scored identically, which is the more interesting result: the loop is stable across releases rather than tuned to one commit — including through 1.0, which rebuilt the planning chain underneath it.

See docs/eval/README.md for the method and docs/sdd-evaluation-rubric.md for the rubric. Read both skeptically: it is my benchmark, of my own tool, scored by me, on small self-contained tasks — so it measures the loop on well-specified work, not large-codebase performance. The harness is in the repo so you don't have to take my word for it.

New to spec-driven development?

Spec-driven development (SDD) means writing down what you want and why before any code exists, then letting that spec drive the build. Instead of prompting an agent and hoping, you hand it a small, explicit contract — the intent, a design, and an ordered list of tasks. The payoff: the agent stops guessing, you review the plan before a line is written, and the result is checked against the spec rather than vibes.

Quickstart

Install the skills and agents into your Claude Code config with uv:

uvx tiny-spec install

Restart Claude Code so it picks up the new skills, then run the flow in your project:

/tiny-spec-scope     # starting from an idea: interview it into features (BREAKDOWN.md)
/tiny-spec-adopt     # starting from a codebase: derive the constitution from real code
/tiny-spec-create    # capture intent and requirements (binds a ticket, optional)
/tiny-spec-plan      # design it, harden the constitution, slice the task list
/tiny-spec-build     # build each task: implement, review, commit

Run one of the two front doors once per project, then create → plan → build per ticket. Or let the router drive:

/tiny-spec-run       # walks the chain, reconciling anything stale; stops before build
/tiny-spec-build     # build each task: implement, review, commit

tiny-spec-run resolves where your work stands and invokes whichever skill moves it forward. It writes nothing itself, it only delegates. By default it stops before tiny-spec-build — that's where you actually review the work.

Ask it to build and it goes all the way instead:

/tiny-spec-run build the backlog     # per feature: branch → plan → build → merge → next

It reads your BREAKDOWN.md (or a list you paste) and works the features one after another, merging each finished branch into main locally before starting the next — until it's done or reaches a terminal state it names out loud. See Working a whole list. Which of the two it does is decided from your opening request and never changes mid-run — a run you started as "get it ready" will not talk itself into building.

Building from a mockup? Run /tiny-spec-design after create. Commit your exports to design/ first; it reads what's there and asks which screens this ticket covers. Designs, if you have them walks through it.

Re-run install any time to update; tiny-spec uninstall removes only what it installed, and tiny-spec version prints the installed version. Each skill is copied (not symlinked) so every install is self-contained.

The skills and agents are plain markdown — copy them in by hand. Claude Code loads skills from ~/.claude/skills/ and agents from ~/.claude/agents/:

git clone https://github.com/GrayMa77er/tiny-spec.git
cd tiny-spec

mkdir -p "$HOME/.claude/skills" "$HOME/.claude/agents"
for s in tiny-spec-scope tiny-spec-adopt tiny-spec-run tiny-spec-create tiny-spec-design tiny-spec-plan tiny-spec-build; do
  cp -R "$s" "$HOME/.claude/skills/$s"
done
cp agents/*.md "$HOME/.claude/agents/"

If a skill name collides with one you already have, rename these before copying, or install one set at a time.

The repo is also a plugin and a one-plugin marketplace. A plugin is the only way to load the suite into a Claude Code Project, whose cloud threads load plugins rather than ~/.claude/skills:

/plugin marketplace add GrayMa77er/tiny-spec
/plugin install tiny-spec@tiny-spec

Installed this way, everything is namespaced: /tiny-spec:tiny-spec-run, and the agents are tiny-spec:tiny-spec-build-executor / -reviewer (the build skill dispatches either form). Pick one install method. A plugin plus uvx tiny-spec install loads every skill twice.

The plugin also carries the in-session run view (/tiny-spec), which needs Claude Code v2.1.287 or later. Older versions load the skills and agents and skip the view.

Watching a run

A long build-through run is easier to follow with a view of it. There are two, and both read only git and .spec/. They write nothing and keep no state file, so you can close them at any point and lose nothing.

  • In Claude Code: /tiny-spec (plugin install, Claude Code v2.1.287+). A one-line band above the prompt appears while a run is in flight, showing the batch, each active feature's progress, and how many executor or reviewer agents are running right now. /tiny-spec opens a pane with Run / Graph / Report / Explorer tabs.
  • In a browser: uvx tiny-spec view. It serves the same four tabs on 127.0.0.1 and refreshes every few seconds. Tick watch PRs to also git fetch and gh pr list once a minute, so a run happening in a Project's cloud threads shows up on your laptop, branch by branch.
TabShows
RunBatches in the order the router runs them, each feature's state, n/m tasks, the next task, and its worktree or PR
GraphThe needs: graph by depth, coloured by state, with the batch each feature lands in
ReportThe run's worst terminal state, what merged, what stopped and why, what waits on your merge, what never started, and the git worktree remove commands for merged lanes (printed, never run)
ExplorerThe constitution, then per feature: acceptance → REQ-N → covering tasks (gaps flagged), tasks, commits, decisions.md halts

uvx tiny-spec state --pretty prints the same derived state as JSON.

See a finished run first

examples/todo-cli/ is a real run of the flow on one small ticket, committed verbatim — the TICKET.md that went in, the SPEC.md, PLAN.md and constitution.md the suite wrote, and the code and tests it produced. The tests pass; you can clone it and run the gate yourself.

How it works

The constitution (constitution.md) is the spine. tiny-spec-adopt derives it from your codebase, or tiny-spec-create seeds it from a short interview; tiny-spec-plan hardens it with concrete engineering rules, and every task's executor and reviewer read it whole. It holds your style, standards, invariants, definition of done, and verification commands.

On an existing codebase, deriving beats asking. Your test command, layout, and conventions are already written down — in package.json, in CI, in your linter config, in the shape of the tree. tiny-spec-adopt reads them, marks each section as declared or inferred so you know which parts to distrust, then runs the verification commands it derived and reports which went green. A gate that has never been run turns every future review into theatre.

Because it is project-wide the constitution can also go missing — deleted, or never committed — while your specs survive. Re-running tiny-spec-adopt (or tiny-spec-create, on a project with no code yet) repairs it, and marks completed tasks stale, since they were reviewed against a document that wasn't there.

Designs, if you have them

Wireframes usually get read once and forgotten. tiny-spec turns them into two durable things — a project-wide token system in the constitution, and a per-screen entry in the spec — so "does this look right?" becomes something the reviewer can fail a task on. Skip all of it for a CLI or a library; the constitution simply has no design section.

Designs enter by convention, not by argument. No skill takes a design flag or a path parameter. tiny-spec-design reads every file in design/ at your project root and asks which screens this ticket covers; you can also just hand it paths. Change an export later and tiny-spec-run notices — it re-hashes every anchored export on its way down the chain, and routes a mismatch back to tiny-spec-design, marking the spec stale exactly like editing a requirement.

1. Commit your exports. Any format an agent can read — a Figma export, an HTML mockup, an Excalidraw file, a photo of a whiteboard.

your-project/
  design/
    signup.png
    dashboard.png

2. tiny-spec-design looks at them and proposes a design system. Actually looks — they are read as images. It infers one coherent scale across all of them rather than measuring each screen separately, tells you what it rounded ("your wireframes had 19px and 21px — proposing space.5=20px for both"), and on your approval writes it into .spec/constitution.md:

## Design system
- color:  `color.surface.base` #FFFFFF · `color.text.default` #111111
          `color.text.muted` #6B7280 · `color.text.danger` #B91C1C
- space:  `space.1`=4px · `space.2`=8px · `space.4`=16px · `space.6`=24px
- type:   `type.heading.lg` 24px/600/1.25 · `type.body` 16px/400/1.5
          `type.caption` 13px/400/1.4
- states: every interactive surface defines default, focus, disabled, loading, empty, error

Since it is project-wide, every screen from here on speaks this vocabulary — and a redesign edits this one table instead of every file.

3. Each screen becomes a D<n> in SPEC.md, written in those token names:

## Design

- D1 — Signup form
  - source: figma.com/file/abc#node-12:34 (view-only)
  - export: design/signup.png
  - sha256: d21d6330648c504edeb924b5398bf7fb6485d3a4c1907e43d800970f39622a1d
  - layout: single centered column, max 420px; title → field → error → submit
  - elements:
    - card    `[data-testid="signup-card"]`   → space.6 padding, color.surface.base
    - title   `[data-testid="signup-title"]`  → type.heading.lg, space.4 below
    - field   `[data-testid="signup-email"]`  → type.body, space.2 below
    - error   `[data-testid="signup-error"]`  → type.caption, color.text.danger
  - states: error (caption under the field), loading (spinner replaces button label)

The selectors are a contract, not a hint — the reviewer measures exactly these, so your markup carries them verbatim. Prefer test ids over CSS classes: classes get renamed by refactors and mangled by CSS-in-JS, and a selector that silently stops matching is the failure this exists to prevent.

4. Tag the tasks that build the surface — and only those, not the API call behind it. This is your blast radius:

- [ ] T4 — Build the signup form
  - acceptance: submitting a valid email advances to the verify step
  - design: D1

5. The reviewer measures it, then looks at it, and fails on both:

DESIGN: D1 — Signup form
  [data-testid="signup-card"]  padding 19px — not on the space.* scale (4/8/16/24)
  [data-testid="signup-email"] MISSING FROM DOM — never built, or renamed
  [data-testid="signup-error"] rgb(204,0,0) — color.text.danger is #B91C1C
  state "loading" never renders: button label stays "Continue", no spinner
  judge: read states default, error — compared against design/signup.png
    state "error": signup-error sits behind the card — every token right, and the
      user sees nothing where the export shows the red caption
    state "default": submit label "Create account" truncates to "Create acco…"
FINDINGS:
- flag: title/field gap feels tight (on-scale — does not fail the task)

Numbers first, and never a screenshot diff — pixel comparison goes flaky on font antialiasing and teams end up muting it. But no measurement catches an element that is present, on-token, and still not on screen: occluded, clipped, truncated, or the same color as what's behind it. So the last step reads a screenshot of each state next to your export and grades presence, legibility, and correspondence. Where a number already settled the question the eye may only flag, which keeps the two halves from contradicting each other — measurable or visible violations fail, taste comes back as flag: notes. A task with no design: tag is graded exactly as before.

tiny-spec ships no script — it can't know your stack. Write one, put it in the constitution's Verification commands, and the reviewer runs it:

## Verification commands
- test:   `npm test`
- visual: `node visual.mjs`
// visual.mjs — node visual.mjs '<selector>' ['<selector>'...]
import { chromium } from 'playwright';

const b = await chromium.launch({ channel: 'chrome' });   // your Chrome, no download
const p = await b.newPage({ viewport: { width: 900, height: 700 } });
await p.goto('http://localhost:3000/signup');             // your dev server + route

for (const sel of process.argv.slice(2)) {
  const el = await p.$(sel);
  if (!el) { console.log(`${sel}\n  MISSING FROM DOM`); continue; }   // required
  console.log(sel, JSON.stringify(await el.evaluate(n => {
    const c = getComputedStyle(n), r = n.getBoundingClientRect();
    return { padding: c.padding, margin: c.margin, fontSize: c.fontSize,
             fontWeight: c.fontWeight, lineHeight: c.lineHeight, color: c.color,
             background: c.backgroundColor, border: c.border,
             opacity: c.opacity, visibility: c.visibility,
             top: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
  })));
}

const state = process.env.STATE ?? 'default';      // however you drive states
const shot = `/tmp/visual-${state}.png`;
await p.screenshot({ path: shot, fullPage: true });
console.log('SCREENSHOT', state, shot);            // this line arms the judge

await b.close();

The missing-selector branch must print something — that is what turns a renamed element into a failure instead of a silent skip. top feeds the layout-order check, and opacity/visibility turn the cheapest kind of invisible element into a numeric failure. The SCREENSHOT line arms the judge; drop it and the numeric half still gates exactly as before, with the reviewer reporting judge: not run rather than quietly skipping it. Without a visual: command at all, a task tagged design: raises a blocker rather than passing quietly.

No Figma token, plugin, or design SaaS. A view-only account works fine: the committed export is what the agents read, and source: keeps the trail back. Change an export and its recorded sha256 stops matching, which marks the spec stale exactly like editing a requirement — so a design that moved under finished work can't pass unnoticed.

tiny-spec-build walks the task list top to bottom. Each task runs through one loop:

  1. Plan the task against the constitution (inline, brief).
  2. Implement it with a fresh tiny-spec-build-executor agent.
  3. Review it with an independent tiny-spec-build-reviewer agent that runs the gate and grades against the constitution and the task's acceptance.
  4. On pass, commit the code plus a checklist tick. On fail, loop back to the executor with the findings. After two failed attempts the run halts exhausted.

Every task's acceptance is exercised end to end, black-box, by the reviewer — that part never varies. What does vary is how much of the toolchain gets re-proven around it: a clean install → build runs on the first task, the last one, anything touching dependency or build config, and anything carrying design:; in between the reviewer runs lint and tests against the existing build. It escalates back to the full gate whenever the narrower one can't carry the verdict, and the authoritative clean run happens once more over the whole project as the final smoke. Re-installing from scratch on every task proved the same thing repeatedly and was the slowest part of the loop.

Both agents run on Sonnet by default. To change one for a single run, say so in the request, e.g. /tiny-spec-build executor on opus or reviewer on opus (or /tiny-spec-run build the backlog, reviewer on opus). To make it stick, set `TINY_SPEC_EXECUTOR_MODE

Source 1 files
hooks/tiny-spec-view.js 285 lines
1// tiny-spec view — the in-session half of the visualizer.
2//
3// Every few seconds this runs the plugin's own tiny_spec/state.py (the same derivation
4// `tiny-spec view` serves in a browser) and draws it inside Claude Code: a one-line band
5// above the prompt while a run is active, and a /tiny-spec pane with Run / Graph /
6// Report / Explorer tabs. On top of the git-derived state it shows what git can't —
7// which tiny-spec executor and reviewer subagents are running right now — read live
8// from $.agent.list(). It writes nothing: no store, no files, no state.
9
10const PANE = 'tiny-spec'
11const POLL_MS = 5000
12const ACTIVE = ['building', 'paused', 'blocked', 'exhausted', 'fork', 'review', 'built', 'planning']
13const HALTED = ['blocked', 'exhausted', 'fork', 'paused']
14const MARK = {
15  merged: '✓', built: '●', review: '⇡', building: '◐', paused: '‖', blocked: '✗',
16  exhausted: '✗', fork: '⑂', planning: '…', 'not-started': '○',
17}
18const COLOR = {
19  merged: 'green', built: 'green', review: 'blue', building: 'yellow', paused: 'magenta',
20  blocked: 'red', exhausted: 'red', fork: 'red', planning: 'gray', 'not-started': 'gray',
21}
22
23let snap = null // last state.py result
24let error = '' // why the last refresh failed, if it did
25let agents = [] // tiny-spec subagents in this session
26let tab = 'run'
27let picked = ''
28let busy = false
29
30// --------------------------------------------------------------------- data
31
32async function isProject($) {
33  return (await $.fs.exists('.spec')) || (await $.fs.exists('BREAKDOWN.md'))
34}
35
36async function refresh($) {
37  if (busy) return
38  busy = true
39  try {
40    if (!(await isProject($))) {
41      snap = null
42      return
43    }
44    const script = $.plugin.root + '/tiny_spec/state.py'
45    const root = await $.session.cwd()
46    let out = null
47    for (const py of ['python3', 'python']) {
48      try {
49        out = await $.process.run([py, script, '--root', root])
50        break
51      } catch (err) {
52        out = null
53      }
54    }
55    if (!out) {
56      error = 'needs python3 on PATH to read git state'
57    } else if (out.exitCode !== 0) {
58      error = (out.stderr || 'state.py failed').trim().split('\n').pop()
59    } else {
60      snap = JSON.parse(out.stdout)
61      error = ''
62    }
63    const all = await $.agent.list()
64    agents = (all || []).filter((a) => /tiny-spec-build-(executor|reviewer)/.test(agentKind(a)))
65  } catch (err) {
66    error = String((err && err.message) || err)
67  } finally {
68    busy = false
69    $.ui.invalidate('ui.render')
70  }
71}
72
73function agentKind(a) {
74  return String(a.agentType || a.type || a.name || a.description || '')
75}
76
77function role(a) {
78  return /reviewer/.test(agentKind(a)) ? 'reviewer' : 'executor'
79}
80
81function live() {
82  return agents.filter((a) => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
83}
84
85function features() {
86  return (snap && snap.features) || []
87}
88
89function isActive() {
90  return live().length > 0 || features().some((f) => ACTIVE.includes(f.state))
91}
92
93function progress(f) {
94  return f.total ? f.done_count + '/' + f.total : ''
95}
96
97function batchLabel() {
98  const n = (snap && snap.batches.length) || 0
99  return n ? 'batch 1/' + n : 'backlog merged'
100}
101
102// --------------------------------------------------------------------- hooks
103
104export function register(on) {
105  on('session.start', async ($, e, next) => {
106    $.clock.every(POLL_MS, () => refresh($))
107    refresh($)
108    try {
109      await $.command.register({
110        name: 'tiny-spec',
111        description: 'Open the tiny-spec run view (Run / Graph / Report / Explorer)',
112        immediate: true,
113      })
114    } catch (err) {
115      $.ui.log('could not register /tiny-spec: ' + String((err && err.message) || err))
116    }
117    return next(e)
118  })
119
120  on('command.run', { command: 'tiny-spec' }, async ($) => {
121    await refresh($)
122    await $.ui.open({ id: PANE, title: 'tiny-spec', focus: true, closeOnEscape: true })
123    return {}
124  })
125
126  // The band: one line, only while a run is in flight, so it never clutters a
127  // session that isn't building anything.
128  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129    if (!snap || !isActive()) return next(e)
130    const { Box, Text } = $.ui.resolve(e)
131    const parts = [Text({ bold: true, children: ['▸ tiny-spec'] }), Text({ dimColor: true, children: [batchLabel()] })]
132    for (const f of features().filter((x) => ACTIVE.includes(x.state)).slice(0, 4)) {
133      const cur = f.current_task ? ' ' + f.current_task.id : ''
134      parts.push(Text({ color: COLOR[f.state], children: [MARK[f.state] + ' ' + f.slug + ' ' + progress(f) + cur] }))
135    }
136    const running = live()
137    if (running.length) {
138      parts.push(Text({ color: 'yellow', children: [running.length + ' agent' + (running.length > 1 ? 's' : '') + ' running'] }))
139    }
140    parts.push(Text({ dimColor: true, children: ['/tiny-spec'] }))
141    const theirs = await next(e)
142    return Box({ flexDirection: 'column', children: [Box({ flexDirection: 'row', columnGap: 2, children: parts }), theirs] })
143  })
144
145  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
146    if (e.requestId !== PANE) return next(e)
147    const ui = $.ui.resolve(e)
148    const { Box, Text, Button } = ui
149    const redraw = () => $.ui.invalidate('ui.render')
150    const tabButton = (name, label, hotkey) =>
151      Button({
152        key: 'tab-' + name, label, hotkey, plain: true, dimColor: tab !== name,
153        onPress: () => { tab = name; redraw() },
154      })
155    const header = Box({
156      flexDirection: 'row', columnGap: 3,
157      children: [
158        tabButton('run', 'Run', '1'), tabButton('graph', 'Graph', '2'),
159        tabButton('report', 'Report', '3'), tabButton('explorer', 'Explorer', '4'),
160        Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, dimColor: true, onPress: () => refresh($) }),
161      ],
162    })
163    let body
164    if (error) body = [Text({ color: 'red', children: ['tiny-spec: ' + error] })]
165    else if (!snap) body = [Text({ dimColor: true, children: ['No .spec/ or BREAKDOWN.md in this directory.'] })]
166    else if (tab === 'graph') body = graphView(ui)
167    else if (tab === 'report') body = reportView(ui)
168    else if (tab === 'explorer') body = explorerView(ui, redraw)
169    else body = runView(ui)
170    return Box({ flexDirection: 'column', children: [header, Text({ children: [' '] }), ...body] })
171  })
172}
173
174// --------------------------------------------------------------------- views
175
176function line({ Text }, f, extra) {
177  return Text({
178    color: COLOR[f.state],
179    wrap: 'truncate-end',
180    children: [MARK[f.state] + ' ' + f.title + '  ' + f.slug + '  ' + f.state + (progress(f) ? ' ' + progress(f) : '') + (extra || '')],
181  })
182}
183
184function runView(ui) {
185  const { Text } = ui
186  const out = []
187  const by = Object.fromEntries(features().map((f) => [f.slug, f]))
188  snap.batches.forEach((batch, i) => {
189    out.push(Text({ bold: true, children: ['Batch ' + (i + 1) + ' of ' + snap.batches.length + (i === 0 ? ' · current' : '')] }))
190    for (const slug of batch) {
191      const f = by[slug]
192      const cur = f.current_task ? '  next ' + f.current_task.id + ' ' + f.current_task.title : ''
193      out.push(line(ui, f, cur))
194    }
195  })
196  const merged = features().filter((f) => f.state === 'merged')
197  if (merged.length) out.push(Text({ dimColor: true, children: ['Merged: ' + merged.map((f) => f.slug).join(', ')] }))
198  const running = live()
199  out.push(Text({ children: [' '] }))
200  out.push(Text({ bold: true, children: ['Live agents'] }))
201  if (!running.length) out.push(Text({ dimColor: true, children: ['none running'] }))
202  for (const a of running) {
203    out.push(Text({ color: 'yellow', wrap: 'truncate-end', children: ['◐ ' + role(a) + ' · ' + a.status + (a.description ? ' · ' + a.description : '')] }))
204  }
205  for (const p of snap.problems || []) out.push(Text({ color: 'red', children: ['! ' + p] }))
206  return out
207}
208
209function graphView(ui) {
210  const { Text } = ui
211  const out = [Text({ dimColor: true, children: ['needs: edges, by depth — arrows point from a feature to what must come first'] })]
212  const depth = Math.max(0, ...features().map((f) => f.depth))
213  for (let d = 0; d <= depth; d++) {
214    out.push(Text({ bold: true, children: ['depth ' + d] }))
215    for (const f of features().filter((x) => x.depth === d)) {
216      out.push(line(ui, f, f.needs.length ? '  ← ' + f.needs.join(', ') : ''))
217    }
218  }
219  return out
220}
221
222function reportView(ui) {
223  const { Text } = ui
224  const fs = features()
225  const merged = fs.filter((f) => f.state === 'merged')
226  const halted = fs.filter((f) => HALTED.includes(f.state))
227  const waiting = fs.filter((f) => f.state === 'review' || f.state === 'built')
228  const left = fs.filter((f) => f.state === 'not-started' || f.state === 'planning')
229  const worst = ['blocked', 'exhausted', 'fork', 'paused'].find((s) => snap.counts[s])
230  const state = worst || (merged.length === fs.length && fs.length ? 'done' : 'in progress')
231  const out = [Text({ bold: true, color: worst ? 'red' : 'green', children: ['Run state: ' + state + ' — ' + merged.length + ' of ' + fs.length + ' merged'] })]
232  if (halted.length) {
233    out.push(Text({ bold: true, children: ['Stopped'] }))
234    for (const f of halted) {
235      const last = f.halts && f.halts[f.halts.length - 1]
236      const why = f.state === 'paused' ? 'pause: ' + (f.current_task ? f.current_task.pause : '') : last ? last.note || last.title : ''
237      out.push(line(ui, f, '  at ' + (f.current_task ? f.current_task.id : '?') + ' — ' + why))
238    }
239  }
240  if (waiting.length) {
241    out.push(Text({ bold: true, children: ['Built, not merged'] }))
242    for (const f of waiting) out.push(line(ui, f, f.pr ? '  PR #' + f.pr.number : ''))
243  }
244  if (left.length) out.push(Text({ dimColor: true, children: ['Never started: ' + left.map((f) => f.slug).join(', ')] }))
245  if (snap.cleanup.length) {
246    out.push(Text({ bold: true, children: ['Worktree cleanup (yours to run)'] }))
247    for (const c of snap.cleanup) out.push(Text({ dimColor: true, wrap: 'truncate-middle', children: [c.command] }))
248  }
249  return out
250}
251
252function explorerView(ui, redraw) {
253  const { Text, Select } = ui
254  const fs = features()
255  if (!fs.length) return [Text({ dimColor: true, children: ['No features yet.'] })]
256  if (!fs.find((f) => f.slug === picked)) picked = fs[0].slug
257  const f = fs.find((x) => x.slug === picked)
258  const out = [
259    Select({
260      key: 'feature', label: 'Feature', value: picked,
261      options: fs.map((x) => ({ value: x.slug, label: MARK[x.state] + ' ' + x.title })),
262      onSelect: (v) => { picked = v; redraw() },
263    }),
264    line(ui, f, f.ref ? '  from ' + f.ref : ''),
265  ]
266  if (f.reqs.length) {
267    out.push(Text({ bold: true, children: ['Requirements'] }))
268    for (const r of f.reqs) {
269      const tasks = f.tasks.filter((t) => t.req.includes(r.id)).map((t) => (t.done ? '✓' : '○') + t.id)
270      const cover = tasks.length ? tasks.join(' ') : f.tasks.length ? 'UNCOVERED' : ''
271      // Omit color rather than pass undefined: a prop the element can't take voids the tree.
272      const props = { wrap: 'truncate-end', children: [r.id + '  ' + r.text + '  ' + cover] }
273      out.push(Text(tasks.length || !f.tasks.length ? props : { ...props, color: 'red' }))
274    }
275  }
276  if (f.tasks.length) {
277    out.push(Text({ bold: true, children: ['Tasks'] }))
278    for (const t of f.tasks) {
279      out.push(Text({ dimColor: t.done, wrap: 'truncate-end', children: [(t.done ? '✓ ' : '○ ') + t.id + ' ' + t.title + (t.pause ? '  [pause: ' + t.pause + ']' : '')] }))
280    }
281  }
282  if (f.commits.length) out.push(Text({ dimColor: true, children: [f.commits.length + ' commit(s), last: ' + f.commits[0].subject] }))
283  return out
284}
285