Tickets as contracts¶
A ticket is the whole specification the loop works from. Its body is frozen at claim, held against at merge, and read by a reviewer who can witness only what is in the candidate tree. Writing one well is most of the operator's job.
The shape¶
ticketTemplate.md is the canonical structure and ticket_template.py
validates it. A ticket opens with a header of typed fields in TOML between
two +++ lines, before its H1:
+++
ui_change = "major"
estimate = 45
depends_on = ["KO-7"]
[mockup]
url = "https://claude.ai/artifact/ID"
approved = 2026-10-19
note = "the two-column settings page"
[[mockup.boards]]
url = "https://media.example/settings.png"
caption = "settings, desktop"
[[evidence]]
file = "01-settings.png"
state = "the settings page, saved"
+++
# Add a settings page
The fields, each optional but estimate:
- ui_change: "none", "minor" (copy, a button style, spacing) or
"major" (a new or reworked layout).
- blast_radius: "high" or "medium", when the change is riskier than its
diff shows; it can raise a review round's tier and never lower it.
- estimate: whole minutes from 1 to 90.
- depends_on: a list of ticket keys, like ["KO-7"].
- orchestration: "off", "subagents" or "workflow", overriding the
project's [agents.implementer] orchestration for this ticket.
- implementer: a profile name of letters, digits, _ and -, run on the
project's [agents.implementer_profiles.NAME].
- [mockup]: url, the approved mock-up (a https://claude.ai/artifact/ID,
https://claude.ai/code/artifact/ID or https://lotuspod.DOMAIN/NAME.html
page), approved, a date like 2026-10-19, and note, what was approved.
Each [[mockup.boards]] entry has a url, a .png snapshot of a board or
a .zip bundle of the boards' .dc.html sources, canvas.json and PNGs,
under the project's [merge.media_bucket] public_base, and an optional
caption of one line without brackets; the run downloads them into the
task worktree's ignored .holophyte/mockup/ and lists them in the
implementer's goal.
- [[evidence]]: at most six entries in capture order, each a file,
NN-slug.png for a state or NN-slug.webm for a recorded flow, numbered
from 01, and a state, the page, state and interaction it shows.
A field that fails its check is a problem naming it, as in header estimate
must be whole minutes from 1 to 90, not 120.
Sections, in order: an H1 title; Summary; What / Why / How (the bold keys
**What:**, **Why:**, **How:**, plain What: also accepted); optional
Reproduce (a bug's steps and where it was seen; its presence makes the first
turn a reproduce turn that commits a failing test before any fix); In scope
(at most six entries); Out of scope; Acceptance criteria (at most ten, each
Given … when … then …); Verify command(s) (a fenced block of
relative-path, non-interactive commands; exit 0 is pass); optional Contract
checks (relative/path: exact literal); Implementation notes; optional
Story; Open questions (exactly - None to be claimable).
During the transition a body without a header still files in the magic-line
form: a **UI change:** major or minor and a **Blast radius:** line under
What / Why / How, a ## Mock-up section (one bare mock-up link, one
Approved YYYY-MM-DD: what line, then its media links, each bare,
 or [caption](URL)), a ## Evidence section (one state per
line) and a ## Estimate & dependencies section (Estimate: N min · Depends
on: KO-n or none). A ticket uses one form or the other: any of those lines
or sections beside a header is refused, as in '## Mock-up' beside a header:
give [mockup] in the header only.
Validate before filing, against the project repository:
python3 ticket_template.py TICKET.md --repo /path/to/project
Blockers make the ticket INVALID and the loop skips it as needs_spec.
Advisories print and let it through.
With --repo, the checker also runs the landmark half of the claim's
freshness check against that repository's main: every path the criteria
and Implementation notes name must be on main, and every function or class
an Implementation notes item names must be in one of the files on main
that item names. A path
is exempt when "new" governs it: "new", at most three words with no
preposition among them, an optional comma, colon or opening parenthesis,
then the path or a list of paths joined by commas, and or or. "a new test
file tests/x.py" and "new modules a.py and b.py" declare; "a new test
in tests/x.py" does not. A repository with no main commit skips the
check.
What the validator refuses, and why¶
| Refusal | Why it exists |
|---|---|
Unfilled placeholder: any <…> or {{…}} outside a markdown link |
KO-165 was claimed with template placeholders in its title and criteria and merged anyway. HTML tags count; write "the main element", not <main>. HTML comments in a draft's "Open questions" section are stripped before validation, so the template's guidance comment there is not a placeholder; a comment in any other section is refused like any other tag. |
| More than six in-scope items, ten criteria, or 90 minutes | KO-110 was a 180-minute blob. Small tickets converge; big ones burn rounds. The caps were raised from three, five and 30 after the 2026-09-29 measurement: across 74 merged tickets, implementation was about a tenth of claim-to-merge time, and the rest is a fixed cost paid once per ticket. Small tickets remain the norm for fixes. |
| A non-relative path in a verify command | KO-111 cd'd to an absolute path and verified the wrong tree. |
| A path a criterion names that the project repository gitignores | KO-166 named a rendered file under a gitignored artifacts/; the reviewer's export cannot contain it and the implementer force-tracked it. |
A depends_on or Depends on that is not a ticket id (or none) |
dependencies are machine-checked through Linear blocks relations |
Open questions not exactly - None |
an open question is not a frozen contract |
**UI change:** other than major or minor, or a ## Mock-up without exactly one accepted bare URL and one Approved YYYY-MM-DD: what line |
a markdown link keeps only its text, and an unnamed approval is no agreement |
ui_change = "major" (or **UI change:** major) with no [mockup] or ## Mock-up, on a project whose [merge] sets ui_paths (--file-ticket and the claim) |
a layout agreed before the run is cheaper than one corrected after the Evidence screenshots |
A mock-up board or media link outside [merge.media_bucket] public_base, or on a project without a bucket; or a major UI change whose mock-up has no snapshot or bundle, on a project that sets ui_paths and a bucket (--file-ticket and --repo, not the claim: a ticket filed earlier still claims and its run records a mockup warning) |
no agent can open a claude.ai artifact; the implementer matches the design only from files the factory downloads |
header has no closing '+++' line, or header is not valid TOML: … |
a header that does not parse sets no field, so its ticket would run without an estimate or a mock-up |
header field "X" is not a ticket field, at the top level or inside [mockup], [[mockup.boards]] or [[evidence]] |
a misspelled field would be dropped silently |
header estimate is missing |
every ticket is budgeted |
A header field's value out of its range, as in header ui_change must be … or header evidence[1].file must read 01-slug.png … |
a typed field holds only what the loop can act on |
A **UI change:** or **Blast radius:** line, or a ## Mock-up, ## Evidence or ## Estimate & dependencies section, beside a header |
two places for one field drift apart |
A named path main lacks, or a named symbol not in the file it is paired with, in a body with Depends on: none (--repo and --file-ticket; with a dependency each reason is an advisory, or a warning: line at filing, and the claim re-checks it) |
the claim parks such a ticket as stale; LOTUS-95, LOTUS-96, LOTUS-97 and HOLO-182 passed filing and were parked at claim |
Advisories: a What: that chains two deliverables; a bare python3 in a
verify command on a project with a venv; a criterion that reads as an
operator or post-merge witness; three or more evidence states with no UI
change declared and no mock-up (declare major and link the mock-up, or
declare minor).
What the reviewer can witness¶
The reviewer sees a clean export of the candidate commit, read-only, and
nothing else: not main after the merge, not the host it runs on, not a
screen, not a store. Every criterion must be witnessable from that tree,
and the reviewer must name the witness:
CRITERION 1: met — tests/test_serve.py::StatusTests::test_lists_live_runs
CRITERION 2: not met — production requests /runs, not /runs?limit=1
CRITERION 3: unwitnessed — no test exercises the 404 path
A named test that does not exist in the tree is unwitnessed. Any criterion
not met or unwitnessed makes the round changes_requested whatever the
verdict line says. So:
- Write criteria as "a test witnesses this", and mean it. The full list of
rules the reviewer enforces sits in the comment above the acceptance
criteria in
ticketTemplate.md, where the author reads it before drafting. - Keep visual passes, re-renders of gitignored output, and "on the writer host" checks out of the criteria; they are operator steps, recorded in the ledger after the merge.
- Verify commands must be true of the candidate, not of a fixture you
imagined: a negated grep that also matches a legitimate line (
---) and a plural that a one-item fixture cannot produce both failed real runs.
Filing and editing¶
python3 factory.py PROJECT --file-ticket TICKET.md --priority high [--state Todo|Backlog]
python3 factory.py PROJECT --file-ticket TICKET.md --update KO-n
Both validate the file against the project, act on Linear, read the stored
body back and validate that again, so a transfer that rewrites bold or
autolinks an example identifier is caught at filing time rather than at
claim time. Exit 1 means nothing was changed; exit 2 means the issue exists
but its stored body needs a fix. Filing adds a blocks relation per
Depends on id; --update adds the blockers the file names that the
board does not hold yet, removes none, and prints what the board still
holds beyond the file (board also holds KO-m) for you to clear by hand.
Todo and In Progress are claimable;
Backlog and Done are not. [loop] order = "priority" makes an Urgent or
High ticket run first.
On a native board ([board] kind = "native") the column is yours to move,
at the revision you read the ticket at:
python3 factory.py PROJECT --move KEY-n ready|backlog --revision N [--note TEXT]
python3 factory.py PROJECT --cancel KEY-n --revision N --note TEXT
A move takes an idle or live ticket between Ready and Backlog; a live run
continues and the ticket is not claimed again. A draft is refused Ready.
A cancel records an abort on a live run, which ends abandoned at its
next safe point, and the printed line names the run. A ticket that moved
past N is left as it is, its current revision printed, exit 1. Nothing
moves a ticket out of Canceled. On a Linear project both are usage errors:
move its tickets in Linear.
A ticket the claim parked needs_spec keeps its column. A park for stale
landmarks or by the critic is re-readied by --file-ticket --update with a
clean body, or by --move KEY-n ready, which re-checks the stored body
against main but does not ask the critic again: with reasons remaining it
lists them and changes nothing, and with none it re-derives the ticket and
records a requeue intervention. A move to Ready of a ticket already in
Ready names its status, as in KEY-n is already in ready at status ready;
nothing changed: a ticket at ready is already claimable.
The ledger¶
Every run leaves a comment on its ticket: the rounds, their findings, the
adjudications (ADDRESS, FOLLOW_UP, DECLINE), and any operator step
taken after the merge with its time. A fix commit writes each FOLLOW_UP
as one unwrapped line, FOLLOW_UP(feature): TEXT @ PATH:LINE or
FOLLOW_UP(guardrail): TEXT @ PATH:LINE, the @ PATH:LINE tail optional;
when the run merges, a feature is filed as a Draft follow-up: ticket in
Backlog, unless an open draft already has its fingerprint, and a guardrail
is kept as a findings-ledger row in the store's followUps table. The store holds the rows;
FINDINGS.md, in a project that opts in, renders the window; the ledger is
the narrative. A contract
revision is recorded there too, with what was wrong and why, so a rerun's
reviewer can read the history.
Tickets the loop must not take¶
Tracking and design tickets have no verify command by design and stay in Backlog. A parent stays Backlog while its leaves run. A ticket whose contract needs a human decision is Backlog until the decision is in the body.