tiny-spec-build · git:20261016.9494129 · 2026-10-16 · sha256 397b664340d3384d
tiny-spec-build git:20261016.9494129A
Immutable. This exact content never changes and is served at /api/v1/blob/397b664340d3384d.
---
name: tiny-spec-build
description: Build the spec — run the per-task loop plan→implement→review→commit, one task at a time. Implements with a fresh executor, grades with an independent reviewer running the real gate, commits per passed task, keeps a lean memory. Can be handed several lanes (one worktree + slug per independent feature) and runs them concurrently, one task at a time within each. Halts on a blocker, a spent convergence budget, a task's pause: point, or a genuine fork, and names which. Resumes from the checkbox state.
---
# tiny-spec-build
The heart of the flow. Walks the `## Tasks` checklist in the active ticket's `PLAN.md`
top to bottom and runs each task through a tight loop: **plan → implement → review → commit**. The constitution
(`constitution.md`) anchors every step; the thing that writes the code is never the
thing that grades it.
Artifacts live under `.spec/`: the **shared** constitution and memory at the root
(`.spec/constitution.md`, `.spec/memory.md`), the per-ticket `PLAN.md`/`SPEC.md`/
`decisions.md` under `.spec/<ticket-id>/`. The `memory.md` skeleton is inline below —
write it from there, no file to read. The two subagents dispatched below
(`tiny-spec-build-executor`, `tiny-spec-build-reviewer`) are referenced by name — install them
alongside this skill (see the suite README). Installed as the `tiny-spec` **plugin**, they
are namespaced `tiny-spec:tiny-spec-build-executor` and `tiny-spec:tiny-spec-build-reviewer`;
dispatch whichever form your agent list shows. Never substitute a general-purpose agent
for a missing one — say it isn't installed and stop.
## Inputs
1. **Resolve the active ticket dir** (call it `<active>`), in order: the `.spec/<slug>/`
whose slug matches the current git branch (one branch per ticket); else the sole
ticket dir if exactly one exists; else ask. **Ask instead** when more than one dir
matches the branch, or when ticket dirs exist while you are on `main`/`master` with no
name match — neither has a safe tie-break. Detached HEAD or no git repo is
**degraded**, not an ask: fall through to sole-dir and ask as written.
In **multi-lane mode** (below) this step does not run: each lane is handed its
worktree and slug explicitly.
2. Read `.spec/constitution.md` (**the shared constitution**), `.spec/memory.md` if
it exists (**shared**), and the `## Tasks` section of `.spec/<active>/PLAN.md`. Every
executor and reviewer reads the constitution + memory **whole**, but by **path**, from
its own working directory. You never paste them into a brief (see **Briefs carry
paths** below). Also note the `ticket` binding in `.spec/<active>/SPEC.md`. It supplies
the commit `Refs:` footer.
3. Refuse to start if `PLAN.md` is `status: stale`, or if `.spec/<active>/tasks.md`
still exists (a pre-2.0 checklist) — tell the user to re-run `tiny-spec-plan` to
reconcile first.
4. Pick the **first unchecked `[ ]`** task. If all are `[x]`, jump to **Completion**.
5. **Fix the executor's model, once, for the whole run.** The first of these that gives
one wins:
1. a model named for the executor in this run's request or brief ("executor on
opus"), or a standing instruction in the user's CLAUDE.md
("tiny-spec executor model: opus");
2. the `TINY_SPEC_EXECUTOR_MODEL` environment variable. Read it with `printenv
TINY_SPEC_EXECUTOR_MODEL`; empty output means unset;
3. nothing. The executor's own default (`model: sonnet` in its definition) applies.
Pass the result as the dispatch's `model` on **every** executor dispatch in the run:
every lane and every fix attempt. If step 3 applies, pass no `model`. **The reviewer never
gets it.** Don't re-resolve mid-run, and name the model and where it came from in the
closing report.
6. **Fix the reviewer's model, once, the same way and independently of step 5.** Use
the same three sources, but only ones that name the *reviewer*: "reviewer on opus", a
CLAUDE.md line "tiny-spec reviewer model: opus", or `printenv TINY_SPEC_REVIEWER_MODEL`.
If none is set, pass no `model` and the reviewer's own `model: sonnet` applies. An
executor model never fills in for a reviewer model, in either direction: grading stays
independent of whatever wrote the code. Pass the result on every reviewer dispatch, and
name it in the closing report next to the executor's.
## Multi-lane mode (optional — several features at once)
A caller may hand you **lanes** instead of a single active dir: one or more
`(worktree path, slug)` pairs, each an independent feature in its own git worktree.
`tiny-spec-run` does this for a batch of features whose `needs:` say they don't depend on
each other. **With one lane, or none, everything below is inert and this skill behaves
exactly as it always has** — that is the common case and it must not drift.
Given lanes, run them **in rounds**:
1. For every live lane, pick its first `[ ]` task and build the executor brief exactly as
step 2 describes — resolved against **that lane's worktree**, not the main checkout.
2. **Dispatch every lane's executor in one message**, so they run concurrently.
3. **Dispatch every lane's reviewer in one message**, once the executors are back.
4. Commit and tick per passing lane, in that lane's worktree.
Then start the next round with whichever lanes are still live. A fast lane waits at the
round boundary for a slow one; that barrier is deliberate, and much simpler to follow than
letting lanes free-run.
**Within a lane nothing changes.** Tasks still run one at a time, top to bottom, with the
same convergence bound, the same gate scope rules, and the same halting states. There is
no parallelism *inside* a feature and no `owns:` contract — tasks in one feature share files
and assume their predecessors landed, which is exactly why they stay sequential.
**A halt stops that lane only.** The other lanes run to completion; you report each lane's
terminal state separately. The features were declared independent, so killing working lanes
because one failed throws away finished work for nothing.
**Every dispatched agent is told its working directory**, and that all paths resolve
against it. An executor or reviewer must never read or write another lane's worktree.
### `memory.md` in multi-lane mode
`.spec/memory.md` is **shared at the `.spec/` root**, and step 6 below writes to it. Two
lanes appending to their own copy in their own worktree would conflict on *every* parallel
batch — a guaranteed merge failure that has nothing to do with the code.
So: **in multi-lane mode, lanes do not write `memory.md` at all.** Collect each lane's
distilled lessons as it goes, and write them **once, in the main checkout on the
integration branch, after the batch has merged** — pruning superseded entries there, as
step 6 describes. Single-lane runs are unaffected and write it in place as before.
The other artifacts are safe and need no special handling: `PLAN.md` and `decisions.md`
are per-feature, and `constitution.md` is read-only for the whole build.
## Briefs carry paths, not contents
Every byte you paste into a dispatch is an **output** token on the orchestrating model,
the most expensive token in the run, and you would paste it again on every task and every
fix attempt. So a brief names files, and the agent reads them on its own, cheaper model:
- the constitution as `.spec/constitution.md`, and memory as `.spec/memory.md` when it
exists, both resolved against the agent's working directory;
- a `design:` task's screen as its id plus `.spec/<slug>/SPEC.md` ("read `D3` in
`.spec/<slug>/SPEC.md`"), with the `export:` path from that entry.
Write out only what exists nowhere on disk: the working directory, the task's own fields,
the gate scope, the accumulated `CHANGES` paths, and on a fix attempt the reviewer's
findings. Never summarize the constitution in place of its path. An agent grading against
your paraphrase is grading against a document nobody wrote.
## The per-task loop
For the selected task, run these steps in order. **Do not tick a task until its
reviewer passes.**
**Before step 1, check the task's `pause:` field.** If it has one and the user hasn't
waived it for this run, **halt `paused`** (see **Halting** below): leave the task
`[ ]`, dispatch nothing, and don't touch `PLAN.md`. A pause stops *before* the work so
the approach gets reviewed while redirecting it is still cheap.
A pause can be waived or added for the current run only, conversationally — "skip the
pause on T5", "also stop before T9". Honor it for this run and **never write it back
into `PLAN.md`**: the file records the standing intent, not one run's override.
### 1. PLAN (inline, brief)
Restate the task as a 2–4 step micro-plan against the constitution: which
**invariants** apply, which files it touches, which **Definition of Done** items
and **Verification commands** it must satisfy. This is the executor's brief — keep
it short and concrete.
### 2. IMPLEMENT (dispatch `tiny-spec-build-executor`)
Spawn one **`tiny-spec-build-executor`**, with the model fixed in **Inputs** step 5 when
there is one, and a fresh brief (paths, per **Briefs carry paths** above):
- **the working directory** it must operate in — this lane's worktree, or the project
root in a single-lane run. Every path resolves against it, and it must never read or
write another lane's worktree;
- the task id, description, and **acceptance**;
- the `files:` hint;
- the path `.spec/constitution.md`, to read whole before starting;
- the path `.spec/memory.md` if it exists, to read whole before starting;
- **if the task has a `design:` field**: the `D<n>` id and the path
`.spec/<active>/SPEC.md` to read it from, so the executor can look at the design instead
of guessing at it;
- only the specific existing files the task starts from, named explicitly (so it
edits with the real current contents, not blind);
- the **accumulated `CHANGES` paths from every passed task in this feature so far**,
labelled as the ground this feature has already moved. Every executor starts cold and
re-derives the codebase from scratch — that re-derivation is the **largest single cost
in the loop**, far larger than the prompt text around it — and naming the files this
feature has already touched is the cheapest way to cut it. You already have the lists in
hand. **Paths only**, deduplicated, most-recently-touched first — never the earlier
tasks' descriptions, findings, or reports. (The reviewer does *not* get this list: it
is scoped to the task's own changed files on purpose, and widening it would dilute the
independence that makes its verdict worth anything.)
Do **not** pass the plan, sibling tasks, or other chatter. It returns a structured
report (`STATUS`, `CHANGES`, `DECISIONS`, `BLOCKER`). A `STATUS: blocked` →
**Blockers** below; do not proceed with this task.
### 3. REVIEW (dispatch `tiny-spec-build-reviewer` — independent)
Spawn one **`tiny-spec-build-reviewer`**, **blind to step 2**, with the model fixed in
**Inputs** step 6 when there is one, and a brief of paths:
- **the working directory** it must operate in — this lane's worktree, or the project
root in a single-lane run. It runs the gate there and touches no other lane;
- the task id, description, and **acceptance**;
- the path `.spec/constitution.md`, to read whole before grading;
- the path `.spec/memory.md` if it exists, so a known precondition doesn't false-fail;
- the list of changed files (from the executor's `CHANGES`) to read;
- the **Verification commands** from the constitution to run, and the **gate scope**
for this task — `full` or `scoped` (decide it as below; you have the run context,
the reviewer does not);
- **if the task has a `design:` field**: the same `D<n>` id and `SPEC.md` path you gave
the executor, so it grades against the contract rather than its taste.
**Gate scope.** A clean `install → build` proves the same thing on task 7 that it proved
on task 1 unless a task changed what it installs or builds, so running it every time buys
little and costs the most wall-clock in the loop. Name the scope in the brief:
- **`full`** — the whole gate from a clean state (install → lint → test → build → run).
Use it on: the **first** task of this run; any task whose `CHANGES` touch dependency or
build configuration (`package.json`/lockfiles, `pyproject.toml`, `go.mod`, `Cargo.toml`,
`Dockerfile`, CI config, build scripts); any task carrying `design:`; and the **last**
task in `## Tasks`.
- **`scoped`** — otherwise. `lint:` + `test:` plus the acceptance exercised end-to-end,
black-box, exactly as on a full gate. The acceptance exercise is never scoped away; it
is the whole reason the reviewer exists.
The reviewer may **escalate** `scoped` to `full` on its own and will say so — that
direction is always safe. It may never go the other way.
It runs the gate at the scope you named, checks the code against the constitution's
**Definition of Done** and **invariants**, confirms the **acceptance** actually
holds (exercised, not inferred), and returns `VERDICT: PASS | FAIL` + findings.
On a `design:` task it also runs the constitution's `visual:` command, measures the
selectors the `D<n>` names against the Design system tokens, exercises the states it
names, and finally **looks at the render beside the design export** — catching what
numbers can't reach, like an element that measures perfectly and shows up invisible.
It fails on what it measured or saw, and merely flags what is taste.
> Why independent: unit-green ≠ working, and the author is the worst judge of its
> own blind spots. The reviewer exercising the acceptance black-box — on every task,
> at either scope — is the safeguard that keeps scope and quality honest without an
> ownership contract.
### 4. CONVERGE (on FAIL)
Re-dispatch the **executor** with the reviewer's findings appended to its brief, then
review again — recomputing the **gate scope** from the fix's `CHANGES`, and using `full`
if the failure was in the gate itself or the reviewer escalated last time. A task that
already failed once has earned the wider check.
Bound this to **2 fix attempts**. If it still fails after that, stop and **halt
`exhausted`** (see **Halting** below) — don't keep grinding or hand-fix past the loop
silently. `exhausted` is recorded as a blocker, because a task that three attempts
couldn't turn green is a signal the upstream document is wrong — but name it
`exhausted`, not `blocked`: the executor never said it couldn't proceed, it just
never got there.
### 5. COMMIT + TICK (on PASS)
Two commits, in order (keeps code history clean of planning churn), both in
**Conventional Commits** format:
1. **Code commit** — stage only the source/test files the executor produced;
message `<type>(<scope>): <task description>`. The **type** is the task's `type:`
field, defaulting to `feat`; **scope** is optional (a component, or the ticket
id). A breaking change uses `!` and/or a `BREAKING CHANGE:` footer.
2. **Bookkeeping commit** — tick the task `[x]` in `.spec/<active>/PLAN.md` (bump
`updated`), add any `decisions.md` entry; message `chore(spec): tick T<n>`. The
checkbox and `updated:` are the **only** things this skill ever changes in
`PLAN.md` — never the design prose, never a task's fields, never `status:`.
**`Refs:` footer (ticket linking).** If `.spec/<active>/SPEC.md` has a `ticket`
binding, append a `Refs:` footer to **both** commits so the platform auto-links the
work (omit it entirely if there's no ticket). Always end with the `Co-Authored-By`
trailer:
```
<type>(<scope>): <description>
Refs: <ticket-ref>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
```
Build `<ticket-ref>` from the binding's provider: Jira → `PROJ-123`,
GitHub → `#123`, ADO → `AB#123`, Monday → the item URL. The **closing** keyword
(`Closes #123`, `Fixes AB#123`) is reserved for the final / PR commit, **not** the
per-task commits.
Never commit a red gate. Never tick a task the reviewer did not pass.
### 6. DISTILL (memory)
If steps 2–4 surfaced a **forward-acting operational lesson** (a toolchain quirk,
a flaky/precondition gate, an abandoned approach, a fragile area), append a curated
entry to the **shared** `.spec/memory.md` (the root — lessons are project-wide) —
pruning any entry the new one supersedes. Skip code-style rules (→ shared
`constitution.md`) and one-off history (→ the ticket's `decisions.md`). Keep it lean.
**In multi-lane mode, do not write the file here.** Hold the entry and write it once
after the batch merges — see **Multi-lane mode** above for why.
On first use, create the file with this structure:
```markdown
# Memory — operational lessons
> Curated, forward-acting lessons that should survive across runs so the blind
> executor and reviewer don't re-learn them. **Project-wide** — lives at the
> `.spec/` root and is shared across every ticket. NOT a changelog. Prune superseded
> entries. Code-style rules belong in `constitution.md`; one-off history in the
> ticket's `decisions.md`.
<!-- Each entry:
- type: environment | pitfall | tried-rejected | hotspot
lesson: <one line — the operational fact>
apply: <one line — why it matters / what to do about it>
-->
- type: environment
lesson: <e.g. the test suite needs the package installed (`pip install -e .`) first>
apply: <run the documented setup before the gate; a bare `pytest` gives a false red>
```
### 7. NEXT
Report the task outcome (built, reviewed, committed). Then:
- **Interactive default:** continue to the next unchecked task. Pausing for the
user between tasks is fine and expected.
- If the user asked to **run it through** ("do it all", "build everything"), or
`tiny-spec-run` invoked you, keep looping until one of the five terminal states
below is reached — committing per passed task as you go.
**The task list is the budget.** The loop ends when the tasks end. There is no turn
ceiling, no token cap, and no max-tasks knob to set — adding one would put a second,
weaker stopping rule next to the real one.
There is **no** separate autonomous mode and **no** checkpoint config: one commit
per passed task, always, on the current branch. (If the user wants a feature
branch, create it once up front — that's their call, not a knob here.) Running
through is a caller's instruction, not a mode — every step above is identical either
way, which is why a loop and a hand-driven build can't drift apart.
## Halting
Every build run ends in exactly **one** of five terminal states. Name it out loud in
the closing report — **per lane**, when there is more than one; a lane's halt never
rounds up into another lane's `done`, and the run's own state is the worst of them:
| State | Cause |
|---|---|
| `done` | every task `[x]` and the final smoke passed |
| `blocked` | the executor reported a `BLOCKER`, or the reviewer found a missing `visual:` command |
| `exhausted` | convergence (step 4) spent its 2 fix attempts and the task is still red |
| `paused` | a task's `pause:` point was reached |
| `fork` | a genuine either/or the plan doesn't pin down |
**Never round up.** Only `done` may report the work as built. A run that ended at
`blocked`, `exhausted`, `paused`, or `fork` has unbuilt work in it, and a closing
report that calls it done is worse than the halt itself — it converts a stop the user
could act on into a false completion they won't check. Say the state, then say what's
left and which command resolves it.
**`blocked` and `exhausted`** log **one** entry with `type: blocker` — an upstream doc
must change; see **Blockers** below. **`fork`** logs `type: halt`:
```
## D-NNN — <short title>
- type: halt
- state: fork
- date: <ISO date>
- affects: REQ-N, T<n>
- note: <the options, your recommendation, and what the user has to decide>
```
Write one entry per halt, not one per cause — a blocker that halts the loop is a single
event and reads as two records if you log it twice.
**`done` and `paused` log nothing.** A green tree with every box ticked already says
`done`. And a `paused` run stopped at a task whose `pause:` line is sitting right there
in `PLAN.md` saying why — an entry would restate what the file already tells you, and
the log is for things that aren't otherwise visible. Report it, don't record it.
The task the loop stopped at stays `[ ]`. That is the whole resume mechanism:
re-running `tiny-spec-build` (or `tiny-spec-run`) picks up at exactly that task with no
conversational context needed.
## Blockers (never hack around)
When the executor reports `BLOCKER`, when the reviewer reports a **missing `visual:`
command** for a `design:` task, or when convergence (step 4) exhausts its attempts:
1. Leave the task `[ ]`.
2. Log it to `.spec/<active>/decisions.md` using the fixed skeleton (`type: blocker`,
the terminal state in `state:`, the upstream doc to fix named in `note:`, and the
affected `REQ-N`/`T<n>` in `affects:`). Create the file if absent:
```
## D-NNN — <short title>
- type: blocker
- state: blocked
- date: <ISO date>
- affects: REQ-N, T<n>
- note: <what stopped you; which upstream doc must change>
```
`state:` is `blocked` for an executor blocker or a missing `visual:`, and
`exhausted` when convergence ran out. Both route upstream the same way; the
distinction is what the user reads to know whether the task was impossible or just
never converged.
3. **Surface and route upstream:** `tiny-spec-plan` (a design gap, a missing `visual:`
command, or a token the Design system doesn't define) or `tiny-spec-create` (a
requirement is wrong/impossible), in update mode. Never resolve a missing `visual:`
command by dropping the task's `design:` field — that turns a gap in the gate into
a task that quietly claims a check it never got. After the upstream fix and a
`tiny-spec-plan` reconcile, re-run `tiny-spec-build` — it resumes from the checkbox state.
A genuine fork the plan doesn't pin down → don't guess: present the options + your
recommendation, get the user's call, record it in `decisions.md`, then continue.
**When running through, a fork is a halt** (`type: halt`, `state: fork`) — record the
options and your recommendation in the `note:` and stop, rather than blocking on an
answer nobody may be present to give. Ask only when the user is in the conversation
with you; a question asked into an empty room stalls the run without ending it, and
the halt is what makes the same fork readable hours later.
## Completion
When every task in `## Tasks` is `[x]`:
1. **Final smoke** — run the constitution's **Verification commands** once more
against the whole project, exercised the way a user would (after the documented
setup — install/build, not a test-runner shortcut). There is no separate
verify skill — this final smoke confirms the requirements actually work end-to-end, not
just that tasks are ticked.
**This is the authoritative clean-state run for the whole build**, and it is never
scoped or skipped. Per-task gates run at the scope step 3 named; this one always runs
everything from clean. A build or install regression that a `scoped` task gate did not
re-prove surfaces here — which is why a red final smoke is a gap to fix or route
upstream, never a pass and never `done`.
2. **Report** — the terminal state by name (`done` here, since every task is `[x]`
and the smoke passed), what was built, the commits made (with the branch), and any
open `decisions.md` items (blockers, tasks unchecked by a reconcile). If the final
smoke reveals a gap, it's a bug to fix now (new/edited task) or a blocker to
route upstream — **not** a pass, and not `done`.
3. **Close the ticket (reference-only).** If a ticket is bound, this is where the
**closing** keyword belongs — on the final / PR commit, not the per-task ones:
GitHub `Closes #123`, ADO `Fixes AB#123`. Jira/Monday have no closing keyword,
so move the ticket's status manually (active auto-transitions are an opt-in layer
— see [INTEGRATIONS.md](INTEGRATIONS.md)). The suite makes **no API calls**.