@majidbabaeifar/software-factory
A one-owner software factory. Intents in, a coding agent writes the spec, plan and code PRs, then reviews, merges, deploys and checks each change live.
Software factory
One person keeps a list of things they want built, one Markdown file per piece of work (an intent), in a git repo. The factory picks the most important intent it may work on and has a coding agent turn it into three reviewed pull requests on GitHub: first the spec change, then a plan, then the code. It runs each change's checks, reviews it, merges it when the owner's rules allow, waits for the deploy, checks the live app and rolls back if it broke. The owner steps in only where they chose to: a question to answer, a PR to approve, a title to pick.
- One owner, one machine. The factory runs as a long-lived service for one person. It works on that person's GitHub repos through its own GitHub App identity, and keeps its state in a local database it can rebuild after a crash.
- Specs are the truth. Every behaviour change starts as a spec change in the target repo, so each
repo's
specs/folder always says what the code does. - Autonomy is per intent. Each intent carries a level that decides where the factory stops and waits for the owner, and what a worker may touch.
- A local web page shows the board, every running stage, what waits for the owner, and cost.
| Area | Prefix | Covers |
|---|---|---|
| intents.md | INT | Intent files, IDs, the intents repo, lifecycle fields, generated views |
| priority.md | PRI | Scores, order, cutoff, dropping |
| autonomy.md | AUT | Where an intent waits for the owner, and what access a worker gets |
| loop.md | LOOP | The service: state machine, crash recovery, limits, commands, messages, updates |
| pipeline.md | PIP | Spec, plan and code PRs; build, verify, proof, CI, review, gate, merge; GitHub interactions; writing |
| ship.md | SHIP | After the merge: wait for the deploy, test the live app, roll back and fix forward |
| repo.md | REPO | What the factory reads from a target repo: CLAUDE.md, REVIEW.md, TOWERCAB.md; protected paths |
| ui.md | UI | The local web page for the owner |
| metrics.md | MET | Cost and time per intent, spend per day |
Written by the author from the specs of their own factory, Towercab, which runs this design every day. The reference build uses Claude Code as the coding agent; any agent that can run headless with a prompt, limited tools and a cost report fits.
Autonomy
Status: Draft · Prefix: AUT
Purpose
How far the factory may take an intent before the owner decides, and what a worker may reach without asking. The owner sets a (1–5) per intent and a repo can cap it; validation never shrinks with trust, so autonomy only decides who merges and how much access the worker gets.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| AUT-3 | At a1 to a3 the owner merges every code PR; the factory prepares it and waits. The gate (PIP-7) sends the mission to approval:merge with the waiting reason review and merge PR. The review still runs and posts its findings on the PR; a review that doesn't approve first gets its fix rounds (PIP-98). |
given an intent with a: 2, CI green, review approved and low risk, when the gate runs, then the mission is at approval:merge waiting review and merge PR and the PR is not merged |
| AUT-4 | At a4 the factory merges the code PR itself, but only when CI is green, the review approved it, the build needed no access beyond the approved plan and its risk is low. The gate goes to merge only when all four hold: CI green, the review's verdict approved, no unplanned access (AUT-15) and a low risk level (AUT-18); otherwise it goes to approval:merge with the reason that failed. |
given a: 4, CI green, review approved, no unplanned access, when the code risk is low then the PR merges; when it is medium then the mission waits at approval:merge with the medium-risk reason |
| AUT-6 | Every intent runs the same checks whatever its autonomy: verify, the red→green proof, CI, the review and the gate. The stage order (LOOP-3) has no stage chosen by a, and the gate merges only with CI green and the review approved. |
given two intents, one a: 1 and one a: 5, when both run to the gate, then each passed verify, proof, CI and review in the same order and none was skipped |
| AUT-7 | A worker that finds the change riskier than the intent says may lower a, never raise it. The build result (PIP-1) may carry lower_a (integer) and lower_why (text). When lower_a is below the current a (no a counts as 5, AUT-9), the factory writes lower_a as the intent's a and adds lower_why; any other value writes nothing. |
given an intent with a: 4, when the build returns lower_a: 2, lower_why: "touches billing", then the intent reads a: 2 with that reason; when it returns lower_a: 5, then the intent is unchanged |
| AUT-8 | A repo can cap the autonomy of every intent in it. max_autonomy (1–5, default 5) in the base branch's TOWERCAB.md (REPO-1). Effective a = the minimum of a when the intent was picked, a now, and the cap; with no a anywhere it is 5 (AUT-9), then the cap applies. |
given a 4 at pick, 5 now and max_autonomy: 3, when the effective autonomy is computed, then it is 3 |
| AUT-9 | An intent without a counts as a5: full autonomy, still limited by the repo cap and, for intents the owner didn't write, by AUT-13. Order: no a at pick and none now → 5; then the minimum with the repo cap (AUT-8); then at most 4 when source isn't me. An a present at pick or now still counts, so removing a mid-flight can't loosen a gate. |
given no a, no cap and source: me, then effective a is 5; with source: issue it is 4; given a: 3 at pick and removed later, then it stays 3 |
| AUT-11 | The owner approves the spec PR at a1–a2, when the second reviewer leaves open points for the owner, or when its risk is too high for the intent's autonomy; otherwise the factory merges it once its checks pass. The owner merges the spec PR (approval:spec) when effective a ≤ 2, when there is any open point (PIP-97), or when the risk waits by AUT-18 (a3 counts like a4 here). The why is The spec leaves <n> questions for you to settle before it is built. (one question for one) or the risk's reason (PIP-110). Otherwise the factory merges it after its CI is green or when it has no CI (PIP-50). The design's own concerns don't stop the spec on their own: the second reviewer reads them and keeps only those the owner must decide, as open points. |
given a: 4, low spec risk and one open point, when the spec gate runs, then it waits at approval:spec with The spec leaves one question for you to settle before it is built.; with no open point, then the spec PR merges once CI is green |
| AUT-12 | The owner approves the plan PR at a1–a3; at a4 and a5 the factory merges it itself unless its risk is too high for the intent's autonomy. Effective a ≤ 3, or a risk that waits by AUT-18, sends the plan PR to the owner (approval:plan; at a4 and a5 the why is the risk's reason). Merging the plan grants the access ticked in it (AUT-14); the access rules of AUT-14 and AUT-15 apply the same whether the factory or the owner merged the plan. |
given a: 3 and low plan risk, then the plan waits at approval:plan; given a: 4 and medium plan risk, then it waits with the medium-risk reason; given a: 4 and low risk, then the factory merges it |
| AUT-13 | Full autonomy applies only to intents the owner wrote; an intent from anywhere else counts as a4 at most. Effective a can be 5 only when the intent's source is me or missing (the owner's own file); any other source caps it at 4. |
given a: 5 and source: issue, when the effective autonomy is computed, then it is 4 |
| AUT-14 | What a worker may reach without asking depends on the effective autonomy. a1–a3 (owner merges): network = the default list + plan grants + mid-build requests; protected paths (REPO-8) writable and listed in the PR; a mid-build request is granted and noted in the PR. a4: network = default list + plan grants; only the protected paths the plan grants; a mid-build request is granted and the PR goes to the owner (AUT-15). a5: network open; protected paths writable; no requests needed. The default list is the factory setting [agent].network, default registry.npmjs.org, *.npmjs.org, pypi.org, files.pythonhosted.org, crates.io, *.crates.io, proxy.golang.org, sum.golang.org, github.com, *.github.com, *.githubusercontent.com, docs.rs, nodejs.org, developer.mozilla.org. Plan grants = the ticked boxes in the merged plan's Access section (PIP-47) plus the intent's own unlock and network fields. |
given a: 4 and a plan granting api.example.com, when the worker fetches api.example.com then it succeeds and other.example.com is blocked; given a: 5, then both succeed |
| AUT-15 | When a worker needs access the plan didn't grant, the factory grants it and keeps building; at a4 the code PR then waits for the owner instead of merging. The worker returns status: access with the domains or paths and why (PIP-1). The factory adds them to the mission's grants, records them on the mission as unplanned (what and why) and resumes the worker. The code PR lists them under "Access beyond the plan". At a4 the gate goes to approval:merge with why The agent needed access the approved plan didn't grant.; at a1–a3 the request is granted and only noted. |
given a: 4 and a worker asking for cdn.example.com mid-build, when the build finishes green with low risk, then the PR lists it under "Access beyond the plan" and waits at approval:merge with that why |
| AUT-16 | Every spec, plan and code change is judged on three questions, and the answers give it a risk level of low, medium or high. Blast radius (who or what is affected if it goes wrong): small, medium, large. Door: one-way when it is hard to undo (data, migrations, public interfaces, deletions), else two-way. Certainty (how sure it behaves as expected after deployment, from what is tested and what depends on the environment): high, medium, low. Each answer carries a plain sentence. The risk agent answers them at the spec, plan and code gates (PIP-96) and proposes the level; the factory then applies the floor (AUT-17). Kept per gate on the mission as risk.spec, risk.plan, risk.code (see Data model). |
given a code change, when the risk agent runs, then risk.code holds a level and the three answers, each with a sentence |
| AUT-17 | The answers set a lowest level that the agent's own judgment can raise but never lower. Each of a one-way door, a large blast radius and low certainty makes the level at least medium; two or more make it high. At the code gate the review's verdict (PIP-5) counts too: its door: one-way is a one-way door, and a touched red line of REVIEW.md (REPO-4) makes the level high. The kept level is the higher of the agent's and the floor; when the floor won, raised says why, e.g. Raised from low to medium: it is hard to undo. A plan that asks for access beyond the defaults (any protected path, or any domain not in [agent].network) is at least medium; this raises the level without counting toward the two-or-more rule. |
given the agent says low with a one-way door, then the kept level is medium with raised Raised from low to medium: it is hard to undo.; given a one-way door and low certainty, then high; given a plan asking for a protected path, then at least medium |
| AUT-18 | At a4 only a low-risk change goes ahead without the owner; at a5 low and medium go ahead and only high waits; this holds at the spec, plan and merge gates alike. Who decides, per gate (spec; plan; merge): a1–a2 owner; owner; ownera3 owner on medium and high; owner; ownera4 owner on medium and high at all threea5 owner on high at all three. The wait's why: It is medium risk, and at autonomy a4 only low-risk changes go ahead without you. or It is high risk, and at autonomy a5 low- and medium-risk changes go ahead without you. At the merge gate CI, the review, the proof and the access rules (AUT-14, AUT-15) still hold first (PIP-7). |
given a: 5, when the code risk is medium then the PR merges; when it is high then it waits at approval:merge with the a5 high-risk why |
| AUT-19 | When the risk check can't give an answer, the owner decides. A risk run that fails after its one continue (PIP-1) is kept as null for what it was to assess; the gate then waits for the owner at every autonomy level, why The risk check didn't finish, so you decide whether it goes ahead. An assessment of an older spec head, plan or diff counts as none. |
given a: 5 and a risk run that fails twice, when the plan gate runs, then it waits at approval:plan with that why |
| AUT-20 | The owner sees the level and the three answers, in plain words, in each PR the factory opens and on the web page. Plain words: the label (Low risk, Medium risk, High risk) and one line per answer: Affects few|some|many: <who>; Easy to undo: <why> or Hard to undo: <why>; Sure|Fairly sure|Not very sure it works once live: <why>. PR bodies get a ## Risk: <level> section (PIP-99). The needs-you card shows a risk chip and the three lines for the gate it waits at (spec card → the spec's risk, plan → the plan's, code → the code's; other cards the latest), and a spec card lists the open points; the intent page shows the latest risk when no card is shown. Only an assessment of the current change shows (its kept key equals the current change's key, PIP-96), so a revised spec, plan or diff never shows an older risk or older open points. |
given a medium code risk with a small blast radius, when the code PR opens, then its body has ## Risk: medium and a line Affects few: …; given the diff changes after that, then the card shows no risk until a new run finishes |
| AUT-21 | When a gate waits only because its risk check didn't finish, the owner's retry runs that check again in the same attempt instead of starting over. At approval:spec, approval:plan or approval:merge whose current assessment is null (AUT-19), a /retry on the waiting PR, Retry on its card (which posts /retry) or towercab retry gives the event recheck instead of retry_fresh. It clears risk.<gate> and riskOf.<gate> and moves back: spec → design, plan → plan (both marked recheck, so they run only the gate's checks and risk check, not a new design or plan), code → review (the review stands, the risk check runs). Any other retry starts a fresh attempt (LOOP-15). |
given a mission at approval:plan with a null plan risk, when the owner comments /retry, then the mission returns to plan in the same attempt, the plan is not rewritten and a new risk run starts |
Data model
| Entity | Field | Type | Required | Limits, default |
|---|---|---|---|---|
| Intent | a |
integer | no | 1–5; missing counts as 5 (AUT-9) |
| Intent | lower_why |
text | no | written only with a lowered a (AUT-7) |
| Intent | source |
text | no | me or missing = the owner's own; anything else caps at 4 |
| Intent | unlock, network |
list of globs / domains | no | plan grants (AUT-14) |
| Build result | lower_a, lower_why |
integer, text | no | applied only when below the current a |
| Mission | unplanned |
list of {what, why} | no | access granted mid-build (AUT-15) |
| Mission | risk.<spec|plan|code> |
risk assessment or null |
no | null = the check didn't finish |
| Mission | riskOf.<spec|plan|code> |
text | no | key of the change that assessment judged (PIP-96) |
| Risk assessment | level |
low|medium|high |
yes | the higher of said and the floor |
| Risk assessment | said |
low|medium|high |
yes | the agent's own level |
| Risk assessment | raised |
text | no | set only when the floor won |
| Risk assessment | blast |
{answer small|medium|large, sentence} |
yes | |
| Risk assessment | door |
{answer one-way|two-way, sentence} |
yes | |
| Risk assessment | certainty |
{answer high|medium|low, sentence} |
yes | |
| Risk assessment | summary |
text | yes | |
| Risk assessment | openPoints |
list of {point, why} | yes | empty at the plan and code gates (PIP-97) |
| Risk assessment | of |
text | yes | the key it judged |
Repo TOWERCAB.md |
max_autonomy |
integer | no | 1–5, default 5 |
Out of scope
- Raising autonomy automatically.
- Repo caps after a revert.
- Tracking how often the owner overrides the risk gate.
Decisions
- Urgency and importance set the order; autonomy only moves gates and access.
- Access follows who merges: when the owner reads every diff before it lands (a1–a3), asking for access mid-build only costs time. The locks matter when the factory merges on its own.
- Access is asked for in the plan, so the wait happens before the build, while the owner is reviewing anyway, not in the middle of it.
- a5 is limited to
source: mebecause open network, editable CI and auto-merge together would let a planted instruction in an issue change CI and merge itself. - The gate and access decisions are pure functions of their inputs; the pipeline does the I/O around them.
- The effective autonomy takes the minimum of every
apresent, so removingamid-flight can't loosen a gate. - Risk instead of concerns: any design concern used to stop the spec, so most specs waited. Now an independent agent judges the risk at every gate, and the level, not the existence of a concern, decides at a4 and a5. No
ameans a5 for the same reason: the risk gate protects a change, not the missing number. - The risk agent is never the run that wrote the change: a session of its own on the review model, read-only, like the review. One run at the spec does both jobs, the risk and the second review, since both read the same spec and concerns.
- The level has a floor computed outside the agent, so a one-way door or a red line can't be talked down to low. A red line is the repo owner's own list of what must not change unseen, so touching one is high, which waits even at a5.
- a3 counts like a4 at the spec gate: a lower
amust never be looser than a higher one. - The default network list names package registries and documentation hosts of common ecosystems; it is a setting, not a stack choice.
Intents
Status: Draft · Prefix: INT
Purpose
An intent is the owner's description of a change: the problem, the outcome wanted, and how urgent and important it is. Intents live as Markdown files in a separate intents repo on GitHub, so the owner can write them from any device; the factory picks them up from there and writes back only a few lifecycle fields, two generated overview files and a feature list per repo.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| INT-1 | Each intent is a folder in the intents repo, and finished ones move to done/. Layout: intents/<id>-<slug>/intent.md (the owner's file) and plan.md (arrives through the plan PR, PIP-46); when stage becomes done or dropped the folder is moved with git to done/<id>-<slug>/. TODO.md and DONE.md at the root are generated (INT-6) and never edited by hand. A folder whose name isn't <id>-<slug> (INT-3) or that has no intent.md is listed as unreadable (INT-11). Plain files directly in intents/ and done/ are ignored, except done/legacy.md. Symlinks are never followed: a symlink in place of intents/, done/, an intent folder or an intent.md is unreadable with reason symlink, and no read or write goes through one. The factory's checkout is config [intents].path (default <home>/intents, <home> the factory's data directory), cloned from [intents].remote when missing. |
given intents/261015-1446-add-login/intent.md with stage: done written → then the folder is at done/261015-1446-add-login/; given intents/x is a symlink → then it is listed unreadable with symlink and its target is never read |
| INT-2 | An intent file is YAML front matter followed by a Markdown body with a title and a few sections. Front matter: the lines between a first line --- and the next --- line, parsed as YAML. Fields the owner writes: repo (required; [A-Za-z0-9._-]+, not starting with -, not . or ..); u, i (integers 1–5) and a (integer 1–5, optional); after (a list of IDs, or one ID); model; review (a model name); source (me or another origin); started (RFC 3339); unlock (list of path globs) and network (list of domains), access granted up front (AUT-14). Any other key is kept verbatim and never rewritten. A known key with the wrong type, a duplicate key, or any other line at column 0 makes the file unreadable (INT-11). Body: everything after the closing ---, kept as raw text. The first # line is the title (# Intent: <title>, the Intent: prefix optional). Sections: ## Problem, ## Proposed outcome (bullets; the review checks each one, PIP-5), ## Affected users and systems, ## Constraints, ## Open questions, and the factory's ## Towercab, ## Answers (INT-15) and ## Original title (INT-34). Only repo and the title are required. towercab add "<repo>: <title>" [uN] [iN] [aN] writes a thin intent: repo, the scores given, source: me, started (now, with the local offset), a blank line and # Intent: <title>, and prints <id> intents/<id>-<slug>/intent.md. |
given towercab add "shop: Buyers can pay by card" u4 i3 at 2026-10-15 14:46 → then it prints 261015-1446 intents/261015-1446-buyers-can-pay-by-card/intent.md and the file has repo: shop, u: 4, i: 3, source: me, started, then # Intent: Buyers can pay by card; given front matter u: high → then the file is unreadable with u must be an integer 1-5 |
| INT-3 | An intent's ID is its creation time to the minute, and its slug comes from the title. ID = YYMMDD-HHMM in local time; a clash with any folder in intents/ or done/, or with an ID in done/legacy.md, appends -2, -3 and so on; IDs are never reused. Slug: lowercase ASCII; ' and ’ dropped; every run of other characters outside [a-z0-9] becomes one -; trimmed of -; cut to 48 characters and trimmed of - again; empty → intent; a leading digit gets an n prefix. The slug is fixed at creation. Commands accept any unique suffix of an open intent's ID: an exact match first, else the one ID ending with it; none → no open intent matches <s>; several → <s> matches <n> intents: <ids>. |
given 261015-1446 already in done/ and a new intent at 2026-10-15 14:46 → then its ID is 261015-1446-2; given title Don't break 3D view! → then the slug is dont-break-3d-view; given title 3D view → then slug n3d-view; given suffix 1446 matching two open IDs → then 1446 matches 2 intents: … |
| INT-4 | An intent is accepted when it is on the main branch of the intents repo. The owner's commits to main are accepted as they are. Anything a machine writes for the owner to accept (a new intent, a plan) arrives as a pull request, and merging it accepts it. The store reads only its checkout's main working tree and refuses a checkout on another branch with intents: <path> is on <branch>, not main. |
given the checkout is on branch draft → when the store reads → then it fails with intents: <path> is on draft, not main |
| INT-5 | The factory records where an intent is in a few front-matter fields, so the owner can see it from the file alone. stage: absent = accepted, not started; then spec, plan, code, done or dropped (the phase of its mission, LOOP-3). waiting: <what the owner must do> while it waits on the owner. spec_pr, plan_pr and pr (the code PR): their URLs once opened. At done: cost (USD, 2 decimals), agent_time (seconds) and attempts; with done or dropped: finished (RFC 3339). A worker may lower a and add lower_why (AUT-7). Lifecycle fields are written only by a set-field and a remove-field operation: a changed key's lines are replaced by one key: value line in place, a new key goes last before the closing ---, and every other line stays byte-identical. Strings are written plain when they read back unchanged, else double-quoted; lists in flow style [a, b]. retry: fresh, written by the owner, starts a new attempt (LOOP-18). |
given a file with a comment line # keep in its front matter → when stage: plan is set → then only a stage: plan line is added before the closing --- and every other byte is unchanged |
| INT-6 | TODO.md and DONE.md give the owner a one-page view of the repo, regenerated on every write. TODO.md sections, in order: Waiting on me (with what is asked), In flight (with the stage), Next (priority order, PRI-4), Parked (below the cutoff), Can't read. DONE.md: newest first, one line each with date, ID, repo, title, PR, cost, agent time and attempts; then done/legacy.md verbatim under ## Before intents. Each line links the ID to its intent.md. Empty sections are left out; nothing at all → Nothing open. / Nothing done. Each file starts with a comment saying it is generated. |
given no open intents → then TODO.md is the generated comment and Nothing open.; given two done intents → then DONE.md lists the newer first, each ID linked to its intent.md |
| INT-9 | An /intent drafting command for the coding agent helps the owner write a good intent through a short conversation. It is installed into the coding agent's user-level commands by the factory's install step, with a name intent and a one-line description. The agent asks what an analyst would (scope, users, constraints, what success looks like), writes the template of INT-2 with every section, proposes u and i, leaves a out unless the owner gives it, and commits docs(intents): add <id> <title> to main. |
manual: run /intent in a new coding-agent session, answer its questions, and check a new folder with every INT-2 section lands on main |
| INT-10 | The factory pulls before every read and pushes every write, and never overwrites the owner's edits. Every read and write holds a lock file in the checkout's git directory, so only one runs at a time across processes. Read: fetch origin main and fast-forward; local commits a killed run left behind are rebased onto origin/main (a conflict only in TODO.md/DONE.md takes main's side; any other conflict drops those commits and logs their subjects). Write: read, apply the change, regenerate both views, commit towercab: <what> <id>, push HEAD to main. A rejected push replays the change: reset to origin/main, re-read, apply the same change again, push; at most 3 retries, then the error push failed 4 times: <stderr>. Nothing changed → no commit. Git never prompts for credentials, runs no hooks, and commits with the bot's name and email when the GitHub App identity is set (LOOP-19). |
given the owner pushes an edit to intent A while the factory writes stage on intent B → then both changes are on main; given a parse then write with no change → then the file is byte-identical and no commit is made; given pushes rejected 4 times → then push failed 4 times: … |
| INT-11 | A file that doesn't parse is never picked or rewritten; it is shown with the reason. It is listed under Can't read in TODO.md and in the UI as <path>: <reason> (for example intents/<dir>/intent.md: u must be an integer 1-5). Get and update on its ID fail with that reason; other writes leave its bytes alone. Two folders with one ID are both unreadable with reason duplicate id. |
given intents/261015-1446-a/ and intents/261015-1446-b/ → then both are listed as …: duplicate id and neither is picked |
| INT-12 | Everything the factory does with intents goes through one store. Operations: list (all intents, readable or not); get by ID or unique suffix; add a new intent; update by ID or suffix with a description <what> and a change (commits towercab: <what> <id>; a change that fails writes nothing); has-mark (finds a commit trailer with a given mark among the last 200 commits, so a step re-run after a crash doesn't write twice, LOOP-6). Errors come in two kinds: a sync error (clone, pull or push failed; the loop retries it) and a refusal (anything else); the message is the reason. |
given an update whose change fails → then no commit is made; given a commit with trailer mark issue:https://github.com/o/r/issues/5 → then has-mark for that mark is true |
| INT-15 | Answers the owner gives outside GitHub, and the factory's questions before any PR exists, are kept in the intent file so the history stays in the intents repo. ## Towercab (last but one section): the factory's current note or question to the owner, replaced whole on each write and removed when empty. ## Answers (last section): one - <local time>: <text> line per answer given in the UI or with towercab answer; every later worker prompt includes the section. |
given two answers → then ## Answers has two lines in order; given a new note → then ## Towercab holds only the new note |
| INT-20 | The factory reads an open issue only if the owner wrote it or labelled it towercab; every other issue is ignored unread. Watched: the open issues of every repo in config [repos]. Eligible: the issue's author login equals config [github].me, or the issue has the label towercab. Issues by anyone else without that label are never read: their title, body and labels reach no coding-agent run, no log and no file. Pull requests are not issues and are skipped. |
given an issue by stranger without labels → then its body appears in no run, log or file; given the same issue labelled towercab → then it is eligible |
| INT-21 | The loop looks for new eligible issues every 5 minutes. It lists the open issues of each repo through GitHub as the GitHub App, at most once per 5 minutes of the loop's clock, and also on start. A GitHub failure is a github fault like any other (LOOP-10): the poll is retried on the next pass and nothing is written for the issues it did not reach. One repo failing does not stop the others. |
given a poll at 10:00 → then the next is no earlier than 10:05; given repo A's listing fails → then repo B's issues are still read |
| INT-22 | A short coding-agent run with no tools reads the issue and proposes its scores, title and sections. Input: only the issue's title, body and labels, fenced and marked as data; no tools, no repo access, no other context; time limit 5 minutes. Output, checked against the score schema: u and i (1–5) each with one sentence of reasoning (u_why, i_why), a clear title, problem (the Problem section) and outcome (the Proposed outcome bullets), plus INT-32's fields. A result that fails the schema is retried once. If the retry also fails, the factory comments on the issue asking the owner to write the intent by hand, adds the label towercab:failed, and does not poll that issue again until the label is removed. |
given two results that fail the schema → then one comment asks for a hand-written intent, the label towercab:failed is added, and the next poll skips the issue |
| INT-23 | The factory turns a scored issue into an accepted intent in one store commit, with no pull request first. One store add writes repo, u, i, a (INT-24), source: issue, issue: <issue URL>, started, then the title, ## Problem and ## Proposed outcome. The two reasoning sentences go in the body under ## Towercab and are not shown as a question. The issue is the owner's request, so the intent is on main at once (INT-4 holds: the commit is pushed to main); lowering u/i or closing the issue (INT-28) is how the owner says no. A mark trailer issue:<url> (INT-12) keeps a step re-run after a crash from writing twice. |
given a scored issue → then exactly one commit on main adds the intent with source: issue; given a crash after that commit and before the comment → when the step re-runs → then no second intent is written |
| INT-24 | An issue intent starts at autonomy 3, and the owner changes that with an a1..a5 label on the issue. a = the issue's a1..a5 label if exactly one is present; several such labels → the lowest; none → config [intents].issue_a (default 3; 1–5). Because source is issue, the effective autonomy is at most 4 whatever the label says (AUT-13); a5 is kept for the owner's own intents. |
given no label → then a: 3; given labels a2 and a4 → then a: 2; given label a5 → then the effective autonomy is 4 |
| INT-25 | The factory comments once on the issue to say it is queued, and labels it so it is not read again. The comment gives the intent's link, u and i with their reasons, and its place in the queue (PRI-4), in plain language with no internal vocabulary. Then the label towercab:queued is added. One comment per issue: an issue that already has towercab:queued is not commented again, and a failed comment is retried without a second intent (INT-26). |
given a queued issue third in the queue → then one comment names its link, scores, reasons and place 3, and the label towercab:queued is set; given the next poll → then no second comment |
| INT-26 | Each issue becomes at most one intent, ever, even after that intent is done or dropped. Dedupe on the issue: field across open intents, done/ and dropped ones; a match skips the issue. Removing the label towercab:queued does not make a new intent. An unreadable intent file (INT-11) whose text holds the issue's URL still counts. |
given a dropped intent with issue: …/issues/5 and the towercab:queued label removed from issue 5 → then no new intent is written |
| INT-27 | Merging the code PR closes the issue it came from. When issue: points to an issue in the same repo as the code PR, the PR body has the line Fixes #<n>. An issue in another repo is linked by URL only, without Fixes. |
given issue: https://github.com/o/shop/issues/12 and a code PR in o/shop → then the body has Fixes #12; given a PR in o/api → then the URL appears and no Fixes line |
| INT-28 | If the owner closes the issue before the factory starts the intent, the intent is dropped with a note. Each poll (INT-21) looks up the issue of every intent that has an issue: and no stage yet; if it is closed, the intent is dropped (PRI-9) with the note issue closed, and the towercab:queued label stays. Once the intent has started, closing the issue changes nothing; the code PR's Fixes line (INT-27) or the owner decides. |
given an unstarted issue intent whose issue is closed → when the poll runs → then the intent is in done/ with stage: dropped and note issue closed; given stage: code → then nothing changes |
| INT-29 | The UI's intent page links to the issue an intent came from. The intent's view data carries issue (the URL, or none); the page shows it as a link next to the source. |
given an intent with issue: …/issues/5 → then its page shows a link to that issue beside the source |
| INT-30 | An intent whose body is not valid Markdown is listed as unreadable with the reason and the line, instead of having its sections read as empty. Parsing an intent (INT-2) also parses its body; a failure makes the file unreadable (INT-11) with the reason and the 1-based line in the file, the line where the broken construct opened (for example intents/<dir>/intent.md: unclosed code fence at line 18). It shows in status, next and the UI's Intents page like any other unreadable file, and get, update and the design step's open-question check (PIP-25) fail with that reason, never with a count against empty sections. Section reads (## Problem, ## Proposed outcome bullets, ## Open questions) never return an empty list on a parse failure. Bodies with balanced fences, or none, read the same as before. |
given a body that opens a code fence on file line 18 and never closes it → then the file is unreadable with unclosed code fence at line 18 and design fails with that reason |
| INT-31 | The /intent command and the issue scorer write bodies that cannot be read as an unclosed code fence. Their instructions tell the writer never to start a prose or bullet line with three backticks or three tildes; inline code uses single backticks, and a real code block is opened and closed on lines of its own. |
manual: the /intent command's and the scorer's instructions both state the fence rule |
| INT-32 | When an issue says who is affected, names a constraint or leaves something undecided, the intent drafted from it keeps that in its own section, so the design starts from everything the issue said. The score schema has affected (string, empty when the issue doesn't say), constraints (list of strings) and open_questions (list of strings); the scorer takes them from the issue only and never invents them, so an issue that says nothing about them gives empty values; the fence rule (INT-31) covers them too. INT-23 writes ## Affected users and systems, ## Constraints and ## Open questions (bullets) after ## Proposed outcome, each only when it has content. The design answers the open questions as for any intent (PIP-25). |
given a score with constraints: ["no new dependencies"] and empty affected and open_questions → then the intent has ## Constraints with one bullet and neither other section |
| INT-33 | An intent's title is a plain user story, so the owner knows which product it is about and what changes for its users when switching in cold, without opening the file. The rule, as the intents repo's README states it under "Naming an intent": one plain sentence of about 6 to 14 words, no period at the end; it names the product, then what someone can do or see once it is done, and for whom if it isn't the owner; no file names, functions, flags, stage names or jargon. Example: "The factory tells me on Telegram when it is stuck or broken", not "Self-monitoring in v2". The owner names their own intents; anything else that writes an intent (an issue, a review, an agent's finding) names it by this rule, and the factory renames it at pick when it doesn't (INT-34). | manual: every agent instruction that writes text for the owner asks for a plain brief and, where it writes an intent, a title by this rule |
| INT-34 | When the factory picks up an intent the owner didn't write, it gives it a plain title by INT-33 and keeps the old one; the owner's own titles are never changed. The design returns title (PIP-109). When the intent's source is set and isn't me, the title differs, and the intent has no ## Original title yet, the factory writes in one store commit (towercab: rename <id>): the old title as section ## Original title, and the new one, cleaned and checked as INT-36 says, on the title line (only the first # line changes, its Intent: prefix and line ending kept; every other line stays byte-identical). Its feature-list line follows (INT-36). It runs before the spec is committed; a replay after a crash renames nothing twice, and finishes the feature line if that was cut off. A failed write is only logged. source: me or no source: the title is never changed. |
given source: issue, title Fix bug 12, design title Shop buyers see the right total when a coupon is applied → then the title line changes, ## Original title holds Fix bug 12, and no other line changes; given source: me → then the title stays |
| INT-35 | The intents the factory writes itself follow the naming rule. Issue intake (INT-22, INT-23) asks the scorer for the title as a user story by INT-33, naming the product (the repo's short name), under 100 characters; the title is cleaned as INT-36 says, at most 100 characters. No other part of the factory creates intents; standards reviews and findings that arrive as intent PRs follow the README's rule, and INT-34 renames them at pick if they don't. | manual: the scorer's instructions ask for a user-story title naming the product, under 100 characters |
| INT-36 | A title an agent writes can't carry markup, mentions or hidden characters into the intent, PRs, commits or messages; one that breaks the naming rule isn't used; and a rename also updates the intent's line in the repo's feature list. Cleaning to one plain line of at most max characters: printable characters only; HTML comments removed; no <, > or backticks; no @ before a name; no leading #, -, * or @; one line, no trailing period; cut at a word boundary to max (100 for titles). A title of fewer than 4 or more than 20 words is refused, and the rename is skipped with the log line <id>: kept its title: the design's (<why>) doesn't follow the naming rule. The renamed intent's line in features/<repo>.md (the one tagged <!-- intent <id> -->) takes the new title through the feature-list edit (INT-37), when its text is still the old title as the factory wrote it; its marker, status word, spacing and tag, and every other line, stay byte-identical. A line the owner reworded keeps their words. A failure is only logged. |
given design title @alice Shop buyers see the coupon total. → then the cleaned title is alice Shop buyers see the coupon total; given a 3-word title → then the rename is skipped with kept its title; given the feature line reworded by the owner → then it keeps the owner's words |
| INT-37 | Each repo has a short list, in the intents repo, of what people can do with it, which the owner can read and edit by hand. File features/<repo>.md (<repo> a [repos] name, INT-2's repo rule). A new file starts with # <repo>: what people can do and an HTML comment saying how to edit it. One feature per line: - [live] <sentence>, - [in progress] <sentence> or - [planned] <sentence> (* also works as the marker; the status in any case). A line the factory adds for an intent ends with <!-- intent <id> -->, which is how it finds the line again. Any other line (notes, headings, blanks) is kept as it is and never read as a feature. Written only through the store's feature-list edit (repo, what, change): INT-10's protocol (lock, pull, change, regenerate the views, commit towercab: features <repo> <what>, push, replay after a rejected push); no change → no commit. A symlink in its path is refused (INT-1), as is a name that isn't a repo name. Edits are line by line: every line the factory doesn't change stays byte-identical. Read for the UI from the local checkout's copy, without a pull or the lock (the loop's list keeps the checkout fresh), so a page open while work runs doesn't sync the intents repo every few seconds and the list still shows offline. Bytes that aren't UTF-8 are refused as features/<repo>.md: not UTF-8. |
given lines * [LIVE] Buyers can pay and Some note → then one live feature is read and the note is kept unread; given an edit that changes nothing → then no commit |
| INT-38 | When the factory starts an intent its line appears as in progress, when the intent is done it becomes live, and when its work stops for good its line goes. In the loop's entry effects (LOOP-6), after the intent file's fields: entering pick sets the intent's line to in progress, adding - [in progress] <title> <!-- intent <id> --> after the last feature line when there is none; entering done sets it to live (added the same way when missing); entering stopped removes it, whatever stopped it (a drop, a closed PR, the intent file removed or moved), since a stopped intent is no longer being worked on and comes back as planned if it is queued again. A replaced attempt changes nothing: the new attempt's pick does. Only the status word of an existing line changes, so the owner's wording stays. The title is cleaned to one line of at most 200 characters. Each edit is idempotent, so an entry retried after a crash repeats it harmlessly. A sync failure retries the entry on the next pass; any other refusal (a symlink, bytes that aren't UTF-8) is logged and the mission moves on. |
given intent 261015-1446 picked → then the file gains - [in progress] <title> <!-- intent 261015-1446 --> and other lines are unchanged; when it reaches done → then that line reads [live] with the owner's wording; given it is dropped → then the line is gone; given the feature list is not UTF-8 → then the mission still proceeds |
| INT-39 | When a repo has no list of its own yet, the factory reads the repo's README and specs once and writes down what people can already do; the owner can ask for it again. Asked for by the Repos page (UI-144): the repo's backfill request becomes due. The loop starts the oldest due one when new work may start (not paused, no blocking problem, under the daily budget), one at a time, on its own (running), and records done (with the feature count and cost) or failed (with the reason). A stop kills the run and makes it due again; one a crash left running is due again on the next start. While it runs the loop isn't idle, so neither a self-update (LOOP-35) nor towercab run without --watch ends it. The run happens inside the loop's pass (never beside a mission's git step): the factory's clone of the repo is fetched and a fresh checkout of the base branch is made in a scratch work folder, removed afterwards; then the features agent runs there with read-only file tools only (read a file, list by pattern, search text), in the sandbox, with no network, the configured default model ([agent].model), a 10-minute time limit and a 2 USD cost limit, its run files kept per repo; a run past its time limit fails. Its prompt gets the repo's name, the current live lines no intent added (<current>) and the live lines intents added (<listed>, not to be repeated), each fenced as data. The answer is data: the features schema ({features: [string]}) is checked first; each sentence is cleaned to one line (no list marker, heading, status or HTML comment; at most 200 characters); duplicates and sentences already in <listed> are dropped; at most 30 kept; none left → failed. The sentences replace the file's live lines that no intent added, at the place of the first one; such lines the owner added during the run (not among <current>) are kept after them; every other line stays. |
given a file with untagged live lines A, B, a tagged live line C and a note → when the backfill answers X, Y, C → then the file has X, Y where A was, C and the note unchanged; given an answer that fails the schema → then the state is failed with the reason and the file is unchanged; given the run exceeds 10 minutes → then failed |
| INT-40 | Queued intents count as planned without being written to the file. The Repos page shows as planned the file's planned lines, then every up-next or waiting intent of the repo (UI-63) that no line names, by its title. Parked intents aren't planned. The factory never writes a planned line; the owner may. |
given a queued intent with no feature line and a parked one → then the Repos page lists only the queued one as planned |
Data model
Intent front matter (intents/<id>-<slug>/intent.md, YAML between --- lines; unknown keys kept verbatim)
| Field | Type | Required | Written by | Limits / meaning |
|---|---|---|---|---|
repo |
string | yes | owner | [A-Za-z0-9._-]+, not starting with -, not . or .. |
u, i |
integer | no (read as 3) | owner | 1–5 (PRI-1, PRI-2) |
a |
integer | no (none = a5, AUT-9) | owner; lowered by a worker | 1–5 |
after |
list of IDs, or one ID | no | owner | waits until all are done (PRI-5) |
model, review |
string | no | owner | model overrides for the work and the review |
source |
string | no | owner or intake | me or another origin (issue, …) |
issue |
URL | no | issue intake | the issue it came from (INT-23) |
started |
RFC 3339 | no | owner or intake | creation time with offset |
unlock |
list of path globs | no | owner | access granted up front (AUT-14) |
network |
list of domains | no | owner | access granted up front (AUT-14) |
retry |
fresh |
no | owner | starts a new attempt |
stage |
spec | plan | code | done | dropped |
no (absent = not started) | factory | phase of the mission |
waiting |
string | no | factory | what the owner must do |
spec_pr, plan_pr, pr |
URL | no | factory | PR URLs once opened |
cost |
number, 2 decimals | at done |
factory | USD over all attempts (MET-10) |
agent_time |
integer seconds | at done |
factory | over all attempts |
attempts |
integer | at done |
factory | ≥ 1 |
finished |
RFC 3339 | at done/dropped |
factory | |
lower_why |
string | no | factory | why a was lowered |
Intent body sections (in order; only the title required): # Intent: <title>, ## Problem, ## Proposed outcome (bullets), ## Affected users and systems, ## Constraints, ## Open questions (bullets), ## Original title, ## Towercab (last but one), ## Answers (last; lines - <local time>: <text>).
Feature line (features/<repo>.md): marker - or *; status live | in progress | planned in brackets, any case; sentence ≤ 200 characters; optional trailing tag <!-- intent <id> -->.
Feature backfill request (one per repo, kept in the factory's own state): state due | running | done | failed; count (integer, at done); cost (USD, at done); reason (string, at failed); at (when the state was set).
Score result (score schema): u, i (integer 1–5); u_why, i_why, title, problem, affected (strings); outcome, constraints, open_questions (lists of strings).
Config: [intents].path (default <home>/intents), [intents].remote (git URL), [intents].cutoff (PRI-5), [intents].issue_a (1–5, default 3), [github].me (owner's GitHub login), [repos] (watched repos).
Out of scope
- Other intent stores (Notion, Jira).
- Intents drafted from monitoring alerts.
- Issues by other people without the
towercablabel (INT-20). - The backfill's cost (INT-39) in the spend metrics: it runs outside any mission, so only its own status sentence shows what it cost.
Decisions
- One folder per intent in a separate repo: intents span several product repos, and the owner can write them from any device.
- Front matter is edited line by line, never re-serialised, so the owner's comments, order and quoting survive.
- A rejected push replays the change instead of rebasing, so generated views never conflict.
stagenames the three phases a person cares about (spec,plan,code), not the internal steps; the step is in the UI andstatus. Older stage valuestest,deployandmaintainread ascode.- Issues become accepted intents directly, without a draft PR: an eligible issue is already the owner's request, and the owner says no by lowering scores or closing it.
- Issue intake reads only the owner's issues or ones the owner labelled, so a stranger's text never reaches an agent with any power.
- The reference build drafts intents with a Claude Code skill and runs the scorer and features agent as Claude Code runs; any coding agent with headless runs, tool restrictions and schema-checked output works.
- Gaps in the ID numbering are left by rows dropped before this spec was written; they are never reused.
Loop
Status: Draft · Prefix: LOOP
Purpose
The factory is one long-running program on the owner's machine: code owns every step, wait, poll and recovery, and the coding agent runs only where judgment is needed (pipeline). It survives crashes, restarts and usage limits without losing work, tells the owner only what needs them, and updates itself when its own code changes.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| LOOP-1 | Program, home and config. The factory is one program run from a git checkout of its own source, with its state in one home folder and its settings in one TOML file. The program itself has no build step (an edit runs after a restart); only the UI's static files are built. Home: --home > TOWERCAB_HOME > ~/.local/share/towercab, created with mode 0700, holding towercab.db (the database, LOOP-37), towercab.lock, towercab.log, intents/ (the intents repo, INT-1), repos/<name>/ (clones, LOOP-8), work/<id>-<attempt>/ (checkouts), runs/<id>/<stage>/ (run files, LOOP-39), cache/<repo>/ (LOOP-34). Config: ~/.config/towercab/config.toml, or --config, or TOWERCAB_CONFIG; keys, types and defaults in Configuration below. Unknown keys are ignored. A wrong type or an out-of-range value → config: <file>: <section>.<key> <what>, exit 1. run and up also need [github] and at least one repo (LOOP-65). |
given a config with only [intents].remote when it loads then agent.parallel is 1, agent.build_timeout 60 and ui.port 7311; given agent.parallel = 0 then the error names agent.parallel and the exit code is 1; given an empty --home then it is created with mode 0700 and the listed entries appear as they are used |
| LOOP-2 | Service. towercab up installs and starts a per-user background service that runs the loop from this checkout. up writes the user service definition (Service unit below, running towercab run --watch from this checkout), reloads the user service manager, then enables and starts the service. down disables and stops it (missions stay). restart restarts it (a few seconds: there is no build). logs follows the service's log. A failing service-manager call → exit 1 with its stderr. |
given towercab up then the service definition exists with run --watch and the service is enabled and running; given towercab down then it is stopped and disabled and every mission row is unchanged; given the service manager fails then exit 1 and its stderr is printed |
| LOOP-3 | State machine. Every mission moves through one fixed set of stages, and one table decides every move. A mission is one attempt at one intent. Stages: the order pick → design → plan → build → verify → proof → publish → ci → review → gate → merge → deploy → e2e → done; the ship stages outside the order, rollback and revert (SHIP-6); the waits approval:spec, approval:plan, approval:merge (the owner merges a PR) and blocked (any other wait); and stopped. Events and moves: Transitions below. Exactly one function, given a mission and an outcome, moves a mission; done and stopped ignore every event. Phase (the intent's stage field, INT-5): pick, design, approval:spec → spec; plan, approval:plan → plan; build … e2e, rollback, revert, approval:merge → code; blocked keeps the phase it came from; done → done; stopped by a closed PR or drop → dropped. |
given every pair of stage and event then the table gives a move or "ignored"; given a mission at verify when red then it moves to build; given one blocked from design when answered then it moves back to design; given one at deploy when red then it moves to rollback; given one at done when any event then nothing changes |
| LOOP-4 | How much runs at once. At most [agent].parallel agent runs or checks run at a time; per repo, at most ahead intents are before build (LOOP-60) and at most workers are in build (LOOP-61). A slot is a child process that does work (an agent run, setup, checks, screenshots); git and GitHub calls don't take one. Per repo, at most [repos.<r>].ahead (default 3) intents are ahead of build and at most [repos.<r>].workers (default 1) build through merge; the rest wait. An intent has at most one unfinished mission. A free slot goes first to unfinished missions in priority order, then to a new pick, so a PR waiting on the owner never stops other repos. A run asked for while every slot is taken waits and is asked for again; it starts later with its own prompt, as if new. Only a run that really started (data.launched = its step, set when the child starts, cleared by a new step or stage) resumes with Continue where you stopped. (LOOP-7), and a session counts as used (session_used) only once a run of it has started. |
given parallel 1 and repos A and B each with a runnable intent when A's mission waits at approval:spec then B's intent is picked; given an intent with an unfinished mission when a second one is inserted then the insert is refused; given a run that waited for a slot when it starts then its prompt is its own, not Continue where you stopped. |
| LOOP-5 | Pick. The loop starts the highest-priority intent that can run. The decision is a pure function of the intent listing and the loop state (also what towercab next shows): the picker's list (PRI-5), skipping intents that are parked, the loop paused, a repo that already has ahead intents ahead (LOOP-60), the model cooling down (LOOP-9), GitHub or the agent unreachable (LOOP-10). An intent whose repo the factory doesn't know (LOOP-65) gets waiting: unknown repo; one whose after names an unknown, dropped or cyclic intent gets waiting: bad after:. Picked → one transaction inserts the mission: attempt = last + 1, a new session uuid, model = the intent's model or [agent].model, a_at_pick (the autonomy at pick), checkout work/<id>-<attempt>, branch towercab/<id>-<slug> (attempt ≥ 2: -<attempt> appended). The pick step fetches the clone, loads the repo files (REPO-1; none → waiting: add CLAUDE.md verifying block, invalid → waiting: fix repo files) and makes the checkout. An intent with a stage but no unfinished mission and no waiting (for example after a crash) loses its stage and is picked again. |
given runnable intents A (priority 9) and B (priority 8) then A is picked; given A's second attempt then its branch is towercab/<id>-<slug>-2; given an intent with after: x and no intent x then it gets waiting: bad after:; given an intent for repo foo the factory doesn't know then waiting: unknown repo; given an intent with stage: plan and no mission then its stage is cleared and it is picked |
| LOOP-6 | Durable steps. Before any side effect, a handler records what it is about to do, so a crash never repeats or loses a step. A move is written in one transaction: the mission's stage, step = 'enter', the outcome's fields, waiting, why, a transitions row and its outbox row (LOOP-14). The loop then runs the entry effects (idempotent: the intent's lifecycle fields, a PR comment) and sets step = ''. Before an agent run, push, PR, merge or intent write, the handler stores the step and the IDs it will use (session, branch, head, PR number) in one transaction. A re-entered step first checks what already happened: an open PR for the branch, a merge commit, a pushed head. |
given a crash forced right after each durable write in turn, with a restart after each, then the mission still ends done exactly once, with one PR, one merge and one entry comment per move |
| LOOP-7 | Crash and restart. After a crash or restart, every mission continues from its recorded step, and a cut-off agent run resumes its own session. meta.running = <pid> <start> while the loop runs; found at start → the last exit was unclean: leftover child process groups are killed, and every mission that was busy gets unclean + 1 (3 in a row → waiting: crashed at <stage>). Sessions: the uuid is chosen at pick; the session's first run starts the agent with that session id, later runs resume that session from the same checkout; after a cut-off the prompt is Continue where you stopped. When the agent reports that the session doesn't exist, the mission gets a new uuid and a prompt naming the branch, the PRs, intent.md, the spec IDs and plan.md. SIGTERM or SIGINT: SIGTERM to each child's process group, SIGKILL after 10 s, interrupted for its mission (not counted as a crash), meta.running removed, exit 0. |
given a build run killed mid-run when the loop restarts then the same session is resumed with Continue where you stopped.; given the agent says the session is missing then a new uuid is used and the prompt names the branch and plan.md; given SIGTERM then exit 0 and unclean is unchanged; given three unclean exits in a row at build then waiting: crashed at build |
| LOOP-8 | Clones and checkouts. The factory keeps its own clone of each repo and a separate checkout per mission, and runs nothing from a checkout's .git. Clone: the repo's path (LOOP-65; default <home>/repos/<r>), cloned from https://github.com/<github>.git when missing; only fetched into and pushed from. Checkout work/<id>-<attempt>: made in a .tmp folder and renamed into place; git clone --no-checkout <clone>, origin removed, base fetched by path, git checkout -B <branch> refs/remotes/origin/<base>. Every git call there uses -c core.hooksPath=/dev/null -c core.fsmonitor=false (no hooks, no file monitor), and the factory's own git there first checks that .git/config holds only plain keys. pick always makes it fresh, replacing whatever is at that path (a crashed pick's, or an older version's with the same <id>-<attempt>); later stages keep it until the mission is done, stopped or replaced. The checkout is the factory's alone: each build round first runs git reset --hard and git clean -fd, so a file another program left there (an untracked file, an edit) can't block the checkout of base or ride into the PR. Ignored files, such as caches, stay. |
given a mission moving from design to build then the checkout is the same folder; given a leftover folder at work/<id>-1 when pick runs then it is replaced; given a checkout then git remote lists nothing; given a .git/config with a core.sshCommand key then the factory refuses to run git there; given an untracked file left in the checkout when a build round starts then it is gone and not in the PR, while an ignored cache folder stays |
| LOOP-9 | Usage limits. When the agent hits a usage limit, the mission waits for the reset and resumes the same step; other models keep working. A limit is detected in the agent's output, case-insensitive: rate.?limit|usage.?limit|hit your limit|limit reached|limit will reset|quota|too many requests|\b429\b|overloaded|rate_limit_error|overloaded_error. The reset time is read by the first rule that matches: an epoch after a bar (|1760540400); retry-after: N seconds; try again|retry|resets? in N unit [N unit]; RFC 3339 or YYYY-MM-DD HH:MM[:SS][zone]; resets [at|on] [<Mon> <day>,] <h>[:mm] [am|pm] [(<IANA zone>)] (needs am/pm, minutes or a date; a past clock time → tomorrow; a past date → next year). None matches → backoff 1, 2, 4 … 60 min. A cooldown per model (until = reset + 30 s) is kept in meta; the mission keeps its step and re-enters it after until with Continue where you stopped. (a review starts a fresh session instead, PIP-13). A limit wait is never a crash, a repair or a timeout. The first success on a model resets its backoff. |
given output You've hit your limit · resets 3pm (Europe/Berlin) at 16:00 Berlin time then the model cools until 15:00:30 Berlin time tomorrow and the mission keeps its step; given retry-after: 120 then the reset is now + 120 s; given a limit with no time three times in a row then the waits are 1, 2 and 4 minutes; given model opus cooling then an intent on another model is still picked |
| LOOP-10 | Network and auth failures. A failed network or login call is retried with backoff and never stops the loop. Every git and GitHub failure in the pipeline is classified from its stderr: network (DNS, refused, reset, timeout, TLS, HTTP 5xx) or auth (HTTP 401, Bad credentials, the agent asking to log in, git's remote: Repository not found, which is how GitHub refuses a token on a private repo). A step that hits one retries after 30, 60, 120, 240, then 300 s; an agent run resumes its session. Health (kept in memory): per kind github, agent, tool:<exe>, sandbox, open from the first failure to the next success. Open for 15 minutes → picks pause, one message names the fix, the UI shows it under Problems. Calls are bounded: GitHub CLI calls 120 s, git calls 300 s. A worker's output that shows a broken environment (the sandbox didn't start, command not found) is that fault, not a question for the owner. |
given stderr Could not resolve host then it is a network failure and the step retries after 30 s; given HTTP 401 then it is an auth failure; given GitHub unreachable for 2 minutes then the loop keeps passing and sends no message; given it unreachable for 15 minutes then picks pause and exactly one message is sent; given worker output command not found then a tool: fault opens and no question is asked |
| LOOP-11 | Stay awake. The machine doesn't sleep while the factory is actively working. While any mission is in a working stage (not waiting on the owner, not in a limit wait), one child holds an OS sleep inhibitor that blocks sleep, idle and lid-switch handling, named for the factory, with the reason <id> <stage>, in blocking mode; it is killed when nothing is driven and at shutdown. Woke from suspend (the wall clock ran ahead of the monotonic clock by more than 3 passes) → every poll is due at once and no wait timeout fires before its poll. |
given a mission at build then one inhibitor runs with reason <id> build; given every mission waiting on the owner then no inhibitor runs; given the wall clock jumps 2 hours ahead after a suspend then the CI poll runs before the ci_wait timeout is judged |
| LOOP-12 | The pass. Every 5 seconds the loop does one pass: read inputs, move missions, start work, send messages. Order: signals; wake check; apply commands; finished children → their handler; entry effects due; child timeouts; due polls (the intents repo every 60 s, re-read when its main moved; PRs every 60 s; GitHub issues every 300 s, INT-21; CI every ci_poll); step due missions; pick; self-update check (LOOP-35); outbox; inhibitor. Children: own process group, environment cleared then set, stdin from a file or the null device, stdout and stderr to files in the stage's run folder (LOOP-39); a timeout (the step's budget minus the run time already spent) sends SIGTERM to the group, SIGKILL 10 s later. |
given an hour-long build child then PR polls still run every 60 s; given an idle loop then passes are 5 s apart; given a child past its timeout then its whole process group gets SIGTERM and, 10 s later, SIGKILL |
| LOOP-13 | status and next. towercab status and towercab next print what is happening in plain lines, with or without a running loop. status: running or not (paused, problems), active work (intent, stage, model, since), waiting on the owner (with the ask and the PR), waiting on CI, queued, cooling models, open problems, next. next: the intent the picker would take and why, then each skipped one with its reason. |
given one mission at build and one at approval:spec then status shows each under active and waiting with the PR link; given a parked intent then next lists it with reason parked |
| LOOP-14 | Messages only when the owner must act. The factory sends a Telegram message only when the owner has to do something, and each says what, on which intent, with a link. Every wait on the owner (Waits below) writes one outbox row in its move's transaction (key: mission, wait, continued), so each new wait sends once. Text: the ask in one sentence, <title> (<repo>), the reason when there is one, and the link (the PR, else the intent page in the UI, else the intent file). Also sent: GitHub or the agent unreachable for 15 minutes (LOOP-10); a self-update that failed (LOOP-35). Never sent on: done, merges, limits, a PR the owner closed. A queued message whose wait is over is dropped. |
given a mission moves to approval:spec then one outbox row with the spec PR link is written; given every key in Waits then each has a text; given a mission reaches done then no row is written; given an unsent message whose wait was answered then it is dropped unsent |
| LOOP-15 | Commands. The CLI covers running the service and the few actions that have no GitHub gesture. Commands: up, down, restart, logs (LOOP-2); run [--watch] [--intent <id>]; status, next (LOOP-13); add (INT-2); answer <id> <text> (LOOP-38); start <id> (LOOP-63); retry <id> (a fresh attempt); drop <id> (PRI-9); approve <id> (merges the PR the intent waits on, as the owner, LOOP-38); pause, resume; repo add | remove | set | list (LOOP-66, LOOP-67, LOOP-69); ui (opens the page); doctor; update (LOOP-35); hook guard <rules> (internal, PIP-29); --version. One loop per home: a lock file; a second run → towercab is running (pid N): towercab down first. answer, retry, drop, start, pause and resume insert a commands row the loop applies on its next pass; with no loop running the CLI applies it itself. Errors print towercab: <reason> and exit 1; bad arguments exit 2. |
given a running loop when towercab run starts then it prints towercab is running (pid N): towercab down first and exits 1; given towercab pause with a loop then a commands row is applied on the next pass; given no loop then pause takes effect at once; given towercab drop with no id then exit 2 |
| LOOP-16 | Telegram. Telegram only delivers the factory's messages; the owner acts on GitHub or in the UI. Send: POST <api_base>/bot<token>/sendMessage with {chat_id, text, disable_web_page_preview: true} (text ≤ 4000 characters, 10 s timeout). Unsent rows go oldest first, stop at the first failure and retry with LOOP-10's backoff. Failures are logged once per outage, without the token. No [telegram] → rows are written and never sent. |
given Telegram answering 500 twice then 200 then the message is sent on the third try and one failure line is logged without the token; given no [telegram] then outbox rows stay unsent and nothing fails |
| LOOP-17 | Idle. With nothing to do, the loop keeps polling quietly. With --watch: no message, no exit. Without --watch, run exits 0 once nothing is runnable and nothing is driven. |
given no runnable intents and run without --watch then exit 0; given the same with --watch then it keeps passing and sends nothing |
| LOOP-18 | Events from GitHub and the intents repo. The factory reads the intent's PRs and the intents repo every minute and turns what the owner did there into events. PR reads (LOOP-38, PIP-21) give at most one event per read, in this order: merged, closed, owner feedback, /retry, conflict, head moved. They are re-derived from the PR each time (level-triggered), so nothing is lost while a child runs. A conflict carries the same conflict repair as CI's (PIP-1): the build merges the base branch first, so the next round no longer conflicts. Intents repo: the intent file gone → intent_removed (stop); retry: fresh written → retry_fresh; waiting removed by the owner while blocked → answered with no text. A move out of a stage kills that stage's child. |
given a PR that is both merged and has new owner feedback then the event is merged; given the owner merges the PR while it is at ci then the mission moves to deploy; given the intent file is deleted then the mission is stopped; given the owner removes waiting: from a blocked intent then answered with no text |
| LOOP-19 | GitHub App identity. The factory acts on GitHub as its own GitHub App, so its work is clearly separate from the owner's. The App (shipped as a manifest) has contents, pull requests, issues, checks, statuses, workflows, actions: write; metadata: read; it is installed on intents and each repo. Its private key app.pem lies next to the config and is refused if readable by group or others. An RS256 JWT is exchanged for an installation token, cached per repo (1 hour, minted again when under 10 minutes are left), and given to GitHub calls and to git through a credential helper; never to agent runs. The token covers the installation's repos, not just one: since 2026-10-27 GitHub's git answers 404 to a token scoped with repositories or repository_ids on a private repo, while the REST API accepts it. Commits are authored as towercab[bot] <<id>+towercab[bot]@users.noreply.github.com>. |
given two repos then each has its own cached token, minted again at 51 minutes old; given a push then git authenticated with the App token; given a commit then its author is towercab[bot]; given an agent run then its environment holds no GitHub token; given app.pem with mode 0644 then it is refused |
| LOOP-20 | gh 2.46. Where the factory uses GitHub's command-line client gh, it works with gh 2.46, the version Ubuntu ships. It uses none of api --slurp, pr checks --json, pr update-branch, run view --json attempt. doctor prints the gh version and warns below 2.46. |
given gh 2.46 when every GitHub step runs then none fails on an unknown flag; given gh 2.40 then doctor warns |
| LOOP-21 | Log lines. Every log line is one readable line; long messages go whole to a file. To stderr (the service log): the first meaningful line, control characters as spaces, at most 300 characters, (+<n> lines) when cut, and the whole text after --- <time> in towercab.log (rotated at 5 MiB). Every stage move logs <intent> <from> → <to> (<event>[: <why>]). A warning repeating every pass is logged once until it changes. |
given a 40-line error then stderr has one line ending (+39 lines) and towercab.log has all 40; given a move from ci to build on red then the line <id> ci → build (red: …) is logged; given the same warning on 10 passes then it is logged once |
| LOOP-34 | Shared build cache per repo. Each repo has one build cache that every mission reuses, so a new mission doesn't compile from scratch. <home>/cache/<repo>/ is writable for setup and the worker of that repo, and read-only for its checks, proof, screenshots and e2e unless the owner allowed them to write (PIP-95). Environment for those runs: XDG_CACHE_HOME=<cache>/xdg, npm_config_cache=<cache>/npm, CARGO_HOME=<cache>/cargo, CARGO_TARGET_DIR=<cache>/target, GOCACHE=<cache>/go, PIP_CACHE_DIR=<cache>/pip, then [agent].env and [repos.<r>].env. With one worker per repo (LOOP-4) two missions never share it at once. |
given two missions of the same repo one after the other then both runs see the same CARGO_TARGET_DIR; given a check run then writing into the cache fails unless the owner allowed it |
| LOOP-35 | Self-update. When the factory's own main moves, the running factory updates itself and restarts within a minute. Every 60 s, when the checkout it runs from is clean, on main, and [update].auto is true (default): git fetch; if origin/main is ahead, wait until no child runs, then git merge --ff-only, reinstall dependencies when the dependency lock file changed, rebuild the UI when the UI sources changed, and check the new tree with towercab doctor --quick, which reads only the config, the tools and the program's own code: it never opens the database, so new code that is then rejected has migrated or loaded nothing. Pass → exit 75 and the service manager starts the new code. Fail → git reset --hard <previous>, keep running, one message self-update failed: <why>. towercab update does the same at once. status and the UI show the running commit and whether it is behind main. It never moves to an older commit. |
given origin/main one commit ahead and no child running then the loop fast-forwards and exits 75; given doctor --quick fails on the new tree then the checkout is reset to the previous commit, the loop keeps running and one message is sent; given an uncommitted edit in the checkout then nothing is fetched or merged; given doctor --quick then the database file is not opened |
| LOOP-36 | Dev loop. A code change to the factory runs on the owner's machine within seconds. The service runs the checkout's own files (no build), so towercab restart runs an edit at once; a dev mode reruns the loop on every source edit with a scratch home, and a UI dev server reloads the page on edit while forwarding /api to the running loop. |
manual: edit a line of the status output, run towercab restart, and towercab status shows the new line |
| LOOP-37 | Database. One database file holds what the loop must not forget: missions, their moves, commands, messages and repos. towercab.db is an embedded, transactional database with write-ahead logging, a full sync on every commit and a 5 s wait on a locked database; migrations run in order by a schema version stored in the file. Tables in Data model below: missions, transitions, commands, outbox, repos (LOOP-65), and a key-value meta (running pid, paused, cooldowns, bot identity, last pass). A transaction takes the write lock at its start. A database at that path from the factory's first generation (v1) is moved aside to towercab.v1.db and not used. A migration that only adds (a table, a column, a meta key older code doesn't read) is marked additive. Each migration records in meta.compatible_from the oldest version (counted by its number of migrations) that can still use the database. A version finding a newer schema opens the database as it is when compatible_from is at most its own number, and otherwise refuses: towercab.db is version <n>, newer than this factory (<m>). Migration 3 (repos, LOOP-65) is additive, so compatible_from stays 2. Rolling back by hand past LOOP-65: versions from before it don't read compatible_from, so stop the factory and set the schema version to 2 first; the old version then reads its repos from config.toml again (keep the tables until you won't roll back). Upgrading again re-runs migration 3, which keeps an existing repos table and its rows. |
given a fresh home then the tables match Data model; given a database at version 1 then migrations 2 and 3 run in order; given a v1 database at the path then it is renamed towercab.v1.db; given a database at version 4 with compatible_from 2 opened by a version with 3 migrations then it opens; given compatible_from 4 then it refuses with the message |
| LOOP-38 | Acting as the owner. Actions the owner starts from the UI or the CLI happen on GitHub under the owner's own login, so GitHub shows they were the owner's. Owner actions use the owner's own GitHub login on the machine (no App token; the login must be [github].me). approve squash-merges the PR the intent waits on; a reply from the UI comments on the PR; drop from the UI closes the current PR. Before any PR exists, answer appends to the intent's ## Answers (INT-15) and continues it. |
given towercab approve <id> at approval:merge then GitHub shows the squash merge by [github].me; given towercab answer <id> "use the v2 API" at design before any PR then the text is under ## Answers in the intent and the mission continues |
| LOOP-39 | Run files by stage. Every file a mission writes is kept in a folder named for the stage that wrote it. runs/<id>/<stage>/ with <stage> one of design, plan, build, verify, proof, publish, ci, review, gate, merge. Everything the stage writes goes there: prompts, the agent's answers, stdout and stderr, the agent's settings and system prompt, the guard file, setup and check logs, screenshots. Build keeps its worker files and its setup log together. |
given a mission through review then the review prompt is under runs/<id>/review/ and the build setup log under runs/<id>/build/ |
| LOOP-40 | Rounds keep their number. A later round never replaces an earlier round's files, and an empty error file is not left behind. A file written more than once in a stage carries the round in its name (worker-2.prompt, worker-2.json, verify-2.log, shots-2/); the first round carries its number too. Empty .err files are removed when the child ends. |
given two build rounds then worker-1.prompt and worker-2.prompt both exist; given a child with no stderr then no .err file remains |
| LOOP-41 | Readers use the stage folders. Everything that reads run files finds them in the stage folders. Repair prompts (log tails), the review (logs and screenshots) and the run file API (UI-65, which accepts <stage>/<file>) read from runs/<id>/<stage>/. The API still refuses a path outside runs/<id>/: no .., no symlink out, text only, at most 2 MB. |
given a red verify then the repair prompt quotes the tail of verify/verify-1.log; given an API request for ../x or a symlink to /etc/passwd or a 3 MB file then it is refused |
| LOOP-42 | Old runs still show. Runs written in the older flat layout (no stage folders) keep working, with no migration. Files in a flat runs/<id>/ are assigned to a stage by their name prefixes (worker-* and build-setup.log → build, verify-* → verify, shots-* → verify, settings-*, system-* and guard.json → the stage of their agent kind, and so on). Nothing is moved, so a running mission is never disturbed. A name that matches no stage is listed under "Other". |
given a flat runs/7/ with worker-1.prompt, verify-1.log and notes.txt then the UI shows them under build, verify and Other, and no file moved |
| LOOP-60 | Intents ahead of build. A repo can have up to 3 intents being designed, planned or waiting for approval at the same time, so one intent's wait for the owner doesn't stop the next one. Setting [repos.<r>].ahead, integer 1–16, default 3. An intent is ahead while its mission is at pick, design, approval:spec, plan or approval:plan, or is blocked in the spec or plan phase. A mission that is building or later, or whose plan is approved and waits for the build slot (LOOP-61), is not ahead. The pick skips an intent of a repo that already has ahead intents ahead (waiting: repo has <n> intents ahead, shown in plain words by UI-61), except one started with start (LOOP-63). Free places go to intents in the picker's order (PRI-5). A higher-priority intent that arrives while the repo is full waits for a free place; no running or waiting design is stopped, paused or dropped for it. [agent].parallel still caps all agent runs and checks (LOOP-4): with 1, a design run queues behind other runs. |
given ahead 3 and 4 runnable intents in one repo then 3 missions are ahead and the fourth waits with repo has 3 intents ahead; given a higher-priority arrival then no design is stopped; given ahead = 0 or 17 then config fails naming the key; given none set then 3 |
| LOOP-61 | Build slot. Only one intent per repo builds at a time, and when the slot is free it goes to the highest-priority intent whose plan is approved. [repos.<r>].workers (default 1) counts missions from build through merge (including approval:merge and blocked in the code phase). When the plan PR is merged and the repo already has workers such missions, the mission does not start build: it waits as ready to build, with no message to the owner and its intent stage still plan. Each pass, for each repo with a free slot, the ready mission with the highest priority starts build (ties: the one approved first). A ready mission never takes the slot from a running build. A ready mission is not ahead (LOOP-60), so its place goes to the next intent. drop, retry and a closed plan PR work on it as at approval:plan. |
given one mission at ci and another's plan PR merged then the second waits as ready to build, sends no message, and its intent shows stage: plan; given two ready missions with priorities 7 and 9 when the slot frees then the 9 starts build; given a ready mission then the repo can pick another intent ahead |
| LOOP-63 | start. towercab start <intent> begins that intent's design now, even when the repo already has 3 intents ahead. It inserts a start row in commands (LOOP-15; applied by the loop, or by the CLI with no loop). The intent is then picked as if the repo had a free place: it goes over ahead and nothing running is stopped. Other skips still apply: parked, paused, unknown repo, bad after, cooling down, GitHub or the agent unreachable (LOOP-5, LOOP-9, LOOP-10). Refused with a reply start: <reason> (the CLI prints it): the intent is unknown, <id> has already started (it has a mission, or an earlier start is still waiting for its pass), or <id> is done or dropped. A started intent is picked before others, so it begins on the next pass. start only moves design forward: an intent with an approved plan still waits its turn for the build slot by priority (LOOP-61). |
given a repo with 3 intents ahead when towercab start x then x is picked on the next pass and the 3 keep running; given x already has a mission then start: x has already started; given x is done then start: x is done or dropped; given x ready to build behind another then start doesn't move it |
| LOOP-64 | status and next show the wait. status and next say when an intent waits for a place or for the build slot. A skipped intent reads waiting: repo has <n> intents ahead (start <id> to go now); a ready mission reads ready to build, behind <id>. |
given a full repo then next prints waiting: repo has 3 intents ahead (start <id> to go now) for the skipped intent; given a ready mission behind a1 then status prints ready to build, behind a1 |
| LOOP-65 | Repos live in the database. The repos the factory works on are kept in its database, so they can be added and removed while it runs; the config file's repo tables are only read once, to bring them in. Table repos (Data model): name (the short name intents use), github (owner/name), path ('' = <home>/repos/<name>), base, workers, ahead, env (a JSON object), source (config, cli or ui), added. On start (every CLI command that reads the home, and the loop's startup), each valid [repos.<name>] table is imported once: unless meta.repo-imported:<name> is set, it is set and the table becomes a row (a name already in the table is left as it is). A table is never imported again, so a repo removed with repo remove stays removed. The in-memory repo list the rest of the program reads is then filled from the table and stays the same object afterwards. A config table whose repo is in the database, or was imported and removed, is ignored: it no longer tunes the repo (that is repo set, LOOP-69). doctor names each one: <name>: [repos.<name>] in <file> is ignored: the repo is in the factory's database now (towercab repo list, towercab repo set). Keep it only while you might go back to a version from before repos moved into the database; then delete it. (or …the repo was removed with towercab repo remove. Keep it only…). A table that fails to validate and has no row still fails only what uses it. |
given a config with [repos.web] on first start then a repos row web with source config; given repo remove web and a restart then no row comes back; given [repos.web] changes workers after import then the row is unchanged and doctor says the table is ignored |
| LOOP-66 | repo add. towercab repo add <owner/name> adds a repo with nothing else to fill in: the factory checks it can reach it, takes its default branch and clones it. repo add <owner/name> [--name <short>]: a https://github.com/ prefix or .git suffix is dropped; the owner and the repo part must each be a repo name (INT-2: no . or ..). The short name defaults to the repo part; it must be a repo name and not intents. The GitHub App must reach the repo: a read of repos/<owner/name> as the App (LOOP-19) gives full_name and default_branch. GitHub's full_name is stored as github (its spelling of the name) and the default branch as base; workers 1, ahead 3, env empty, path ''. The repo is cloned as in LOOP-8 into <home>/repos/<name>; a clone already there is reused only when its origin is this repo (compared without case). Only then is the row stored, in a transaction that checks for duplicates again (source cli, or ui from the page, UI-130). One process adds a name or repo once at a time; a second add while the first runs is refused. Refusals print towercab: <reason> and exit 1: repo add needs [github] in <file> (app_id, me); <text> isn't a GitHub repo: write it as owner/name; <name> can't be a repo name: use letters, digits, dots, dashes and underscores; <owner/name> is being added already; <name> is already a repo (<github>); <owner/name> is already added as <name>; The factory can't reach <owner/name>: <why> (the App isn't installed on it, GitHub's refusal, no app.pem); GitHub didn't say which branch is <owner/name>'s default; <clone> already holds a clone of another repo: move it away first; The factory couldn't clone <owner/name>: <why>. |
given repo add https://github.com/Acme/Web.git where GitHub answers acme/web with default branch trunk then a row web with github acme/web and base trunk and a clone in <home>/repos/web; given a repo the App isn't installed on then The factory can't reach … and exit 1 and no row; given web already added then web is already a repo (acme/web) |
| LOOP-67 | repo remove and repo list. The owner can list the repos and remove one nothing is working on. repo list: one line per repo, by name, <name>: <owner/name> · base <base> · <n> worker(s) · ahead <n> · <clone path>, then · env <names> when it has variables (names only, never values); none → no repos yet: add one with towercab repo add <owner/name>. repo remove <name> deletes the row in one transaction with the check that no mission still needs the repo: none is unfinished, and none that finished still has its entry step to run (step != '': closing its PRs, retiring its branch, LOOP-6). Refused with <name> still has unfinished intents (<ids>): let them finish or drop them first, or <name> isn't a repo the factory knows (towercab repo list). The clone stays on disk. |
given repo web with env TOKEN=x then repo list shows · env TOKEN and not x; given no repos then the no repos yet line; given an unfinished mission in web then repo remove web is refused naming its id; given none then the row is gone and the clone folder stays |
| LOOP-68 | The running loop sees repo changes. A repo added or removed while the factory runs counts from its next pass, without a restart, and the intents that waited for it start. Each pass first makes the in-memory repo list equal the repos table, changing only the entries that differ (repo add from the CLI is another process; the page adds in the loop's own). An intent with waiting: unknown repo, no unfinished mission, and a repo that is now known loses its waiting; when at least one did, the intents are read again in the same pass, so it can be picked at once. The pass waits on the network between its sync and its pick, so the pick's transaction checks that the picked intent's repo row still exists; if it was removed meanwhile, nothing is picked and the log says <id>: its repo <name> was just removed; not picked. |
given an intent waiting with unknown repo when repo add adds its repo from the CLI then it is picked in the next pass; given its repo is removed between sync and pick then nothing is picked and the log line appears |
| LOOP-69 | repo set. towercab repo set <name> key=value… changes a repo's settings; config tables no longer do. Keys: workers, ahead (integers 1–16), base (a branch name), path (absolute or ~/; empty = <home>/repos/<name>), env (env=NAME=value sets one variable, env=NAME= sets it empty; env={"NAME":"value",…}, a JSON object of strings, replaces them all). Several pairs at once; one bad pair changes nothing. It prints the repo's repo list line after set . The running loop uses the new values from its next pass (LOOP-68). base and path change only while no mission needs the repo (as for repo remove): <name> has unfinished intents (<ids>): change base or path once they are done. Other refusals: <key> must be an integer 1-16; base: <value> is not a branch name; path must be absolute or start with ~/; env must be NAME=value or a JSON object of strings; unknown setting <key>: use workers, ahead, base, path or env; <pair> isn't key=value; <name> isn't a repo the factory knows (towercab repo list). |
given repo set web workers=2 env=CI=1 then it prints set web: … · 2 worker(s) …· env CI; given repo set web workers=2 ahead=99 then nothing changes and ahead must be an integer 1-16; given an unfinished mission then repo set web base=dev is refused |
Configuration
config.toml keys. Paths expand ~/.
| Key | Type, range | Default |
|---|---|---|
intents.remote, intents.path |
URL; path | required; <home>/intents |
intents.cutoff |
int 1–15 | 7 |
intents.issue_a |
int 1–5 | 3 |
agent.model, agent.review_model |
a model name the coding agent accepts | the agent's top model (opus in the reference build); one step above the worker's |
agent.parallel |
int 1–16 | 1 |
agent.build_timeout, agent.review_timeout, agent.check_timeout |
minutes 1–1440 | 60, 15, 10 |
agent.max_budget_usd, agent.daily_budget_usd |
number > 0 | 10; none |
agent.ci_poll, agent.ci_grace, agent.ci_wait |
seconds | 30, 120, 1800 |
agent.path |
PATH for runs and checks |
the loop's PATH |
agent.toolchain |
list of absolute dirs | ~/.local/bin |
agent.env |
table of strings | {} |
agent.network |
list of domains | the default list of AUT-14 |
agent.sandbox_unix_sockets |
bool | false |
repos.<r>.github, path, base, workers, env |
owner/name; path; branch; int 1–16; table of strings |
required; <home>/repos/<r>; main; 1; {} |
repos.<r>.ahead |
int 1–16 | 3 |
github.app_id, github.me, github.api_base |
int; GitHub login; URL | required; required; https://api.github.com |
telegram.bot_token, telegram.chat_id, telegram.api_base |
strings; URL | none (no Telegram); https://api.telegram.org |
ui.port |
int 0–65535 (0 = off) | 7311 |
update.auto |
bool | true |
A first-generation [tasks] table is read as [intents] when [intents] is absent. [repos.<r>] tables are imported into the database once (LOOP-65) and ignored after that: they no longer tune an imported repo; towercab repo set does (LOOP-69).
Transitions
next(stage, event). "next" = the next stage of the order; U = unexpected, counted like a crash; – = ignored.
| Event | Working stages | approval:spec | approval:plan | approval:merge | blocked |
|---|---|---|---|---|---|
| ok | next (e2e → done; rollback → build; revert U) | U | U | U | U |
| red | verify, proof, ci, review → build; deploy, e2e → rollback; else U | U | U | U | U |
| skip | deploy → done; else U | U | U | U | U |
| revert | rollback → revert; else U | U | U | U | U |
| timeout, bad_result, interrupted | again | – | – | – | – |
| limit | cool | U | U | U | U |
| wait (a handler's) | to the wait's stage | same | same | same | same |
| env_fail | backoff | backoff | backoff | backoff | backoff |
| crash | recover | – | – | – | – |
| spec_merged | design → plan; else – | → plan | – | – | from design → plan |
| plan_merged | plan → build; else – | – | → build | – | from plan → build |
| code_merged | build … merge → deploy; else – | – | – | → deploy | → deploy, unless it came from deploy … revert |
| closed | → stopped | → stopped | → stopped | → stopped | → stopped |
| feedback | build … merge → build; else – | → design | → plan | → build | as answered |
| answered | – | – | – | – | continue |
| conflict | ci … merge → build; else – | – | – | → build | → build |
| head_moved | review … merge → ci; else – | – | – | → ci | – |
| intent_removed | → stopped | → stopped | → stopped | → stopped | → stopped |
| retry_fresh | fresh | fresh | fresh | fresh | fresh |
| recheck (AUT-21) | – | → design | → plan | → review | – |
| resume | merge (held) → again; else – | – | – | – | – |
- again = re-enter the step; cool = wait for the model's cooldown (LOOP-9); backoff = LOOP-10; recover =
unclean + 1, at 3 →crashed at <stage>; fresh = this missionstopped(whyreplaced by attempt <n>) and a new one atpick; continue = back tofrom_stagewhen it was pick, design or plan, elsebuildwith the answer in the prompt. plan_mergedwhile the repo's build slot is taken waits as ready to build (LOOP-61).- Every move out of a wait adds 1 to
continued, so the next wait sends a new message (LOOP-14).
Waits
waiting: text → stage; the ask is line 1 of the message and the UI's card. The keys are stored in intent files and never change; each ask says plainly what the owner decides or does (PIP-110).
waiting: |
Stage | The ask |
|---|---|---|
approve spec |
approval:spec | Decide if this is the right change to build: merge the spec PR to go ahead, or comment what to change. |
approve plan |
approval:plan | Decide if it should be built this way, with the access it asks for: merge the plan PR to start, or comment what to change. |
review and merge PR |
approval:merge | Decide if this change can go live: merge the PR to ship it, or comment what to fix. |
answer the question |
blocked | Answer a question so the work can go on: pick an option or write your own. |
checks red |
blocked | The tests can't pass without your help. Reply with what to change, or start over. |
allow check writes |
blocked | Decide if the tests may write to this machine's shared build cache: reply allow or no. |
tests don't prove the change |
blocked | No test shows the change works. Reply with what a test should check, or say go on to continue without one. |
CI red |
blocked | GitHub's checks still fail after two fix attempts. Reply with a hint on what to change, or start over. |
CI stuck |
blocked | GitHub's checks didn't finish in time. Make sure they run, then reply so the factory looks again. |
worker timeout |
blocked | The agent ran out of time before finishing. Reply to let it carry on, or start over. |
worker budget |
blocked | The agent used up its budget before finishing. Reply to let it carry on, or start over. |
no result |
blocked | The agent stopped without a usable answer. Reply to let it carry on, or start over. |
no changes |
blocked | The agent finished without changing anything. Reply with what it should do, or drop the intent if nothing is needed. |
crashed at <stage> |
blocked | The factory failed three times at the same step. Reply to try again once the cause is fixed, or start over. |
spec conflict |
blocked | The spec clashes with other changes and the factory couldn't combine them. Resolve it in the spec PR, then reply. |
merge failed |
blocked | GitHub refused to merge the change. Fix what GitHub objects to, then reply to try again. |
add CLAUDE.md verifying block |
blocked | The factory doesn't know how to test this repo yet. Add a verifying section to its CLAUDE.md, then reply. |
fix repo files |
blocked | One of the repo's files for the factory can't be read. Fix it, then reply. |
unknown repo |
(intent only) | Tell the factory which repo this is for: add the repo, or fix the repo name in the intent. |
bad after: |
(intent only) | Fix what this intent waits for: the factory can't find one of the intents it names, or it was dropped. |
rolled back, revise |
blocked | The change broke the live app, two fixes didn't hold, and it was taken back out. Revise the intent, then start over (/retry). |
rollback failed |
blocked | Taking the broken change back out failed, so nothing more goes live in this repo until you fix it. |
Every blocked wait continues on a reply on the intent's current PR, towercab answer, or removing waiting: from the intent; /retry starts over; closing the PR drops the intent. waiting: repo has <n> intents ahead (LOOP-60) is shown, not sent: it needs no owner action.
Service unit
The user service that towercab up writes (LOOP-2):
- Name
towercab, described with the factory's name; command: this checkout'stowercab run --watch. PATH= the program runtime's folder,~/.local/bin,/usr/local/bin,/usr/bin,/bin.- Always restarted when it exits, after 5 s (this is how self-update's exit 75 starts the new code, LOOP-35).
- Stopping sends SIGTERM to the main process only (it stops its own children, LOOP-7); whatever is left is killed after 30 s.
- Started with the owner's user session.
Data model
One database file (LOOP-37). Times are unix seconds (numbers with fractions where marked "time").
meta
| Field | Type | Required | Notes |
|---|---|---|---|
key |
text | yes | unique; keys used: running (<pid> <start>), paused, model cooldowns (until and backoff per model), bot identity, last pass, compatible_from, repo-imported:<name> |
value |
text | yes |
missions
One row per attempt at an intent. At most one row per intent whose stage is neither done nor stopped (enforced by a unique condition).
| Field | Type | Required | Notes |
|---|---|---|---|
id |
integer | yes | unique, assigned |
intent |
text | yes | the intent id |
repo |
text | yes | the repo's short name |
attempt |
integer | yes | 1, then last + 1 (LOOP-5) |
stage |
text | yes | a stage of LOOP-3 |
step |
text | yes | default enter; '' when the entry effects are done (LOOP-6) |
from_stage |
text | no | the stage a wait or blocked came from |
waiting |
text | no | a key of Waits |
why |
text | no | the reason shown with the wait |
continued |
integer | yes | default 0; +1 on every move out of a wait |
busy |
integer | yes | default 0; 1 while a step is running |
unclean |
integer | yes | default 0; unclean exits in a row (LOOP-7) |
held |
integer | yes | default 0; 1 while a merge is held until resume |
model |
text | yes | the worker's model |
a_at_pick |
integer | no | the intent's autonomy when picked |
session |
text | yes | the agent session uuid |
session_used |
integer | yes | default 0; 1 once a run of the session started |
checkout |
text | yes | work/<id>-<attempt> |
branch |
text | yes | towercab/<id>-<slug>[-<attempt>] |
base_sha |
text | no | |
head |
text | no | the pushed head commit |
spec_pr, plan_pr, pr |
integer | no | PR numbers of the spec, plan and code PRs |
pushed_at |
time | no | |
feedback_seen |
text | no | owner comments up to this point are taken (PIP-21) |
merged_by |
text | no | GitHub login that merged |
merge_sha |
text | no | |
repairs, ci_repairs, retries |
integer | yes | default 0 each |
retry_at |
time | no | when a backed-off step is due again |
limit_model |
text | no | the model whose cooldown the mission waits for |
step_secs |
number | yes | default 0; run time already spent in the step (LOOP-12) |
child_pid, child_kind, child_since, child_model |
integer, text, time, text | no | the running child |
cost_usd |
number | yes | default 0; the agent's reported cost |
agent_secs |
number | yes | default 0 |
data |
text (JSON object) | yes | default {}; holds e.g. launched (LOOP-4) |
created, updated |
time | yes | |
finished |
time | no |
transitions
| Field | Type | Required | Notes |
|---|---|---|---|
id |
integer | yes | unique |
mission |
integer | yes | a missions.id |
at |
time | yes | |
from_stage, event, to_stage |
text | yes | |
why |
text | no |
commands
| Field | Type | Required | Notes |
|---|---|---|---|
id |
integer | yes | unique |
name |
text | yes | one of answer, retry, drop, pause, resume, start |
arg |
text | yes | default ''; the intent id |
text |
text | yes | default ''; the answer |
source |
text | yes | who inserted it (CLI or UI) |
created |
time | yes | |
applied_at |
time | no | |
result |
text | no | e.g. start: <reason> (LOOP-63) |
outbox
| Field | Type | Required | Notes |
|---|---|---|---|
key |
text | yes | unique; mission, wait and continued for waits (LOOP-14) |
mission |
integer | no | |
text |
text | yes | ≤ 4000 characters when sent (LOOP-16) |
created |
time | yes | |
sent_at |
time | no | |
tries |
integer | yes | default 0 |
next_try |
time | yes | default 0 |
repos
| Field | Type | Required | Notes |
|---|---|---|---|
name |
text | yes | unique; the short name intents use; a repo name, not intents |
github |
text | yes | owner/name, GitHub's spelling |
path |
text | yes | default '' = <home>/repos/<name> |
base |
text | yes | branch name |
workers |
integer | yes | 1–16, default 1 |
ahead |
integer | yes | 1–16, default 3 |
env |
text (JSON object of strings) | yes | default {} |
source |
text | yes | config, cli or ui |
added |
time | yes |
Out of scope
- Running on more than one machine.
- Commands sent through Telegram (an earlier version had them and no one used them: the owner acts on GitHub or in the UI).
Decisions
- The program's language was chosen for how fast it is to change, not how fast it runs (the owner's call, 2026-10-26): the program mostly waits on git, GitHub and the agent, and one language serves the loop and the UI.
- No build step for the program: the service runs the checkout, so an edit is live after a restart.
- One database file, five tables plus
meta(the owner's call;reposjoined later, LOOP-65): crash recovery needs a mission's step and its message written together; everything else is derived from GitHub and the intents repo. - Repos in the database, not the config (the owner's call, 2026-10-30): adding a repo takes only
owner/name, from the page or the CLI, and needs no restart. Config tables are imported once, so the database is the one record; a table imported once is never read again, so a removal sticks. - The running loop re-reads the
repostable every pass rather than being told:repo addfrom the CLI is another process, and one small read every 5 s is simpler than a signal. - Code drives, the agent judges: limits, restarts and polling need code; the agent runs only design, plan, build and review.
- Single process, one pass at a time: children run detached and are reaped by events, so polls keep their cadence during an hour-long build.
- PR events are level-triggered: re-read every minute, nothing queued to persist.
- Telegram only notifies: acting on GitHub keeps one record.
- Self-update exits and lets the service manager restart it: no in-process reload to get wrong.
- The reference build uses Claude Code as the coding agent, a systemd user unit as the service (with its journal for
logs),systemd-inhibitfor LOOP-11 and the GitHub CLI for GitHub calls. The agent capabilities the loop needs: a headless run with a prompt in a given folder, starting a session with a chosen id and resuming it, a cost report, and recognisable messages for usage limits, a missing session and a missing login. - This spec calls the health kind for the coding agent
agent(the reference build names it after its agent). - The source's file map and its unused, behaviour-free placeholder row were left out.
Metrics
Status: Draft · Prefix: MET
Purpose
How much each intent costs in money and time, kept where the owner already looks: the intent file, DONE.md and the UI.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| MET-10 | When an intent is done, its file and DONE.md show what it cost over all its attempts. At done the intent gets cost (the sum of its missions' cost_usd, 2 decimals), agent_time (the sum of their agent_secs, in seconds) and attempts (INT-5); DONE.md shows them (INT-6). |
given two missions of one intent with cost_usd 1.234 and 0.5 and agent_secs 600 and 300 → when the second reaches done → then the file has cost: 1.73, agent_time: 900, attempts: 2, and its DONE.md line shows the same |
| MET-28 | Every coding-agent run adds its cost and duration to its mission. From the run's result (PIP-24): its reported total cost in USD is added to the mission's cost_usd and its reported duration in milliseconds, divided by 1000, to agent_secs, in the same transaction that records the run's end. When a run resumes an earlier session, the reported total covers the whole session, so only the difference from that session's last recorded total (session_cost) is added. |
given a run reporting 0.40 USD and 90,000 ms → then cost_usd grows by 0.40 and agent_secs by 90; given a resumed session whose last recorded total was 0.40 and that now reports 0.65 → then cost_usd grows by 0.25; given the end recorded twice after a crash → then the cost is added once |
| MET-29 | The UI shows what the factory spent today and in the last 7 days, against an optional daily budget. Sums of run costs by local day, from a log of one {at, cost} entry per run kept for 30 days. When config daily_budget_usd is set and today's spend reaches it, picking new intents pauses until local midnight, shown under Problems (UI-60). |
given runs costing 3 USD today and 4 USD yesterday → then the UI shows 3 USD today and 7 USD for 7 days; given daily_budget_usd = 3 → then no new intent is picked until midnight and Problems names the budget |
| MET-30 | The intent page shows how long each step took, from the mission's moves. Durations are the gaps between consecutive recorded transitions of a mission (LOOP-3), grouped by stage; time spent waiting on the owner is shown apart from the factory's own time. | given transitions to design at 10:00, plan at 10:20, a wait on the owner at 10:50 and build at 12:50 → then the page shows design 20 min, plan 30 min and 2 h waiting on the owner, separately |
Data model
| Entity | Field | Type | Required | Limits / meaning |
|---|---|---|---|---|
| Mission | cost_usd |
decimal USD | yes, starts 0 | sum of its runs' costs (MET-28) |
| Mission | agent_secs |
number, seconds | yes, starts 0 | sum of its runs' durations |
| Mission | session_cost |
decimal USD | no | last recorded total of the agent session being resumed |
| Run spend entry | at |
timestamp | yes | when the run ended |
| Run spend entry | cost |
decimal USD | yes | the cost added for that run; entries older than 30 days are dropped |
| Config | daily_budget_usd |
decimal USD | no (none = no budget) | > 0 |
Out of scope
- Exporting metrics to an external monitoring system (OTLP, dashboards), the coding agent's own telemetry, and eval pass rates.
- Run tables and periodic reports.
- Costs of runs outside a mission, such as the feature backfill (INT-39).
Decisions
- Keep the three numbers the owner asks about (cost, time, spend per day) next to the work, instead of exporting dozens of metrics no one reads; an earlier version did that and it went unused.
- Costs come from the coding agent's own report of each run, so the factory never estimates prices.
Pipeline
Status: Draft · Prefix: PIP
Purpose
What happens to an intent from "picked" to "merged". One worker session of the coding agent designs the spec change, plans the work and builds it; the factory's own code runs the checks, proves the tests, watches CI, gets an independent review and risk assessment, and decides the merge. The owner sees and steers it through three pull requests (spec, plan, code) using ordinary GitHub actions, and everything the factory writes for a person reads like a colleague wrote it.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PIP-1 | The worker builds the approved plan in the mission's checkout: failing tests first, then the code, then the docs, until the repo's checks pass. It resumes the mission's worker session (PIP-13) on the intent's model, after the base branch's setup block (REPO-2) ran in the checkout. The prompt (the worker agent, PIP-70) gives plan.md, the intent file with its ## Answers, the spec IDs, the run set (REPO-9), the access granted (AUT-14) and the writing guide (PIP-39), and tells the worker to: write a failing test for each spec row's Verified by (using its test titles exactly) and each Proposed outcome bullet, as far as the repo allows; implement; run each check command itself from the repo root until all pass; update the docs the change affects; switch the spec rows it built from Draft to Live and keep every other line, changing a row's text only when the build proves it wrong (saying why in notes); never commit, push or touch .git; when a protected path is refused or a domain can't be reached, finish everything else first, then return access; when it can't go on without the owner, return blocked with one question. Result (build schema, Data model): status done | blocked (with question, PIP-41) | access (what and why, AUT-15); pr (the code PR's text, PIP-48); plan_changed (Markdown, when it departed from the plan, with why); lower_a, lower_why. The factory then stages every change in the checkout, commits it as the bot with the design's Conventional Commit line as the message, and goes to verify (PIP-23). No diff → waiting: no changes. A repair round (not a conflict round) that adds nothing to the branch and carries no words from the owner → also waiting: no changes, why The repair round changed nothing. plus the worker's notes, so the same red check never runs again unchanged; with the owner's words it goes on, and after a CI repair that head's red is rerun once more (PIP-18). Timeout, budget used up, or no valid result → exactly one continue with the prompt Continue where you stopped. and the step's full time budget again; failing again → the matching wait. |
given a plan and an intent with an answer, when build runs, then the prompt contains the plan, the answer and every run-set command, and the new commit's author is the bot; given a worker that changes nothing, then waiting: no changes; given a repair round with no diff and no owner words, then waiting: no changes with why The repair round changed nothing.; given a run that times out twice, then one continue with Continue where you stopped. and then the wait |
| PIP-2 | After green checks and a proof, the factory pushes the branch and opens the code PR; later rounds only push. It pushes towercab/<id>-<slug> from its own clone of the repo (fetched from the checkout by path). The first time it opens the PR ready for review (not a draft), title = the design's Conventional Commit line (PIP-111), body = PIP-48; the mission's pr, head and pushed_at are recorded before the side effect (LOOP-6). A push refused other than as non-fast-forward, or refused 3 times → waiting: answer the question with the reason (a non-fast-forward refusal: PIP-85; a branch left over from before the first push: PIP-86). Never a force-push: commits someone else (the owner) pushed to the PR branch are merged into the checkout at the start of each later build round and before each push; a conflict there is a conflict round. Such a PR is marked as having someone else's commits, which sends it to the owner at the gate (PIP-7). |
given a first green build, when publish runs, then one non-draft PR exists with the design's commit line as title; given a second round, then no new PR is opened and the branch moves; given an owner commit on the PR branch, when the next round pushes, then that commit is an ancestor of the new head |
| PIP-3 | The factory waits for the code PR's checks and sorts them into pass, fail and pending in its own code. Every ci_poll seconds (default 30) it reads the PR's headRefOid, mergeable and statusCheckRollup. A check run COMPLETED with SUCCESS, NEUTRAL or SKIPPED passes; FAILURE, TIMED_OUT, ACTION_REQUIRED, STARTUP_FAILURE, or a commit status FAILURE/ERROR fails; CANCELLED cancels; anything else is pending. Within ci_grace seconds (default 120) of a push, checks started before that push are ignored. No checks at all after ci_grace → green, noted no CI on this repo. Pending past ci_wait seconds (default 1800) → waiting: CI stuck. Red → a repair round in build with the failing jobs' log tails, at most 2 (counter ci_repairs), then waiting: CI red. |
given checks SUCCESS, SKIPPED and TIMED_OUT, then the result is red; given no checks 121 s after the push, then green with no CI on this repo; given CI red three times, then two repair rounds and then waiting: CI red |
| PIP-4 | A separate, read-only run of the coding agent reviews every code PR against the intent, the plan and the repo's REVIEW.md, and posts what it found on the PR. It runs after green CI and again on each new head; a review is reused while the diff's patch identity (git patch-id) is unchanged. Model: the intent's review field, else the configured review model ([agent].review_model), else one tier above the worker's model (the top tier stays). It is given the intent, spec IDs, plan.md, the PR diff (it reads it with a git diff command in the checkout), the CI result, REVIEW.md and the screenshots (PIP-49). The review agent checks, in order: each Proposed outcome bullet (met, and how it knows); each pass in REVIEW.md (REPO-4), reporting findings as important or nit as that file defines them, at file and line, leaving out what it says not to report; tests weakened, deleted or skipped; whether the plan was followed and any departure explained; whether the change is two-way or one-way (data, migrations, public interfaces) and which REVIEW.md red lines it touches. Its summary opens with the verdict in one sentence, then what is good, then what needs attention, most important first. The verdict is posted as a Review stage comment (PIP-42). A review that doesn't approve goes back to build first (PIP-98); then the risk agent assesses the diff (PIP-96). |
given a green PR, then one review run and one Review comment; given a new head whose patch identity equals the reviewed one, then no new review run; given no review field and no configured review model, then the review runs one tier above the worker |
| PIP-5 | The review returns a structured verdict, and the factory's code decides from it whether the PR is approved. Schema review (Data model): approved, one verdict per Proposed outcome bullet (met, why), tests_weakened (value, where), plan_followed (value, where), findings (severity important | nit, pass, file, line, what), door (one-way | two-way), red_lines (touched, with file), summary (Markdown, for the owner). Approved = approved ∧ every outcome met ∧ no weakened tests ∧ plan followed ∧ no important finding. Nits are capped as REVIEW.md says. |
given approved: true and one outcome with met: false, then not approved; given approved: true, all outcomes met, tests_weakened.value: false, plan_followed.value: true and only nits, then approved |
| PIP-7 | After the review, the factory's code decides whether it merges the code PR itself or the owner does. It takes the effective autonomy a and the caps from AUT-8 and AUT-13. First match wins → approval:merge (waiting: review and merge PR, with a plain why per PIP-110): CI not green; review not approved (or didn't finish); access beyond the plan at a4 (AUT-15); proof missing (PIP-30); effective a ≤ 3; a risk that waits at this a (AUT-18; the risk of the reviewed diff; no assessment → AUT-19); someone else's commits on the PR (PIP-2); the build departed from the approved plan, below a5 (PIP-28); in a repo with a live block, no e2e test for the change (SHIP-8). Else → merge (PIP-22). The PR body's Checks and Risk sections are updated first (PIP-48, PIP-99). Before any of it, while another change in the same repo ships, the gate holds (SHIP-14). |
given a4, green CI, approved review, proof present and low risk, then merge; given the same at a3, then approval:merge; given a5 and an owner commit on the PR, then approval:merge with why naming the commits; given a5 and a plan change, then merge |
| PIP-12 | The factory's own repository runs its checks on every PR and every push to main. Its CI workflow has one job that installs dependencies from the lockfile (with a dependency cache) and runs the repo's single check command (lint, typecheck, all tests), named after the runtime and what it runs (<runtime>: lint, typecheck, tests), and one job that installs a headless browser, runs the UI screenshot tests and uploads the screenshots as an artifact (<browser tool>: UI screenshots). Every job sets name:. |
manual: the factory repo's CI workflow has both jobs, each with a name:, triggered on pull_request and pushes to main |
| PIP-13 | Design, plan, build and every repair continue one coding-agent session per mission, so the worker keeps its context. The session id is chosen at pick (LOOP-5, LOOP-7); the design run starts a session with that id, and every later worker run resumes it from the same checkout. A repair's prompt is short: what failed, and the findings or the log tail, fenced as data. Review and risk are always fresh sessions. | given a mission, when design, plan, build and one repair run, then all four use the same session id and the review uses a different one |
| PIP-16 | Messages to the owner come from fixed templates in the factory's code, never from a model. There is one template per wait (LOOP-14 Waits), and each text is pinned by a check. | given every wait key listed in LOOP-14, then each has a non-empty template and none calls a model |
| PIP-18 | A stale check never counts, and a first red or cancelled GitHub Actions run is rerun once before it counts. Checks count only when the headRefOid returned in the same read equals the mission's recorded head. The first red or cancel on a head → rerun that Actions run's failed jobs once (run id from the check's detailsUrl), with no repair counted; a second cancel counts as red; a check that isn't an Actions run is never rerun. |
given a green rollup whose headRefOid differs from head, then still pending; given a first red Actions run on a head, then one rerun of its failed jobs and ci_repairs unchanged |
| PIP-21 | Every minute the factory reads the intent's PRs: their state, the owner's reviews and comments, and conflicts. For each of spec_pr, plan_pr and pr that is open, and for the current PR (the newest of the three) even when merged, it reads state, mergedAt, mergeCommit, headRefOid, mergeable, reviews, comments and body, plus every page of the PR's inline review comments (100 per page, read as a stream of JSON arrays). The events these give: PIP-43. |
given an open PR with 150 inline review comments, then all 150 are read; given a merged current PR, then it is still read |
| PIP-22 | The factory merges in its own code, and only on the head that passed the gate. It reads the PR's headRefOid and mergeStateStatus: head ≠ the gated head → back to ci. CLEAN or HAS_HOOKS → squash merge, accepted only if the head is still the gated head. BEHIND → GitHub's update-branch call with expected_head_sha=<head> → ci. DIRTY → a conflict round in build. BLOCKED or UNSTABLE → read again after ci_poll (the third time → approval:merge). Refused otherwise → waiting: merge failed. While the factory is paused, a mission entering merge holds until resume. |
given CLEAN on the gated head, then one squash merge pinned to that head; given BEHIND, then one update-branch call and the mission in ci; given DIRTY, then a conflict round in build; given BLOCKED three times, then approval:merge |
| PIP-23 | After each build or repair, the factory runs the repo's setup and checks in a sandbox and sends failures back to the worker. The base branch's setup block runs first, with network and the repo cache (LOOP-34) writable; non-zero exit → once more after 60 s, then red. The run set (REPO-9) then runs in order without network, each command bounded by check_timeout (default 10 min). Logs: runs/<id>/verify-<n>.log, with $ <command> before each command's output (under LOOP-39 they move to runs/<id>/verify/check-<n>.log). Red → a repair round in build with the log tail, at most 2 for verify and proof together (counter repairs), then waiting: checks red. A log that shows a missing tool or a sandbox failure is a fault (LOOP-10), not a repair; a log that shows the checks could not write to the repo cache is a question for the owner (PIP-95), not a repair. Sandbox: its own PID, IPC and UTS namespaces and its own session, killed with its parent; the root filesystem read-only; a fresh /dev and /proc; empty temporary /run, home and /tmp; the toolchain directories read-only with credential files masked; the repo cache (read-only for checks, PIP-95); the checkout writable but its .git read-only; the working directory is the checkout; each line runs through sh -c; the network is cut except where a row says otherwise. |
given setup that fails once and then passes, then the checks run; given a red check three times, then two repair rounds and waiting: checks red; given a check that writes to .git, then it fails; given a check that opens a network connection, then it fails |
| PIP-24 | Every coding-agent run is headless, sandboxed, and gets only the tools and access its job needs. Capabilities used: a non-interactive run with the prompt on standard input and a JSON result; no permission prompts (anything not allowed is denied); no external tool servers; no settings read from the user, the project or the target repo; an appended system prompt file (the base branch's CLAUDE.md + the writing guide, PIP-39); a settings file; a model; the tool list; allowed and disallowed permission rules; a new session with a given id or resume of one; a budget cap in USD; a JSON schema the result must match. Per-run system and settings files are written to the mission's run files (the stage's folder under LOOP-39). Environment: HOME, PATH, LANG, the repo cache's variables (LOOP-34) and [agent].env; never a GitHub token. Settings: the agent's shell sandbox enabled, failing if unavailable, with no unsandboxed commands; reads denied on the secret paths; writes denied on <checkout>/.git and on protected paths not granted; writes allowed on the repo cache; network allowed only to the granted domains, with web fetch allowed per granted domain (any domain at a5); the guard hook for workers (PIP-29). Secret paths: ~/.ssh, ~/.config/gh, ~/.aws, ~/.gnupg, ~/.config/towercab, the coding agent's own credentials file, ~/.netrc, ~/.git-credentials, ~/.config/sops, ~/.config/restic, ~/.config/age, ~/.password-store, ~/.docker, ~/.kube, ~/.npmrc, ~/.pypirc, ~/.cargo/credentials*, ~/.config/rclone; and the factory's home outside this mission (other checkouts, run files and caches, the clones, the intents checkout, the database and the log). Tools per agent: the Agents table. Denied for every run: shell commands starting with git push, git remote, git config, gh, ssh or sudo, and reading any secret path. A denied push or secret read fails the step. |
given a worker run, then its environment has no GitHub token and its settings deny reading ~/.ssh; given a run that tries git push, then the step fails; given a run that reads another mission's checkout, then the read is denied; given a5, then web fetch is allowed for any domain |
| PIP-25 | The worker's first run turns the intent into spec changes, and the factory opens them as the spec PR. The design agent is given the intent file, the repo's specs/, CLAUDE.md and the writing guide; tools read, find, search, edit and write, allowed to edit specs/** only. It is told to read CLAUDE.md, specs/README.md when present and the spec files the intent touches, plus enough code to know what exists; to follow the repo's spec format (one plain sentence, then the technical detail and the test titles that verify it), starting a new area from specs/_template.md when there is one; never to reuse an ID (a dropped row keeps its heading and says why it was removed); and to use only the IDs the intent reserves (PIP-80). Result (design schema): title, subject, brief, type, scope, breaking, spec_ids, concerns (what, why), open_questions (one per bullet of the intent's ## Open questions, in order, each answered or null to leave it to the owner), pr (the spec PR's text: two or three sentences on what will behave differently and why, then each requirement by ID with one plain line, then the concerns). Rows added here are Draft: kept out of the file's Status line (a bare Live becomes Live: <prefix>-<first>..<last built>, an existing Live: … list stays without the new IDs, nothing else is written on that line), so the repo's spec check stays green. The factory commits docs(specs): <subject> on towercab/<id>-<slug>-spec, pushes, and opens the spec PR with the same title. Once its checks pass, the risk agent and second reviewer read it (PIP-96, PIP-97), then the spec gate (AUT-11): the owner (approval:spec) or the factory (PIP-50). No behaviour change (spec_ids empty and no diff) → no spec PR; on to plan. The owner's review comments on the spec PR → a design revision in the same session, a new commit on the same PR; red checks on the spec PR start one too (PIP-82), and every revision sees their logs (PIP-83). |
given a design that edits README.md, then the edit is denied; given a design that edits specs/a.md, then a spec PR titled docs(specs): <subject> opens on towercab/<id>-<slug>-spec; given empty spec_ids and no diff, then no spec PR and the mission is in plan; given an owner comment on the spec PR, then a new commit on the same PR |
| PIP-28 | The worker plans the build, read-only, and the factory opens the plan as a PR on the intents repo. The plan agent resumes the session with tools read, find, search and read-only git (log, diff, show) and ls; it is told to read the code the change touches, the specs and CLAUDE.md, to prefer the simplest change that meets every requirement and Proposed outcome bullet, and to ask only for the access it expects to use (network domains such as package registries or APIs; protected paths such as CI workflows, CLAUDE.md, AGENTS.md, REVIEW.md, TOWERCAB.md, .claude/ and paths TOWERCAB.md lists), each with a reason. Result (plan schema): brief, plan (Markdown, PIP-46), access (domains and paths, each with why) and e2e. The factory writes intents/<id>-<slug>/plan.md (PIP-46) on branch plan/<id>-<slug> of the intents repo, pushes, and opens the plan PR (title docs(plan): <subject>; body: the approach in a few sentences and the access asked for). Then the risk agent reads it (PIP-96) and the plan gate (AUT-12) decides: the owner (approval:plan) or the factory (PIP-51). The owner's comments on the plan PR → a plan revision, a new commit on the same PR. Merged → the grants are read from the merged plan.md (PIP-47) and the build starts. A build that departs from the plan returns plan_changed; the factory commits it to plan.md on main of the intents repo (message towercab: plan changed <id>), keeping the merged Access section, and the gate sends the PR to the owner below a5. A revised plan PR keeps the boxes the owner unticked. |
given a plan result, then a PR titled docs(plan): <subject> opens on the intents repo from plan/<id>-<slug>; given an owner comment, then a new commit on that PR; given a merged plan with one box unticked, then only the ticked access is granted |
| PIP-29 | A pre-tool hook stops the worker from writing paths it hasn't been granted, and tells it how to ask. Every worker run's settings add a hook before each edit, write or notebook-edit that runs towercab hook guard <runs>/guard.json ({checkout, protected, granted, locked}), which exits 2 with a reason for: a path outside the checkout; a protected path not granted (REPO-8, AUT-14); a locked test (PIP-31). The reason for a protected path ends if you need it, finish what you can and return status access with this path and why. It fails closed: unreadable rules, no path, or any error → exit 2. Publish also refuses a diff that touches a protected path that isn't granted. |
given protected CLAUDE.md not granted, when the worker edits it, then exit 2 with the access sentence; given it granted, then exit 0; given /etc/passwd, then exit 2; given an unreadable guard.json, then exit 2 |
| PIP-30 | The factory checks that the PR's tests fail without the change, so the tests prove something. After green verify, when the design changed spec rows or its type is fix: a checkout of origin/<base> with the PR's changed test files laid over it (test globs: TOWERCAB.md tests, default tests/**, test/**, **/__tests__/**, **/*_test.*, **/test_*.*, **/*.test.*, **/*.spec.*; e2e tests under a live block's suites are left out, SHIP-8) runs setup, then the run set (from the PR's CLAUDE.md when PIP-54 applies). Red = proven. Green → a repair round asking for a test that fails without the change, naming the commands the proof ran on base and saying that a change to how tests run isn't used there, so a worker that can't wire its test into them asks the owner; after the repairs → waiting: tests don't prove the change. After the owner answers, a proof that still fails to prove → publish with unproven set (the gate then sends the PR to the owner, PIP-7). |
given a new test that fails on base, then proven; given a test that passes on base, then a repair round naming the commands and, after 2, waiting: tests don't prove the change; given an owner answer and still green on base, then the PR is published and the gate sends it to the owner |
| PIP-31 | During repair rounds the worker can't change tests it already pushed, so it can't make a red check green by weakening a test. Locked = test files (PIP-30's globs) at the head of the last non-repair round; the guard blocks them and publish refuses a diff that changes or deletes them. New test files are fine. A locked test that reads at the new head exactly as on the base branch came in with a merge of the base (a conflict round), so publish lets it through. | given a pushed test, when a repair round edits it, then the edit is blocked and publish refuses; given a conflict round that merges a base change to that test, then publish accepts it |
| PIP-39 | Every coding-agent run gets one writing guide, so everything the factory writes for a person reads clearly. The guide is appended to every run's system file (PIP-24) and covers: who the reader is (the owner, who knows the project but wasn't there, often on a phone; or a reviewer); two kinds of text (PIP-106); the outcome or answer first, so the first two sentences tell what this is and whether it needs the reader; full sentences, active voice, present tense; plain words before identifiers; one idea per short paragraph of two to four sentences; specifics (numbers, files, limits) over adjectives; saying what was not done or checked; no filler or self-praise; headings and lists only where they help, never nested more than one level; no internal jargon (step names, field names, schema keys, status values) in text for a person, while the repo's own identifiers and spec IDs are fine; questions with context, options and a recommendation (PIP-41); the code PR sections (PIP-48) without test output or the diff; spec requirements in the repo's format; and a final read as the owner would. | given any run of any agent, then its system file contains the writing guide |
| PIP-40 | The coding agent writes the text a person reads as Markdown in its own words; the JSON result carries only what code needs. Text for people: the spec, plan and code PR texts, questions and the review summary. These are Markdown strings in the result, written by the run that did the work, on its own model. The factory's code adds only facts it owns (links, check results, the footer) and never stitches sentences from fields. | given a worker pr text, then the code PR body contains it unchanged and no sentence built from schema fields |
| PIP-41 | When the worker needs the owner, it asks one question with its context, the options and its recommendation. question: context (what it was doing and why it is stuck, two or three sentences), question (one sentence), options (each option with what then follows: the work, the risk, what it rules out), recommendation (and why). It asks only what it cannot decide from the repo, the intent and its specs, so the owner can answer "go with your recommendation" without opening a file. The factory renders it as a Question stage comment (PIP-42) on the intent's current PR, or, with no PR yet, in the intent file's ## Towercab section (INT-15); waiting: answer the question. |
given a blocked result while a code PR is open, then a Question comment on that PR with context, options and recommendation; given a blocked design with no PR, then the question is in the intent's ## Towercab section |
| PIP-42 | Every comment the factory posts on a PR says which step wrote it, and only informs. A visible header **<Step>** · round <n> (Step one of Design, Plan, Build, Verify, Proof, CI, Review, Gate, Question, Access) and a hidden marker <!-- towercab:<step>:<n> --> on the first line. A comment for the same step and round is edited, not added again. The factory's own comments never cause an event. |
given a Review comment for round 2, when round 2's review is posted again, then the same comment is edited and the PR has one Review round 2 comment; given that comment, then no event is raised |
| PIP-43 | What the owner does on a PR moves the intent: merging approves, closing drops, a review or comment steers. Merged spec, plan or code PR → spec_merged, plan_merged, code_merged. Closed without merge → closed (the mission stops and the intent is dropped, PRI-9). A review with Approve on a PR waiting on the owner → the factory merges it, as the owner's approval. A review with Request changes, or a comment → feedback with its text (or answered while blocked). A comment that is exactly /retry → retry_fresh. Edited checkboxes in an open plan PR are read when it merges. |
given the owner merges the plan PR, then plan_merged; given the owner closes the code PR, then closed and the intent dropped; given an Approve review on a PR in approval:merge, then the factory merges it; given the comment /retry, then retry_fresh |
| PIP-44 | Only the owner's reviews and comments move an intent; anyone else's are ignored. The author login must equal [github].me (case-insensitive), with a non-empty body or a review state. Comments by the factory's App bot or anyone else never raise an event. Comments older than the mission's feedback_seen are skipped; feedback_seen moves forward when an event is taken. |
given a comment by another user, then no event; given an owner comment written ME against login me, then feedback; given an owner comment older than feedback_seen, then no event |
| PIP-45 | Two short comment commands cover what a GitHub action can't express. /retry (alone in a comment by the owner on any of the intent's PRs): a fresh attempt. /release: reserved for a later release step; today it gets a reply comment saying it is parked. |
given the owner comments /release, then a reply comment says it is parked and the mission does not move |
| PIP-46 | The plan explains the approach before the steps, so the owner can judge it in a minute, and is complete enough for another engineer to build it without the session's context. plan.md: # Plan: <title>, a line with the intent and spec IDs, then the worker's Markdown with exactly these sections: ## Approach (what will be built and why this way, naming a real rejected alternative), ## Files that change (PIP-93), ## Steps (numbered, in build order; failing tests first, one per requirement's test titles and per Proposed outcome bullet, then the code, then the docs), ## Risks (what could break and the guard), ## How to check it (commands, pages or requests a reviewer uses); then the factory's ## Access (PIP-47). |
given a plan result, then plan.md has those headings in that order, with ## Access last |
| PIP-47 | The plan lists the access the worker expects to need as checkboxes, and the merged boxes are what it gets. ## Access lines: - [x] network: <domain> (<why>) and - [x] path: <glob> (<why>), ticked by default; plus a sentence on what each autonomy level grants (AUT-14). Parsed from the merged plan.md: ticked lines are granted, unticked ones are not; no section → nothing beyond the defaults. |
given an access list, when written and parsed back, then the same domains and globs; given - [ ] network: example.com (docs) in the merged plan, then example.com is not granted |
| PIP-48 | The code PR opens with what it does, why, and what to check before merging, in plain words; the technical detail follows. Body: ## In plain words and the risk first (PIP-108); then the worker's pr Markdown with sections ## What this does (two or three sentences on the effect), ## Why (the problem it solves), ## What to check before merging (the few things worth a human look, or Nothing special.), ## Changes (short bullets on behaviour, not files); then the factory's ## Checks (one line each for verify, proof, CI and review, in plain words), ## Access beyond the plan (when any), links to the spec PR and the plan PR, the spec IDs, the review's details in a <details> block, and the footer <sub>Built by <factory name> · intent · $<cost> · <h>h<mm>m</sub>. The body is updated at the gate. Without a worker text: the intent's title and problem under ## What this does. |
given a worker pr text, then the body has ## What this does, ## Checks with four lines and the footer with cost and time; given no worker text, then ## What this does holds the intent's title and problem |
| PIP-49 | For a repo with a screenshots command, the review sees the UI as it now looks. After green verify, the screenshots block (REPO-10) runs in the sandbox with network to localhost only; its PNG files are copied to runs/<id>/shots-<n>/ and their paths go into the review prompt (the review reads them as images). A failing command is logged and noted in the review prompt; it never blocks. |
given a screenshots command that writes home.png, then the review prompt names its copied path; given a failing command, then the review still runs and its prompt says the screenshots failed |
| PIP-50 | When the owner doesn't need to approve the spec, the factory merges the spec PR once its checks pass. Spec gate open (AUT-11) → wait for the spec PR's CI on the head the factory pushed (PIP-3 rules, no repairs; a red that survives the rerun and one revision (PIP-82), a conflict, or someone else's push → waiting: approve spec with the reason), then squash merge pinned to that head. The same checks come first when the gate is closed (PIP-82). |
given an open spec gate and green checks on the pushed head, then one squash merge pinned to it; given a push by someone else, then waiting: approve spec |
| PIP-51 | At a4 and a5, when the plan's risk lets it go ahead, the factory merges its own plan PR and starts building. Plan gate open (AUT-12, AUT-18) → squash merge on the intents repo as the App, why a<n>, <level> risk: the factory approved its own plan. |
given a5 and a low-risk plan, then the plan PR is merged by the App and the mission is in build; given a3, then approval:plan |
| PIP-52 | Each PR links to the intent and to the intent's other PRs, so the owner can follow a change from any of them. Spec and plan PR bodies end with links to the intent file and the PRs before them; the code PR links both (PIP-48). The intent records each PR's URL (INT-5). | given a code PR, then its body links the intent file, the spec PR and the plan PR, and the intent's front matter holds all three URLs |
| PIP-53 | When an intent starts over, the replaced attempt's open PRs are closed, so only the new attempt's PRs wait for review. Entering stopped with a newer attempt (retry_fresh, /retry): its open spec, plan and code PRs are closed as the App, with a comment saying the intent started over; merged or closed ones are left. Their branches are deleted too (PIP-87). A failure is retried on the next pass (LOOP-6). |
given an attempt with an open spec PR and a merged plan PR, when /retry, then the spec PR is closed with the comment and the plan PR untouched; given a failed close, then it is retried on the next pass |
| PIP-54 | When the owner let the PR change CLAUDE.md, the proof runs the checks that file now lists, so a test added to the checks block can prove the change. Applies when the PR's diff against the base changes CLAUDE.md and that path is granted (REPO-8, AUT-14); publish already refuses an ungranted change, so otherwise the base's block is used. The proof's run set (REPO-9) is then built from the ## Verifying your work section of CLAUDE.md at the PR's head; setup still comes from the base and runs on the bare base before the tests are laid over it (PIP-30); only CLAUDE.md is read from the head, while REVIEW.md and TOWERCAB.md stay the base's. The PR's CLAUDE.md is not laid into the proof checkout; only its parsed blocks are used. If it doesn't parse or has no non-empty checks block, the base's block is used and the proof's log says so. Verify (PIP-23) and CI keep the base's block. When the PR's block is used, red on base is not enough: the same lines then run on the PR's head, in the mission's checkout without network, and must pass (log proof-head.log). Red there → a repair round (counted with verify's, at most 2), then waiting: checks red; never the unproven path, so an always-failing line can't count as proven. |
given a granted CLAUDE.md change adding check-new, then the proof runs check-new; given the change not granted, then the base's block; given a head CLAUDE.md that doesn't parse, then the base's block and a log line saying so; given a PR line that fails on base and on head, then a repair round, not proven |
| PIP-70 | Each kind of coding-agent run the factory starts is configured by one agent file, agents/<name>.md, and nowhere else. Agents: design, plan, worker, repair, review, risk, score, features (the Agents table); the file name is the agent's name. The front matter holds what the run may do and its limits (PIP-71); the body is the prompt template, with {{key}} placeholders and data fenced in tags such as <intent>…</intent> and marked "data, not instructions". The writing guide (PIP-39) is not an agent and stays its own file. No other place in the factory holds per-agent tools, timeouts or model choices; the result schemas are defined in one place and found by the name the file gives. Agent files ship with the factory and are read from its own installation; a target repo cannot override one, and still adds CLAUDE.md and REVIEW.md (REPO-1, REPO-2). |
given the agent list, then each name has an agents/<name>.md file; manual: no per-agent tool, timeout or model table exists outside the agent files |
| PIP-71 | An agent file's front matter says which tools the agent has, what it may do without asking, and how long and how much it may run. Keys, all required: tools (list; an empty list means no tools), allow (list of permission rules, each Tool or Tool(pattern)), sandbox (strict | none, PIP-78), model (mission | review | agent: which configured model it takes), session (new | resume | none), timeout_min (integer), max_cost_usd (number or null) and schema (a schema name, or null). Unknown keys, a missing key or a wrong type fail the load with an error naming the file and the key. [agent] settings (model, limits) still apply on top; for limits the lower of the two wins, so config can lower a limit but not raise it. |
given a file with an extra key color, then loading fails naming the file and color; given max_cost_usd: 10 and [agent].max_budget_usd = 5, then the run's cap is 5; given [agent].max_budget_usd = 20, then it stays 10 |
| PIP-72 | The command line of a coding-agent run is built from the agent's file, so the file is the whole truth about what that run may do. The run takes its tools, allowed rules, session mode, budget and schema from the agent file, and its timeout from timeout_min. The disallowed list (PIP-24) is added to every agent and is not in the file. Stage handlers start a run by agent name; none passes tools, permissions or a timeout of its own. |
given the review file, then the run's tools, allowed rules, budget 3 and schema review match it and the disallowed list is present |
| PIP-73 | A check reads every agent file and fails when one is wrong, so the files and the code cannot drift apart. For each file: the front matter parses (PIP-71); every tools entry is a tool name the coding agent knows, and every allow rule parses and names a tool the agent has; the schema exists; the prompt renders with the variables its handler provides, and a {{key}} outside that set fails. Every name in the agent list (PIP-70) has a file and every file is in the list. No allow rule matches a command the disallowed list (PIP-24) forbids. |
given an agent with allow: [Bash(git push:*)], then the check fails; given a prompt with {{unknown}}, then the check fails; given an extra file agents/x.md not in the list, then the check fails |
| PIP-74 | The repair run and the issue scorer each have their own agent file, so their role is written down instead of borrowed from the worker. repair: the prompt of a repair round (red checks, a merge conflict, a review fix, an owner's answer), the build result schema and session: resume, so it continues the mission's session (PIP-13); its tools are the worker's (no gain, PIP-77). It tells the worker to fix it in the same session keeping to the plan, to change code not pushed tests (new tests are fine), to rerun the checks until they pass, and to return the same result with an updated pr if the change grew and a fresh brief. score: the issue scorer (INT-22), no tools, session: none, timeout_min: 5. The PR text stays part of the build result, written by the worker in a section of its prompt rather than a separate run. |
given a repair round, then it resumes the mission's session with the worker's tools; given the score file, then tools is empty and session is none |
| PIP-75 | The change that moved agent settings into files carries a written audit of every coding-agent run the factory starts, so the owner can see what changed and what is still open. For each agent: its prompt, tools, permissions, schema, model, session, time and cost limit, before and after. Gaps are listed (a run without its own prompt, a permission broader than its stage needs, a schema field no code reads, a limit that lived only in code); each is fixed in the same change or named as left open, with the reason. | manual: that code PR's description lists the audit, one entry per agent, and every gap is fixed or named open |
| PIP-76 | This spec lists every agent in one table, with what it is for and what it may touch. The Agents table has columns Agent, Purpose, May touch (tools and the paths or commands allowed), Session, Model, Limits; one row per agent in PIP-70's list. A check fails when an agent file has no row or a row has no file. | given an agent file agents/x.md with no row in the table, then the check fails |
| PIP-77 | Moving the settings into files makes no agent more powerful than before. For every agent, the tools and allow rules are a subset of the Agents table's; repair takes the worker's set or less. A narrowing is allowed and is named in the change that makes it. A check holds the table's values for each agent as a ceiling and fails when a file goes beyond it. |
given review with Edit added to its tools, then the check fails |
| PIP-78 | An agent file can only narrow what a run may do; the sandbox, the guard and the protected paths are built separately and the file cannot switch them off. sandbox is strict for every agent that has shell, edit or write; none is accepted only for an agent with no tools. The sandbox settings (PIP-24), the worker's guard (PIP-29) and the protected paths (REPO-8) are built from the mission in the factory's code, not from the file. |
given an agent with shell and sandbox: none, then loading fails; given score with no tools and sandbox: none, then it loads |
| PIP-79 | Reserved: this ID was set aside with the agent files and carries no behaviour. A builder implements nothing for it. | manual: nothing to check |
| PIP-80 | Each design is told which requirement IDs other unmerged spec PRs of the repo already add, and must not use them. Before a design or design revision runs, the factory lists the repo's open spec PRs (towercab/*-spec branches of other intents), reads each diff against main and collects the requirement IDs they add. The prompt gets them as data: IDs already taken by other open spec PRs: …. The design must pick IDs outside that list and outside main, and still only from the IDs the intent reserves. A PR that cannot be read is skipped and logged, not a failure. No other open spec PR → the list is empty and the prompt says so. |
given another open spec PR that adds PIP-200, then the design prompt lists PIP-200; given that PR's diff can't be read, then the design still runs and the skip is logged |
| PIP-81 | When the spec PR no longer merges into main, the design resolves the conflict in a revision, like a red check on the spec PR. While the spec waits for approval (AUT-11), a spec PR GitHub reports as conflicting (typically a Status line another spec PR changed) sends the mission back to design in the same session. The factory merges main into the branch, the design edits only specs/** to resolve the markers and keep both sides' rows, and the factory commits and pushes on the same PR. The prompt names the conflicted files and the IDs other open spec PRs add (PIP-80). At most 2 conflict revisions per spec PR; after that, waiting: spec conflict. A conflict outside specs/** is not edited: the same wait. |
given a conflicting spec PR, then one revision commit on the same PR with no markers left; given a third conflict, then waiting: spec conflict; given a conflict in README.md, then waiting: spec conflict without a revision |
| PIP-82 | When the checks on the spec PR stay red after the rerun, the design revises the spec once by itself, so the owner never has to review a red spec PR. Applies to every spec PR before it goes to the owner (approval:spec) or to the factory's merge (PIP-50). The factory reads the spec PR's checks as PIP-3 does, on the head it pushed, with the first-red rerun of PIP-18 first. A check still red after that rerun sends the mission back to design in the same session, as if the owner had commented; the revision is a new commit on the same PR (PIP-25), and its checks are read the same way. The prompt is PIP-83's; nothing is posted in the owner's name. At most 1 red revision per spec PR (counted separately from PIP-81's conflict revisions). Still red after it, or the revision changes nothing → waiting: approve spec, the reason naming the red checks. A red spec PR is not ready to merge: the factory does not merge it and the owner is not asked to approve it before the revision has run. A cancelled check is rerun once like a red one; still cancelled → waiting: approve spec naming it, without a revision. Checks pending past ci_wait → waiting: approve spec with that reason. No checks on the repo → nothing to revise. |
given a spec PR red after the rerun, then one design revision; given it red again, then waiting: approve spec naming the checks; given the spec gate closed and a red spec PR, then the revision runs before approval:spec |
| PIP-83 | Every design revision on a spec PR whose checks are red gets the tails of the failing jobs' logs in its prompt, because the design session has no shell to read them. Applies to a revision started by red checks (PIP-82), by the owner's comment (PIP-25) or by a conflict (PIP-81), whenever the spec PR's checks on its current head are red. The log tails are fetched as PIP-3 does for a code PR and passed as fenced data (PIP-39), each with its job's name. Green, pending or absent checks → no log section. A log that cannot be fetched is noted as unavailable; it does not stop the revision. A revision that started from a comment, finds nothing wrong in the spec but sees a red log is told to fix whatever in specs/** makes the check fail, or to say in concerns that the cause lies outside specs/**. |
given an owner comment and a red spec-lint job, then the revision prompt has spec-lint and its log tail; given green checks, then no log section; given a log that can't be fetched, then the prompt says unavailable and the revision runs |
| PIP-85 | When GitHub refuses a push because the branch already has commits the factory doesn't have, the mission asks the owner instead of counting a crash. A push rejected as non-fast-forward is a distinct error kind that the crash counter (LOOP-6) does not count; it counts as a refused push instead, and after 3 refusals → waiting: answer the question. The question follows the writing guide (PIP-39): it names the branch, says GitHub already has commits on it the factory doesn't, and offers: delete the branch on GitHub and reply, or start the intent over (Retry; the new attempt's branch has a new name). Never a force-push (PIP-2). The same goes for the spec branch's push. Each refusal is retried on the next pass; the third asks. |
given three non-fast-forward refusals, then waiting: answer the question naming the branch and the crash counter unchanged |
| PIP-86 | Before a mission's first push, the factory checks whether the branch already exists on GitHub and clears it away only when an earlier PR of the factory is all that is on it. Check: towercab/<id>-<slug> on GitHub and the PRs with that head, in all states. No branch → push. The branch exists, at least one PR on it was opened by the factory's App, and no open PR on it is anyone else's → close each open one with a comment saying the intent started over, delete the branch (PIP-89), then push; a branch whose App PRs are all closed is replaced the same way. The branch exists and an open PR on it was opened by anyone else, or there are commits on it and no PR of the App's at all → nothing is closed or deleted, waiting: answer the question; the ask names the branch and the foreign PR if any, and offers: delete the branch on GitHub and reply, or start the intent over (Retry). The factory never picks the next free name -<n> by itself, since the commits may be someone's work. A branch whose head is the commit about to be pushed (a push that finished before a crash) is not in the way. Not checked again once the first push has succeeded; later rounds only push (PIP-2). |
given a leftover branch with one closed App PR, then it is deleted and the push succeeds; given an open PR by another user on the branch, then nothing is deleted and the mission waits; given a branch with commits and no PR, then it waits; given the branch at the commit about to be pushed, then no wait |
| PIP-87 | When the factory itself closes a PR, it deletes that PR's branch on GitHub too. Applies to the closing in PIP-53 (/retry replacing an attempt: spec, plan and code PRs), to a mission stopped by drop or because its intent was removed (PRI-9; its open PRs are closed first, with a comment saying the intent was dropped), and to PIP-86. Merged PRs are not touched (PIP-89). Done on entering stopped, after the close, under PIP-89's guards. A branch that another open PR still uses as its head (a newer attempt) is kept. |
given /retry on an attempt with an open spec PR, then the PR is closed and towercab/<id>-<slug>-spec deleted; given drop, then the open PR is closed with the dropped comment and its branch deleted |
| PIP-88 | When a mission stops because the owner closed its PR, the factory deletes that PR's branch. The state machine moves the mission to stopped on the closed event (PIP-43); that move is the record, and the entry step of stopped then deletes the branches of the mission's closed PRs, under PIP-89's guards. |
given the owner closes the code PR, then the mission is stopped and its branch is deleted |
| PIP-89 | The factory deletes only branches it made for a PR it opened, and tries again if GitHub fails. A branch is deleted only if its name starts with towercab/ (or plan/ in the intents repo, where plan PRs' branches live), its PR was opened by the factory's App, and that PR is closed without merge. A branch with an open PR from anyone else, or outside those names, is never deleted; a branch already gone counts as done. The deletion is a side effect: the move to stopped (or, for PIP-86, the recorded step) is stored first, then GitHub's delete-ref call; a failure leaves the entry step due, so the next pass retries it (LOOP-6). Merged branches are left to the repo's GitHub setting delete_branch_on_merge. |
given a closed App PR on feature/x, then nothing is deleted; given a delete call that fails, then the next pass deletes it; given a merged PR, then its branch is not deleted by the factory |
| PIP-90 | The factory's own CI ends in one job, All required checks passed, that is green only when every other job is green or skipped on purpose; the repo's ruleset requires only that job. The workflow follows the owner's org template: triggers pull_request, push to main and workflow_dispatch; top-level permissions: {} with each job granting what it needs; every third-party action pinned by commit SHA (version in a trailing comment). Jobs: the shared checks job (PR title as Conventional Commit, secret scan, workflow lint, workflow security audit, dependency vulnerability scan) and the shared static-analysis job (the host's code scanning once the repo is public, a free static analyser while it is private); the repo's own two jobs (PIP-12), kept custom because the repo's check command is its definition of green and the shared job does not build the UI; and an aggregate job with name: All required checks passed that needs every other job, runs always, and fails when any needed job failed or was cancelled. The jobs need no manual step for the factory's own PRs (the browser installs in the job, tests use no network). The ruleset is applied by the owner's repo-settings tool only after this workflow is on main and the aggregate job has run there; it is not part of the code. A public repo can't call a private repo's workflows, so the shared ones are synced copies: checks.yml, sast.yml and release.yml in .github/workflows/ with the first line # Synced from <owner>/ci@<sha> <path>; edit there, not here., callers use ./.github/workflows/<name>.yml, and the shared CI repo's daily sync keeps them current by PR. |
manual: the workflow has one All required checks passed job needing all others, every action pinned by SHA, permissions: {} at the top, and the three synced files start with the synced-from line |
| PIP-91 | The factory's repo opens a release PR from its Conventional Commits, tags and publishes a release when it is merged, and gets dependency update PRs. A release workflow runs on pushes to main with a release config and a version manifest starting at the current package version (1.0.0), all from the org templates; its job calls the synced shared release workflow (PIP-90), with permissions: {} at the top and the grants on the job. The dependency-update config only extends the owner's shared config, which holds a 3-day minimum release age. Opening the release PR needs the GitHub repo setting that lets Actions create PRs, which the repo-settings tool sets. |
manual: the release config, manifest (1.0.0) and workflow exist and agree; the dependency-update config extends the shared one |
| PIP-92 | Each review finding says which pass found it, so the owner sees at a glance whether it is a bug, a security problem or a departure from the spec and plan. Each item of findings has pass (string): the pass's name as REVIEW.md ## Passes writes it, or one of the defaults Bugs, Security, Compliance (REPO-4); the review agent is asked for it. A name outside that list is kept as written, not refused. The Review comment (PIP-42) shows each finding as - <pass> · `<file>:<line>`: <what> under its severity's heading. Approval (PIP-5) and the nit cap do not change. |
given a finding {pass: "Security", file: "a.go", line: 3, what: "x"}, then the Review comment has - Security · `a.go:3`: x under the important heading; given pass: "Style" not in the list, then it is shown as written |
| PIP-93 | The plan lists the files the build will add, change or delete, so the owner sees how far the change reaches before approving it. plan.md (PIP-46) has ## Files that change between ## Approach and ## Steps: one bullet per path, `<path>` (new|changed|deleted): <what changes>, tests included; a glob may stand for many similar files (generated screenshots, fixtures). The plan agent is asked for the section and told the bar: complete enough for another engineer to build without the session's context. A build that touches a file the plan didn't list is not refused; the review's plan_followed (PIP-5) judges it. |
given a plan result, then ## Files that change sits between ## Approach and ## Steps with one bullet per path |
| PIP-94 | A review run stops at $3, as every other run has a limit, so a runaway review cannot spend without bound. The review agent file has max_cost_usd: 3; [agent].max_budget_usd can only lower it (PIP-71). A review that uses up its budget ends as any run that does (PIP-24, budget used up): the mission waits for the owner, with the reason. This narrows the review's limits and gains nothing (PIP-77). |
given the review file, then max_cost_usd is 3; given a review that hits the cap, then the mission waits with the reason |
| PIP-95 | When the checks fail only because they could not write to the repo's build cache, the factory explains what happened and asks the owner whether the checks may write there; it does not send the failure to the worker, because it is not a code problem. The checks, the proof, the screenshots and e2e run with the repo cache (LOOP-34) read-only, so they cannot change what setup and the worker built there; some tools can't run that way (for example a build tool that keeps its output and lock in the cache, such as CARGO_TARGET_DIR=<cache>/target making cargo clippy stop at once). Detection, in verify: a red checks log with a read-only error (Read-only file system, os error 30, Errno 30 or EROFS) that names a path under <home>/cache/<repo>/, on that line or the 3 lines before it; a read-only error on any other path stays a plain red. The mission waits allow check writes (stage blocked) and asks a question (PIP-41: on the current PR, else in the intent file). Its context names the command, the path, why the cache is read-only, and that the code is not at fault. Options: Allow (the factory remembers it for the repo and runs the checks again; later missions of the repo don't ask) and Don't allow (the mission waits checks red; the owner can make the checks write inside the checkout, for example a cache variable in [repos.<r>].env, then reply, or drop the intent). It recommends allowing, since setup and the worker already write there. An answer starting with allow, yes or ok (any case) allows; any other answer does not. The answer continues the mission at the stage that asked, not at build. An allowed repo has the stored flag checks-write:<repo> = 1; from then on its checks, proof, screenshots and e2e get the cache writable; deleting the flag takes the permission back. |
given a log line Read-only file system naming <home>/cache/r/target, then allow check writes and no repair; given the same error naming /usr/lib, then a repair round; given the answer Yes please, then the checks rerun with the cache writable and checks-write:r = 1; given no, then waiting: checks red |
| PIP-96 | Before each gate decides, a separate read-only agent that didn't write the change judges its risk (AUT-16). The risk agent: tools and permissions as the review's, session: new (never the mission's session), the review model, schema risk; its run files go to the folder of the stage it judges (design/, plan/, review/, as risk-<n>.*). Spec: in design, after the spec PR's checks are green or absent, given the spec diff (git diff <base>...HEAD -- specs), the design's concerns and the open questions it left to the owner. Plan: in plan, after the plan PR is opened, given the plan and the access it asks for. Code: in review, after a review that approves or has used its fix rounds (PIP-98), given the diff, the CI and proof results and the review's summary; the review's door and red lines feed the floor (AUT-17). A review that didn't finish gets no risk run (the gate holds anyway). The agent answers three questions in plain words: blast radius (small: one corner of the product or only the team; medium: a feature some users rely on; large: every user, their data, money, security, or systems others depend on; with who), door (one-way when undoing is hard or lossy: data changes, migrations, deletions, public interfaces or formats, messages sent to people; else two-way; with why), certainty (high when tests cover what changes and nothing depends on the environment; medium when some is untested or depends on config, data or other services; low when much can only be seen live; with why); then a level (low: small, two-way, well covered; high: a wrong move hurts many or can't be undone, or it can't be told if it works; else medium), which the factory may raise but never lower. Each assessment is kept with a key of what it judged (spec: the spec PR's head plus a hash of the owner's answers on it, PIP-97; plan: a hash of the plan and its access; code: the diff's patch identity) and reused while the key is unchanged; a new spec head, answer, plan or diff gets a new run. Its cost counts like the review's, on a session of its own (MET-28). |
given a green spec PR, then one risk run with the spec diff in its prompt; given the same plan and access twice, then one risk run; given a new code diff, then a new run; given a review that didn't finish, then no risk run |
| PIP-97 | At the spec gate the risk agent is also a second reviewer: when it finds the spec clear, with nothing the owner must decide, the spec moves on (subject to its risk); open points go to the owner. Its open_points (point, why) at the spec gate are the decisions the owner must make before the spec is built, taken from the design's concerns and open questions or found in the spec. Any → the spec gate waits (AUT-11), why The spec leaves <n> questions for you to settle before it is built. (one question for one; PIP-110), and the points are in the spec PR's body under ## Open points for you and on the card. None → the risk decides. At the plan and code gates the list is empty. The owner's answers on the spec PR (the feedback that starts a design revision, not the factory's own conflict or red-check revisions) are kept as the mission's specAnswers and given to the next spec risk run as decided, not to be raised again; they are part of the spec's risk key, so an answer gets a new check even when the revision changes nothing in the spec. |
given two open points, then approval:spec with why The spec leaves 2 questions for you to settle before it is built. and both points in the PR body; given the owner answers them and the next risk run returns none, then the spec moves on |
| PIP-98 | A code review that doesn't approve sends its findings back to the worker for up to 2 fix rounds before the change waits for the owner. Review not approved (PIP-5) and reviewFixes < 2 → event red from review to build (why the review didn't approve: fix round <n> of 2) with repair kind review: the Review comment as the details, in <review> tags, run by the repair agent in the worker's session (tests locked as in PIP-31). The new diff gets a new review. After 2 fix rounds the gate holds as before (review not approved). A fix round that changes nothing → waiting: no changes. reviewFixes starts over on a ship fix round (SHIP-11). |
given three unapproving reviews, then two fix rounds and then approval:merge with review not approved; given a fix round after which the review approves, then the mission goes on to risk and gate |
| PIP-99 | Each of the three PRs says how risky the change is, in plain words. Once assessed, the spec, plan and code PR bodies carry ## Risk: <level> with the three answers in plain words (AUT-20), the agent's summary and, when the floor raised the level, why. The spec PR's body also lists ## Open points for you (PIP-97). The spec and plan PR bodies are rewritten after the risk run; the code PR's at the gate (PIP-48). A risk run that didn't finish says so. |
given a medium-risk plan, then the plan PR body has ## Risk: medium and the three answers; given a risk run that didn't finish, then the body says so |
| PIP-100 | An agent without edit or write can't change the checkout through its shell either. For an agent whose tools have neither Edit nor Write (today plan, review, risk), the whole checkout is write-denied in the sandbox settings, besides its .git. Their allowed git diff, log and show accept --output=<file>, so text planted in a diff could otherwise make them write files; the sandbox, not a pattern on the command, stops it. |
given a review run executing git diff --output=x.txt, then x.txt is not created |
| PIP-106 | Every agent that writes for the owner writes two kinds of text: a plain brief that someone who doesn't code understands, and the technical text agents and reviewers work from. The design, plan, build (worker and repair) and review results have a required brief (string): two to four sentences on what changes for the people who use the product, what the owner is asked to decide or do, and the risk in everyday words, with no file names, functions, flags, commands, field names, spec IDs or step names (a worker asking for access returns an empty brief). The risk agent's summary is its plain brief (two or three sentences). Each agent's prompt asks for it, and the writing guide (PIP-39) has a section "Two kinds of text": who reads which, plain brief first, technical detail second, and a brief that stands on its own (never "see below", never a term the technical text defines). |
given each result schema, then design, plan, build and review require brief; given each of those agents' prompts, then each asks for a plain brief |
| PIP-107 | The factory keeps each stage's plain brief beside its technical text, so cards and pages can lead with it. Mission data: design.brief and design.title (INT-34), plan.brief, build.brief (the worker's latest, a repair round's included), review.brief, and brief for the question being asked (the worker's brief when it is blocked; a question without one clears an earlier one). Any wait that asks no question of its own drops question and brief, so an answered question never comes back on a later card. A question posted on a PR or in the intent opens with **In plain words:** <brief>, and so does the Review comment; the review's details (the card's and the PR's technical text) stay without it. |
given a blocked worker with a brief, then the Question comment opens with **In plain words:**; given that question answered and a later CI red wait, then the card shows no question and no brief |
| PIP-108 | The spec, plan and code PRs start with what the change means in plain words, with the risk and what the owner decides beside it; the technical text is folded under "Details". The plain part is ## In plain words followed by the brief (nothing without a brief); the technical part is folded as <details><summary>Details</summary>…</details>. Spec PR: the brief, the open points and the risk (PIP-99), then, folded, the design's pr text and the spec IDs; the links stay open. Plan PR: the brief and the risk, the approach folded, then the access asked for, the merge line and the links. Code PR (PIP-48): the worker's brief, the risk and the access beyond the plan open; folded: the worker's sections, the checks and the review details; then the IDs, the issue line, the links and the footer open. Without a brief (a run from before briefs) nothing is folded and the bodies read as before. |
given a spec PR with a brief, then its body starts with ## In plain words and the design's text is inside <details>; given no brief, then the body has no <details> block |
| PIP-109 | The design also returns the intent's title as a plain user story, which the factory uses for intents the owner didn't write. design.title: one plain sentence of about 6 to 14 words, no period at the end, naming the product and what someone can do or see once it's done, and for whom when it isn't the owner (INT-33); no file names, functions, flags or jargon. Kept as the mission's design.title; applied as INT-34 says. The owner's own intents keep their title whatever it returns (the agent is told to return it unchanged for them). |
given an intent from an issue and a valid design title, then the intent takes it; given an owner's intent, then its title is unchanged; given a title that breaks the naming rule, then it is not used |
| PIP-110 | What a card or message asks, and why it waits, says in plain words what the owner needs to decide or do, not what the factory did. The asks of the Waits table (LOOP-14) name the decision or the step (for example Decide if this change can go live: merge the PR to ship it, or comment what to fix.); their keys don't change, since intent files store them. The reasons an approval waits are plain sentences: the gate's reasons (PIP-7); the risk's (It is <level> risk, and at autonomy a<n> only low-risk changes go ahead without you.; no assessment → The risk check didn't finish, so you decide whether it goes ahead.); the spec's open points (PIP-97); the spec PR's checks and conflicts (each says what to do: comment so the factory revises it, re-run the checks, or merge it anyway); and GitHub's refusal to merge (behind, dirty, blocked, unstable, draft and any other state, each with the step that clears it). A blocked wait's reason stays what the factory saw (a check's name, an error line, prefixed Still failing after 2 fix attempts: when the repairs ran out) and is shown as technical text (UI-126); the checks red ask names no count, since refusing cache writes (PIP-95) reaches it too. The message of an intent-only wait (unknown repo, bad after:) opens with its ask from Waits. |
given each wait key, then its ask matches the LOOP-14 table; given a medium-risk change at a4, then why It is medium risk, and at autonomy a4 only low-risk changes go ahead without you.; given mergeStateStatus BEHIND, then the why says what clears it; given a wait unknown repo, then the message opens with its ask |
| PIP-111 | PR titles and commit messages name the change in a few words, even when the intent's title is a long user story. The design also returns subject: two to six words, Conventional Commit description style (lowercase, imperative or noun phrase, no period, no product name, e.g. magic link login). It is cleaned like a title (INT-36) to at most 60 characters with its first letter lowered, and titles the spec PR and its commit (docs(specs): <subject>), the plan PR and its commit (docs(plan): <subject>), and the code PR, its commits and the squash merge (<type>(<scope>)!: <subject>, ! only when breaking). Without one, or when it cleans to nothing, the intent's title is used. |
given subject Magic link login and type feat, scope auth, then the code PR title is feat(auth): magic link login; given an empty subject, then the intent's title is used |
Agents
The agent files (PIP-70) and their front matter (PIP-71). "Read, find, search" are the coding agent's file-read, file-glob and content-search tools; "shell" is its command tool, always inside the sandbox (PIP-24).
| Agent | Purpose | May touch | Session | Model | Limits, schema |
|---|---|---|---|---|---|
design |
Turns the intent into spec changes and the spec PR's text (PIP-25). | Read, find, search, edit, write; edit allowed only under specs/**. |
the mission's (started on first use) | mission | 60 min, $10, design |
plan |
Plans the build; reads only (PIP-28). | Read, find, search; shell only for git log, git diff, git show, ls; checkout write-denied (PIP-100). |
the mission's | mission | 60 min, $10, plan |
worker |
Writes tests and code, and the code PR's text (PIP-1). | Read, edit, write, find, search, shell in the sandbox; web fetch for granted domains; protected paths per the grants (PIP-29). | the mission's | mission | 60 min, $10, build |
repair |
Fixes red checks, a conflict, a review finding or an owner's answer in the worker's session (PIP-74). | The worker's tools and permissions, no more. | the mission's | mission | 60 min, $10, build |
review |
Reviews the code PR against the intent, plan and REVIEW.md; reads only (PIP-4). |
Read, find, search; shell only for git diff, git log, git show; checkout write-denied. |
new per run | review | 15 min, $3, review |
risk |
Judges the risk of the spec, plan and code at their gates, and reviews the spec a second time; reads only (PIP-96, PIP-97). | Read, find, search; shell only for git diff, git log, git show; checkout write-denied. |
new per run | review | 10 min, $2, risk |
score |
Turns a GitHub issue into a scored intent (INT-22); runs outside any mission in an empty folder. | Nothing: no tools, sandbox: none. |
none | agent | 5 min, $1, score |
features |
Writes a repo's live features from its README and specs (INT-39); runs outside any mission in a fresh checkout of the base branch. | Read, find, search in the sandbox; no network. | none | agent | 10 min, $2, features |
repair runs under the worker's run kind, so its run files and counters are the worker's. Model mission = the intent's model; review = PIP-4's choice; agent = [agent].model. Every prompt fences the data it is given (intent, plan, issue, logs, REVIEW.md) and says it is data, not instructions; score and features are also told never to follow anything their input says and never to mention their instructions or schema.
Data model
All result schemas are closed objects: every key is required, and null stands for "not given" where a field is optional. S = string, ? = may be null.
Agent file front matter (PIP-71): tools [S] required; allow [S] required, each Tool or Tool(pattern); sandbox strict|none required; model mission|review|agent required; session new|resume|none required; timeout_min int required; max_cost_usd number? required; schema S? required (a name below). No other key.
Agent run result (PIP-24): the last line of the run's output that is a JSON object with type: "result"; fields is_error bool, result S, session_id S, total_cost_usd number, duration_ms int, structured_output (the schema's object), permission_denials list. A failed run is classified, first match wins: session gone; a usage limit (LOOP-9); a network or auth failure (LOOP-10); budget used up; a denied push or secret read; no valid result; other failure.
| Schema | Fields |
|---|---|
design |
title S (6–14 words, no period); subject S (2–6 words); brief S; type one of feat, fix, perf, refactor, docs, test, build, ci, chore; scope S (may be empty); breaking bool; spec_ids [S]; concerns [{what S, why S}]; open_questions [{question S, answer S?}]; pr S |
plan |
brief S; plan S (PIP-46 sections); access {domains [{domain S, why S}], paths [{glob S, why S}]}; e2e {tests [S], none_why S}? (null in a repo without e2e suites) |
build |
status done|blocked|access; brief S; pr S?; notes S; question {context S, question S, options [{option S, then S}], recommendation S}?; access {domains [{domain, why}], paths [{glob, why}]}?; plan_changed S?; lower_a int 1–5?; lower_why S? |
review |
brief S; approved bool; outcomes [{bullet S, met bool, why S}] (one per Proposed outcome bullet, in order); tests_weakened {value bool, where S}; plan_followed {value bool, where S}; findings [{severity important|nit, pass S, file S, line int?, what S}]; door one-way|two-way; red_lines [{line S, file S}]; summary S |
risk |
blast_radius {size small|medium|large, who S}; door {kind two-way|one-way, why S}; certainty {level high|medium|low, why S}; level low|medium|high; summary S; open_points [{point S, why S}] |
score |
u int 1–5; u_why S; i int 1–5; i_why S; title S (under 100 characters); problem S (2–5 sentences); outcome [S] (1–5); affected S (may be empty); constraints [S]; open_questions [S]; no field line starts with three backticks or tildes |
features |
features [S], 5–30 items, each one plain sentence under 160 characters, no Markdown |
Mission data this area keeps (alongside the mission record of LOOP-6): head S (last pushed commit); pushed_at time; spec_pr, plan_pr, pr int?; feedback_seen time (PIP-44); counters repairs (verify and proof, max 2), ci_repairs (max 2), reviewFixes (max 2), spec conflict revisions (max 2) and spec red revisions (max 1); unproven bool (PIP-30); specAnswers [S] (PIP-97); riskOf.<gate> {key, assessment} for spec, plan, code (PIP-96); design.brief, design.title, plan.brief, build.brief, review.brief, question, brief (PIP-107). Per repo: the flag checks-write:<repo> = 1 (PIP-95).
Guard rules file (PIP-29): {checkout: S, protected: [S], granted: [S], locked: [S]}.
Out of scope
- After the merge: deploy, e2e runs, rollback and revert belong to the ship stage (ship.md); a release gate and rollback rehearsals stay out.
- Evals, a separate verifier agent and policy skills.
Decisions
- Three PRs per intent (the owner's call): the spec, the plan and the code are reviewed where they live, and merging each one is the approval. The plan PR lives in the intents repo, so plans don't clutter the target repo's history.
- The worker writes its own PR text: it knows the change; an earlier separate writer model saw only truncated notes.
- Questions before a PR exists go into the intent file, so there is always one place to answer.
- Mixed interaction model (the owner's call): GitHub gestures first, plain replies for answers and feedback,
/retryand/releaseas the only commands. - The worker gets general shell inside the sandbox: an earlier per-command allowlist caused most blocked waits ("compound command refused"), and the sandbox, not the allowlist, is what keeps the machine safe.
- Spec rows are
Draftin the spec PR and the code PR flips them toLive, somainnever claims something is built before it is. - The review cost cap of $3 was set from real use: the first 9 reviews cost $0.23 to $1.18 each, so $3 leaves room for a large diff.
- The agent files were compared with an AI-native SDLC playbook. Taken: findings name their pass (PIP-92), the plan names its files (PIP-93), the review gets a cost cap (PIP-94), the scorer fills the intent's other sections (INT-32), and the review skips what the checks already enforce (REPO-12). Not taken: a narrow shell allowlist (the decision above); denying
.env*andsecrets/inside the checkout, because the checkout is a fresh clone that never holds a real secret and the secret paths outside it are already denied (PIP-24); and the target repo's own skills and subagents, which the "no settings from the repo" rule keeps out so a repo cannot change what an agent may do (PIP-70). - Stack-free rewrite: the reference build uses Claude Code as the coding agent (its tools Read, Glob, Grep, Edit, Write, Bash, WebFetch, NotebookEdit are what
toolsandallowname), a namespace sandbox for checks, the GitHub CLI for PR reads and merges, and release-please and Renovate for PIP-91. The worker's model tiers (small → medium → large) stand for the reference build's model names. The source listed six agents in its agent list but shipped eight files; this spec lists all eight. The source'splan.mdrow pointed to the wrong row for "Files that change"; it is PIP-93 here. Rows about the factory's own repository (PIP-12, PIP-90, PIP-91) are kept because the source has them; a builder in another stack keeps their intent (one aggregate required check, pinned actions, release PRs, delayed dependency updates) with its own tools. - Text that names the product in messages and the PR footer uses "the factory" or
<factory name>; branch prefixes, markers, commit messages and commands that containtowercabstay as they are, since they are interfaces other areas and the intents repo rely on.
Priority
Status: Draft · Prefix: PRI
Purpose
Which intent the factory works on next. The owner scores each intent for urgency and importance; code, never a model, turns the scores into one order, and intents below a cutoff wait until the owner raises them.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PRI-1 | Every intent has an urgency u and an importance i from 1 to 5, and optionally an autonomy a from 1 to 5. Enforced when an intent is read and written (INT-2) and by the score words of towercab add (uN, iN, aN, each at most once), else the error bad score "<s>": want uN, iN or aN with N 1-5, each once. |
given towercab add "shop: Buyers can pay by card" u6 → then it fails with bad score "u6": want uN, iN or aN with N 1-5, each once; given u2 u3 → then the same error for the second; given front matter i: 0 → then the intent is unreadable |
| PRI-2 | A missing urgency or importance counts as 3; a missing autonomy stays missing. The defaults are applied on read and never written into the file. No a counts as a5 (AUT-9). |
given an intent with no u and no i → then its priority is 9 and the file still has no u or i line |
| PRI-3 | An intent's priority is twice its importance plus its urgency: priority = 2 × i + u, from 3 to 15. |
given u: 4, i: 3 → then priority 10; given u: 5, i: 1 → then 7 |
| PRI-4 | Higher priority goes first; between equal priorities, the older intent goes first. Sort by priority descending, then by ID ascending, comparing YYMMDD, then HHMM, then the -n suffix (none = 1). |
given 261015-1446 and 261015-1446-2 and 261014-0900, all priority 10 → then the order is 261014-0900, 261015-1446, 261015-1446-2; given priority 11 on 261016-0800 → then it goes first |
| PRI-5 | An intent below the cutoff is parked: shown, but never started on its own. Parked when priority < [intents].cutoff (config, 1–15, default 7). next, status and the UI show it as parked with raise u or i to run it. It runs once the owner raises a score, or names it with towercab run --intent <id>. The picker's list: open intents with no stage and no waiting, whose after IDs are all done, in PRI-4 order, those at or above the cutoff first, then parked ones. |
given u: 2, i: 2 (priority 6) and the default cutoff → then it is listed as parked with raise u or i to run it and never picked on its own; given after: [261014-0900] and that intent still open → then it is not in the picker's list |
| PRI-6 | The order is worked out on every read and never written into an intent. TODO.md's Next and Parked sections (INT-6) and the UI's queue show it. |
given an intent's i raised from 2 to 4 on main → when TODO.md is next regenerated → then it moves up in Next, and no order field appears in any intent file |
| PRI-9 | Dropping an intent marks it dropped and moves it to done/; intents are never deleted. Through the store: stage: dropped, finished: <now>, folder moved to done/ (INT-1). Triggered by towercab drop <id>, the UI, or the owner closing one of its PRs (PIP-43). DONE.md lists it as dropped. |
given towercab drop 1446 → then the folder is in done/ with stage: dropped and finished set, and DONE.md lists it as dropped |
Out of scope
- Model-suggested scores, except the
/intentdrafting command (INT-9) and issue scoring (INT-22), whose scores the owner can change. - A scheduled backlog review or reminders to review the backlog.
Decisions
- Importance counts double: an important intent should beat an urgent trivial one.
- The order is code, not a model: it must be predictable and explainable in one line.
- Defaults are never written back, so a file shows only what the owner chose.
Repo files
Status: Draft · Prefix: REPO
Purpose
What the factory reads from a target repo to know how to build and judge a change there: how to set up and check it (CLAUDE.md), how to review it (REVIEW.md) and the factory's own settings (TOWERCAB.md). They come from the base branch only, so a PR can't change the rules it is judged by.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| REPO-1 | The factory reads CLAUDE.md, REVIEW.md and TOWERCAB.md from the base branch of its own clone, never from a PR or a working tree. It reads the three paths at the repo root from the tree of the remote-tracking base branch (origin/<base>) after a fetch, as blobs, not from any checkout. A symlink, a directory or a non-UTF-8 file in a file's place is an invalid-repo-files error <file>: …; a failing git command is a git error. The text is then parsed by the Grammar below. TOWERCAB.md front matter keys: max_autonomy (1–5, default 5, AUT-8), protected (globs, default none), tests (globs, default the list in PIP-30), live (the ship block, SHIP-1). Any other key → unknown key <k>. |
given a PR branch whose CLAUDE.md has a different checks block, when the factory loads repo files, then it uses the base branch's checks; given TOWERCAB.md with colour: red, then the error reads TOWERCAB.md: …: unknown key colour |
| REPO-2 | CLAUDE.md tells the factory how to set up and check the repo, in fenced blocks under ## Verifying your work. Blocks: ```setup (commands with network, run after each clone and before checks, e.g. installing dependencies); ```checks (the full suite, one command per line, run without network); optional ```fast (the subset run before publishing when the repo has CI, REPO-9); optional ```screenshots (REPO-10). Prose around the blocks is for the coding agent. |
given a ## Verifying your work with setup make deps and checks make lint and make test, when parsed, then setup is [make deps] and checks is [make lint, make test] |
| REPO-4 | REVIEW.md tells the reviewer what to look for in this repo and what counts as important. Sections: ## Passes (bullets name: what; none or no bullets → Bugs, Security, Compliance with the spec rows and the plan); ## What Important means here; ## Cap the nits (the first number in it, default 5); ## Do not report (REPO-12); ## Red lines (paths or behaviours whose change makes a PR high risk, AUT-17). The whole text goes to the review (PIP-4). |
given no REVIEW.md, then the passes are Bugs, Security and Compliance and the nit cap is 5; given ## Cap the nits with "at most 3 nits", then the cap is 3 |
| REPO-5 | A repo without a checks block waits for the owner to add one. No ## Verifying your work section with a non-empty ```checks block on the base branch → the intent waits add CLAUDE.md verifying block. REVIEW.md and TOWERCAB.md are optional. |
given a repo whose CLAUDE.md has no checks block, when an intent for it is picked, then it waits add CLAUDE.md verifying block and no worker starts |
| REPO-6 | The factory's own repo carries its own CLAUDE.md, REVIEW.md and TOWERCAB.md, which describe its codebase and parse under these rules. CLAUDE.md: at most 60 lines (commands, conventions, architecture, what the coding agent gets wrong), with a setup block that installs dependencies and the headless browser of REPO-11, a checks block running the full suite, a fast block (lint, type check, and the changed tests via {tests}), and a screenshots block running the UI screenshot tests. REVIEW.md: passes Bugs, Security, Compliance; red lines are the mission state machine, the guard that limits what a worker may write, and the wrapper that runs the coding agent. TOWERCAB.md: max_autonomy: 5, protected = the state machine's source, tests = the repo's test folder. |
given the factory's own repo, when its repo files are loaded, then they parse without error, CLAUDE.md has ≤ 60 lines and all four blocks are present |
| REPO-8 | Some paths decide how a change is judged, so a worker can write them only when its autonomy or plan allows (AUT-14). Protected = CLAUDE.md, AGENTS.md, REVIEW.md, TOWERCAB.md, .github/**, .claude/**, .git/**, plus TOWERCAB.md protected. A path is blocked when a protected glob matches it or a parent directory and no granted glob does; an absolute path, a path with a .. component, or anything under .git is always blocked. .git/** is never writable, whatever is granted. The sandbox gets the list of existing blocked paths in the checkout as read-only. |
given grants [.github/**], then .github/workflows/ci.yml is writable, REVIEW.md is blocked and .git/config is blocked; given protected: [db/**] and no grant, then db/schema.sql is blocked |
| REPO-9 | Before publishing, the factory runs the fast checks when the repo has CI and the full checks when it doesn't. No CI → the checks lines. CI and a ```fast block → its lines; CI and no fast block → checks. In a fast line, {tests} becomes the changed test files and {stems} their file names without extension, space-separated and shell-quoted; a line with a placeholder and no changed tests is skipped. The repo has CI when the base branch has a .github/workflows/*.yml or *.yaml file. |
given CI and fast line run-tests {tests} with changed tests a test.js and b.js, then the line runs as run-tests 'a test.js' b.js; with no changed tests, then the line is skipped |
| REPO-10 | A repo with a UI can give the factory a command that takes screenshots, and the review sees them. ```screenshots under ## Verifying your work: one command, run after green checks in the sandbox (PIP-23) with TOWERCAB_SHOTS=<dir> set; every *.png it writes there, at most 20 and each ≤ 5 MB, goes to the review (PIP-49). No block → no screenshots. |
given a command that writes 25 PNGs, one of 6 MB, when the review runs, then it gets at most 20 screenshots and none over 5 MB |
| REPO-11 | The factory's own setup installs the headless browser its screenshots command needs into the repo's shared build cache, so screenshots run in every mission. The browser lands under the cache's XDG_CACHE_HOME (LOOP-34), where the later screenshots run finds it; a second mission finds it there and downloads nothing. Setup runs with network (PIP-23), so it reaches the browser's download host without any change to [agent].network or the repo's allowed domains; no default domain is added. With the browser installed, the screenshots log of a new mission shows the UI tests passing with no browser-missing error, and the review gets the screenshots (REPO-10). |
given two missions on the factory's own repo, when the second runs setup, then nothing is downloaded for the browser and its screenshots log has no browser-missing error |
| REPO-12 | Without a ## Do not report section, the review still skips generated files and style the repo's checks already enforce, so it spends no findings on what CI catches anyway. No REVIEW.md, or one without ## Do not report → the review prompt (PIP-4) gets these defaults: files .gitattributes marks linguist-generated, lockfiles, and formatting or lint issues a formatter or linter in the repo's checks would catch. A ## Do not report section with bullets replaces the defaults; one without bullets keeps them, as ## Passes does. |
given REVIEW.md with ## Do not report and bullet - the docs folder, then the prompt lists only the docs folder; with the section empty, then it lists the three defaults |
Grammar
- Every file: UTF-8, a leading byte-order mark dropped, lines split on
\n, a trailing\rdropped from each line. The first error wins and reads<file>: <where>: <what>. - Fence: a line starting (after spaces) with 3 or more backticks or tildes opens a block named by the rest of the line; the next line of at least as many of the same character closes it (none →
unclosed ```<name> block). Body = its lines trimmed, without blank lines and lines starting#. Two blocks of one name →two ```<name> blocks. - Section: outside fences, a
## <title>line at column 0 up to the next#or##line; titles compare trimmed and case-insensitive. A known title twice →two ## <title> sections. - Bullet: a list item (
-,*,+, or digits followed by.or)), continued by the indented lines after it. - Globs (
protected,tests,unlock): dotfiles match;*stays within one path component,**spans components; relative to the repo root;P/**also matchesPitself. A bad glob →<key>: bad glob "<g>".
Data model
| Entity | Field | Type | Required | Limits, default |
|---|---|---|---|---|
TOWERCAB.md front matter |
max_autonomy |
integer | no | 1–5, default 5 |
TOWERCAB.md front matter |
protected |
list of globs | no | default none; added to the built-in protected list |
TOWERCAB.md front matter |
tests |
list of globs | no | default the PIP-30 list |
TOWERCAB.md front matter |
live |
mapping | no | SHIP-1; absent = no ship stage |
CLAUDE.md verifying |
setup, checks, fast, screenshots |
lists of command lines | checks yes, others no |
checks non-empty; screenshots one command |
REVIEW.md |
passes |
list of {name, what} | no | default Bugs, Security, Compliance |
REVIEW.md |
nit cap |
integer | no | default 5 |
REVIEW.md |
do not report |
list of text | no | default REPO-12 list |
REVIEW.md |
red lines |
list of text | no | default none |
Out of scope
- Rulesets set up by the factory on target repos.
Decisions
- The base branch is the only source: a PR that edits
CLAUDE.mdcan't loosen its own checks. One exception: when the owner granted the PR the right to changeCLAUDE.md, the red→green proof runs that PR's checks block (PIP-54); verify and CI still use the base's. fastexists so a repo with CI doesn't run a slow full suite locally on every round; CI stays the gate.- The file names
CLAUDE.mdandAGENTS.mdfollow the conventions coding agents already read; the reference build uses Claude Code. - The live block lives in SHIP-1, not here, so the ship area owns its own format.
Ship
Status: Draft · Prefix: SHIP
Purpose
After a merge, the factory follows the change until it is live, tests the live app end to end and, when the change broke it, gets the last good version back and fixes the problem itself. It works with any platform through a small adapter and never deploys anything or touches a server; a repo opts in, and without that, merged means done.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SHIP-1 | A repo opts into the ship stage with a live block in its TOWERCAB.md. Front matter key live (REPO-1), a mapping: url (required, the app's public https:// URL); environment (the deploy environment to watch, default production); adapter (github, default, or command, which also needs live_command and rollback_command, one shell line each, SHIP-5); timeout (minutes to wait for a deploy, 1–120, default 20); suites (one or more e2e suites, each with name matching [a-z0-9-]+ and unique, command one shell line, tests at least one glob where its e2e tests live, optional paths source globs that make it relevant, optional smoke: true to run it after a rollback, SHIP-10). Any other key or a missing required field makes TOWERCAB.md invalid (live: <why>) and the intent waits on fix repo files. TOWERCAB.md is protected (REPO-8), so a worker can't change the block. |
given live with url: http://app.example.com, when repo files load, then the intent waits fix repo files with a live: reason; given adapter: command without rollback_command, then the same |
| SHIP-2 | Secrets the e2e tests need, such as a test login, live outside every repo, in one file per repo that only the owner can read. <config dir>/secrets/<repo>.env (default ~/.config/towercab/secrets/<repo>.env): KEY=VALUE lines; # comments and blank lines skipped. Any group or other permission bit set → the stage waits on fix repo files with <path> is readable by others; chmod 600 it. No file = no secrets. The values go only into the environment of the suites and the adapter's commands. Every log the factory keeps or shows from those runs has each value of 4 or more characters replaced with [redacted]. Secrets never reach a worker, a prompt, a PR, a comment or a message. |
given a secrets file with mode 0644, then the stage waits with the chmod message; given PASSWORD=hunter22 and a suite that prints it, then the kept log shows [redacted] |
| SHIP-3 | The factory talks to the platform through an adapter with three questions and one request, so any platform can be wired in. current(): the commit live in the environment now, or none. outcome(sha): live when sha is live (a newer live commit that contains sha counts as live), failed with the platform's reason when it failed to deploy, pending otherwise. rollback(sha, reason): ask the platform to make sha live. The factory never runs a deploy itself, holds no server credential and never connects to a server. |
given the same platform state behind both adapters, when current() and outcome(sha) are asked, then both give the same answers |
| SHIP-4 | The default adapter reads and creates GitHub Deployments on the app's repo, as the factory's GitHub App. current(): among the newest 30 Deployments in the environment (GET repos/<r>/deployments?environment=<e>), the newest whose latest status (…/deployments/<id>/statuses?per_page=1) is success; its sha. outcome(sha): the newest Deployment of that sha in the environment; latest status success → live, failure or error → failed with its description, anything else → pending. With no success for sha, current() having sha as an ancestor (checked in the factory's clone after a fetch) → live. rollback(sha, reason): POST repos/<r>/deployments with ref = sha, the environment, task: deploy:rollback, auto_merge: false, required_contexts: [], a description and payload: {intent, from, reason}. A Deployment the step already created (same sha and payload.intent, created after the step was recorded) is reused, so a crash never asks twice. A 403 → the rollback fails with The factory's GitHub App needs read and write access to Deployments (with the App's own name). The platform's side of this contract is the deploy contract (SHIP-16). |
given a step that created a rollback Deployment and then crashed, when the step runs again, then no second Deployment is created; given a 403 on create, then the rollback fails with the access message |
| SHIP-5 | A platform that doesn't react to GitHub Deployments is wired in with two commands. live_command prints the live commit: the first 40-hex word of its output is current(). outcome(sha) is live when that commit is sha or contains it, else pending (such a platform turns red only by the timeout). rollback_command runs with TOWERCAB_ROLLBACK_SHA and TOWERCAB_REASON; exit 0 means the request was taken. Both run like the suites (SHIP-9): in the sandbox with network, with the secrets, BASE_URL and TOWERCAB_ENVIRONMENT, in the checkout at the shipped commit, 5 minutes each. A non-zero exit of live_command reads as pending; of rollback_command, as a failed rollback. |
given live_command printing deployed 3f2a…(40 hex) at 12:00, then current() is that sha; given rollback_command exiting 1, then the rollback has failed |
| SHIP-6 | After its code PR is merged, a mission ships instead of finishing. New stages deploy and e2e sit in the order after merge and before done; rollback and revert are working stages outside the order; all four are in phase code. code_merged from build … merge, approval:merge or blocked → deploy; a wait that came out of a ship stage keeps seeing its merged PR, so there the event is ignored. At deploy: ok → e2e, red → rollback, skip → done. At e2e: ok → done, red → rollback. At rollback: ok → build, revert → revert. revert leaves only through a wait. The state machine tables of LOOP-3 include these. |
given a mission at deploy, when red arrives then it is at rollback; given rollback and ok, then it is at build |
| SHIP-7 | After the merge, the factory waits until the platform says the merge commit is live. deploy fetches the base branch first, so a live block merged after the change was built still counts. No live block → skip. Otherwise the merge commit is added to the mission's ship.merges, and outcome is read every ci_poll: live → ok; failed → red the deploy failed: <reason>; timeout minutes after entering deploy → red not live after <n> minutes. |
given timeout: 1 and an outcome that stays pending, then after 1 minute the mission goes red with not live after 1 minutes |
| SHIP-8 | In an opted-in repo, a change comes with end-to-end tests for what it adds. The plan agent gets the suites and their test globs, and its result names the e2e tests it will add or says why none is needed (e2e: {tests, none_why}). The review agent is told to check that the e2e tests cover the new behaviour. At the gate (PIP-7): a live repo, no changed file under any suite's tests globs and no none_why in the plan → approval:merge with The change has no test that runs against the live app. A fix round (SHIP-11) is exempt: the suite that failed checks the fix once it ships. E2e test files need the live app, so they don't count as the change's tests in the red→green proof (PIP-30). |
given a live repo with suite tests e2e/**, a diff with no file under e2e/ and no none_why, when the gate runs, then it waits at approval:merge with that message |
| SHIP-9 | Once the merge is live, the factory runs the repo's e2e suites against the live URL. In the mission's checkout detached at the shipped commit: the setup block (with network), then each suite's command in the sandbox (PIP-23) with network, BASE_URL=<url>, TOWERCAB_SHA and the secrets, check_timeout_min each. Order: suites whose tests globs match a file the intent's merges added or changed, then suites whose paths match a changed file, then the rest, each group in file order. A red suite runs once more; red twice → red naming the suite, with its log kept at runs/<intent>/e2e/<n>-<suite>.log, redacted (SHIP-2). All green → ok, and the shipped commit becomes the repo's last good version. |
given suites api then web in the file and a change under web's tests, then web runs first; given web red then green, then the stage is ok and the shipped commit is the last good version |
| SHIP-10 | When the change is broken, the factory asks the platform for the last good version and checks it. Target: the repo's last good version or, before any green run, the first parent of the intent's first merge. When current() already is the target (the platform's own health check put it back), no request is made; else rollback(target, reason). Then it waits for current() to be the target, like SHIP-7, and runs the smoke suites once each (smoke: true, or every suite when none is marked). Green → a fix (SHIP-11) or, after two fixes, a revert (SHIP-12). A refused request, a failed or late deploy, or a red smoke suite → rollback failed (SHIP-13). |
given current() already equal to the last good version, then no rollback request is sent and the smoke suites run; given a red smoke suite, then the mission waits rollback failed |
| SHIP-11 | After a rollback, the worker fixes the cause in a new PR on top of the base branch, and that PR ships like any other. rollback → build with a repair of kind e2e: which suite failed against which URL after which commit went live, that the app is back on the last good version, and the suite's log. The worker keeps its session (PIP-13), on a new branch <branch>-fix<n> from base; the mission's pr, head and merge_sha start empty; the replaced PR's number is added to ship.prs, so the intent page still lists it (UI-124). The fix goes through verify, proof, CI, review and gate as usual. At most 2 fixes per mission (ship.fixes); a third red goes to revert. |
given a mission with ship.fixes = 2, when its e2e goes red again and the rollback is green, then it goes to revert, not build |
| SHIP-12 | When two fixes didn't hold, the factory undoes the intent's merges on the base branch and hands the intent back. Branch towercab/<intent>-revert from base, with a revert (no edit) of every merge of the intent, newest first, committed as the factory's bot; a PR Revert: <title> whose body names the merges, the failing suite and the tail of its log. The factory merges it once its CI is green (or the repo has none), whatever the intent's autonomy: the repo opted into rollback with its live block. Then it waits for the revert's commit to be live (SHIP-7) and runs the smoke suites. Green → wait rolled back, revise, naming the failing suite and its log. A revert conflict, red or stuck CI, a failed or late deploy, or a red smoke suite → rollback failed. |
given an intent with merges M1 and M2, when it reverts, then the revert branch reverts M2 then M1 and, once live and smoke-green, the intent waits rolled back, revise |
| SHIP-13 | When getting back to a good version fails, nothing more merges in the repo until the owner has looked. The mission waits rollback failed (blocked) with the ask Rolling back failed. Nothing more merges in this repo until you fix it. and a why saying what failed. The hold (SHIP-14) lasts while the mission waits there. An answer continues at build with the answer in the prompt; /retry starts over; drop ends it. |
given a mission waiting rollback failed, when another mission of that repo reaches the gate, then it holds; when the owner drops the first, then the second may merge |
| SHIP-14 | While a change ships, rolls back or is being fixed, nothing else merges in its repo. At the gate (PIP-7), before deciding: another unfinished mission of the same repo at deploy, e2e, rollback or revert, in a fix round, or waiting on rollback failed → stay at the gate, look again after 60 s, why repo on hold: <intent> is shipping. |
given mission A at e2e and mission B of the same repo at the gate, then B stays with repo on hold: A is shipping and is checked again after 60 s |
| SHIP-15 | A repo without a live block works as before: merged means done. deploy → skip → done; no adapter call, no suite, no hold. |
given a repo without live, when its code PR merges, then the mission is done and no Deployment is read |
| SHIP-16 | A public deploy contract tells a platform team what to build so the factory can ship to their platform. It covers the two adapters and what a platform must do for each (record each deploy as a GitHub Deployment with a final status, and deploy the commit a deploy:rollback Deployment asks for; or provide the two commands), the live block, the secrets file, and a worked example of one server deploying from the code host's CI over a private network. Short sketches cover a GitOps notification hook and hosted platforms through the command adapter. The factory's README links it. |
manual: read the deploy contract and check it covers both adapters, the live block, the secrets file and the worked example; the README links it |
Data model
| Entity | Field | Type | Required | Limits, default |
|---|---|---|---|---|
live block |
url |
https:// URL |
yes | |
live block |
environment |
text | no | default production |
live block |
adapter |
github|command |
no | default github |
live block |
live_command, rollback_command |
one shell line each | with adapter: command |
|
live block |
timeout |
integer minutes | no | 1–120, default 20 |
live block |
suites |
list of suite | yes | at least one |
| Suite | name |
text | yes | [a-z0-9-]+, unique in the block |
| Suite | command |
one shell line | yes | |
| Suite | tests |
list of globs | yes | at least one |
| Suite | paths |
list of globs | no | default none |
| Suite | smoke |
boolean | no | default false; none marked = all are smoke |
| Secrets file | lines | KEY=VALUE |
no | mode 0600 or stricter |
| Mission | ship.merges |
list of commit sha | no | every merge of the intent, in order |
| Mission | ship.prs |
list of PR numbers | no | PRs replaced by fix rounds |
| Mission | ship.fixes |
integer | no | 0–2 |
| Repo | last good version | commit sha | no | set by an all-green e2e run |
| Rollback Deployment | ref, environment, task, auto_merge, required_contexts, description, payload |
GitHub Deployment fields | yes | task: deploy:rollback, auto_merge: false, required_contexts: [], payload: {intent, from, reason} |
| Plan result | e2e |
{tests: list, none_why: text} | in a live repo | one of the two non-empty |
Out of scope
- Database migrations: a rollback only swaps the version. The review asks for backward-compatible migrations where possible but doesn't block on it. Migration-aware rollback is for later.
- Staging and preview environments before production.
- Watching the live app after green, alerts and incidents.
- A release gate, rollback rehearsals and a
/releasecommand (the command name stays reserved, PIP-45). - Changes merged by hand outside the factory: the platform ships them, but the factory doesn't test them.
Decisions
- The factory never deploys: deploying is the platform's job, and keeping the factory on GitHub alone lets a company plug in any platform without giving the factory server access.
- GitHub Deployments are the default contract: they need no endpoint or shared secret between the factory and the platform, keep an audit trail, and many platforms already write them. The command adapter covers the rest; an HTTP webhook adapter can come later.
- Fix forward before revert: a rollback brings the app back at once, so the worker has time to fix the cause properly, and a revert is the fallback after two tries.
- The hold is per repo, not per file: a ship is short, and knowing exactly what to roll back to matters more than a few minutes of waiting.
- Suites are plain commands: the factory doesn't know whether a test drives a browser or calls an API, so the same stage works for a backend, a frontend or both.
UI
Status: Draft · Prefix: UI
Purpose
A local web page for the person running the factory. It answers four questions in this order: what needs me, what is stuck, what is moving, and what it costs. It shows the few things that matter in plain words, offers only the action each situation needs, and keeps the full detail one click away.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| UI-1 | The running loop serves the page on this machine only, and the page never holds up the loop. towercab run --watch starts an HTTP server on 127.0.0.1:<[ui] port> (default 7311; 0 = off). A port already in use is logged once and retried every 30 s. The server serves the built page and the API (UI-65); the loop never waits on it. |
given [ui] port = 7311 when towercab run --watch starts then http://127.0.0.1:7311/ answers and the port is not reachable from another host; given the port is busy then one log line appears and the bind is retried 30 s later |
| UI-2 | towercab ui opens the page in the browser. It opens http://localhost:<port>/ in the system's default browser; --no-open prints the URL instead. Port 0 → exit 1 with ui is off: [ui] port = 0. When no loop is running, the command serves the same page itself, read-only, with a banner saying the factory isn't running. |
given [ui] port = 0 when towercab ui then exit 1 and ui is off: [ui] port = 0; given no loop when towercab ui --no-open then the URL is printed and the page shows the not-running banner and no action buttons |
| UI-4 | Only the owner's own browser on this machine can read the page or act through it, and nothing secret is ever sent. Host must be 127.0.0.1:<port>, localhost:<port> or [::1]:<port>, else 421. A POST also needs Origin = http://<Host> (else 403) and header X-Towercab-Token equal to a per-process random token (32 bytes, hex), which the page carries in <meta name="towercab-token">; compared in constant time; wrong or missing → 403. No CORS. Every response sets Cache-Control: no-store, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY and a CSP of default-src 'self' with no inline script. No tokens, message-channel settings or key paths are ever sent; log lines are redacted (ghs_…, ghp_…, github_pat_…, bot tokens, Bearer …). |
given Host: evil.example:7311 then 421; given a POST with the right Origin and no token then 403; given a log line containing ghp_abc123 when shown on the page then the token is replaced by a redaction mark |
| UI-55 | The page is a small single-page app served as static files, with nothing loaded from elsewhere. The built page is one HTML file, its script and one stylesheet using colour and spacing variables. No external resources (fonts, scripts, images) at runtime. A development mode may serve the page with live reload, forwarding /api to the running loop. |
given the built page when its HTML, script and stylesheet are scanned then no URL points to another origin |
| UI-56 | Every page uses the same few states, with the same words, colours and icons. States: Needs you, Working, Waiting on CI, Queued, Cooling down (usage limit, with the reset time), Paused, Stalled (no move for longer than its stage allows: 2 × the stage's timeout, or 2 hours for CI), Up next, Parked, Done, Dropped. The server derives each item's state once, from the mission and the current time, and sends it with the item. Steps are named in plain words: "Writing the spec", "Waiting for your spec review", "Planning", "Building", "Running checks", "Proving the tests", "Opening the PR", "Waiting for CI", "Reviewing", "Merging". |
given a mission in CI with no move for 2 h 1 min then its state is Stalled; given a mission in the build step then its step reads "Building" on every page |
| UI-57 | The top bar says at a glance whether the factory is healthy and how much needs the owner. It shows health (Running, Paused, Problem, Not running); counts (2 need you · 1 working · 3 queued); spend today, and of daily_budget_usd when set (MET-29); the running version and whether it is behind main; and Pause/Resume. The browser tab title starts with (<n>) while n things need the owner. |
given 2 missions waiting on the owner, 1 working and 3 queued then the bar reads 2 need you · 1 working · 3 queued and the tab title starts (2) |
| UI-58 | Each thing waiting on the owner is one card that says what is asked and why, and offers only the actions that answer it. A card shows the intent's title and repo; the ask (LOOP-14 Waits); the plain reason and a short plain brief (UI-126: a few lines at most, the rest behind "More", the technical text under "Details"); the PR link. Actions by kind: spec approval → "Merge spec PR"; plan approval → "Merge plan PR", with the access list visible; code approval → "Merge PR"; a question or other blocked wait → a reply box with "Send reply". Always, in a menu: "Start over" (/retry) and "Drop" (with a confirm). Cards are ordered oldest wait first. No cards → "Nothing needs you." |
given a plan approval then the card has "Merge plan PR", the access list and the menu, and no reply box; given a question then it has a reply box and no merge button; given none then "Nothing needs you." |
| UI-59 | Moving intents show their progress through spec, plan and code, the step they are on, and how long and how much so far. One row per unfinished mission not waiting on the owner: title, repo, a three-part progress (Spec, Plan, Code; each done, current, to do, or skipped when there was no spec change), the step in plain words (UI-56), time in step, cost so far, model. Queued rows say what they wait behind. |
given a mission building its code after a skipped spec then its row shows Spec skipped, Plan done, Code current and "Building" |
| UI-60 | Problems appear only when there are some, each with what to do about it. Problems: stalled missions; the same check failing in two rounds; a mission costing over 3 × the median of the last 20 missions; GitHub or the coding agent unreachable (LOOP-10); a failed self-update; intent files that can't be read. Nothing wrong → the section isn't shown. | given no problems then no Problems section; given a mission costing $9 when the median of the last 20 is $2 then it is listed with what to do |
| UI-61 | The overview shows what the factory will start next and why the others wait. The top 5 of the picker's list (PRI-5) with their priority and, for each skipped one, the reason in plain words: paused, repo busy with , model cooling down until , parked. | given the top intent's repo busy with api-12 then its row says "repo busy with api-12" |
| UI-62 | One page per intent shows its whole story: the three PRs, the current state, what happened when, and the intent itself. /i/<id> shows: title, repo, scores; the current state sentence; the three PRs (spec, plan, code) with state (open, merged, closed, not needed) and links; the needs-you card when it waits (UI-58); a timeline of moves in plain words with times and durations; cost and agent time per attempt; the intent's sections and ## Answers, rendered. The stage map (UI-73..UI-77) takes the place of time per step and the run files. |
given an intent with a merged spec PR, an open plan PR and no code PR then /i/<id> lists spec merged, plan open, code not needed or not yet opened, and a timeline in plain words |
| UI-63 | Per repo, the factory says what runs, what is up next, what waits on something else and why, and what is parked. GET /api/queue returns one lane per configured [repos] repo, then any other repo a mission names. A lane has: its missions (the build slot's holder first); "up next": queued intents held by nothing but their turn (none, a full repo, Pause), numbered in pick order; "waiting": those held by a dependency, a usage limit, an unreachable service or another reason, each with the reason (UI-61); parked ones; the last 10 done. The Repos page (UI-140) shows it. Read-only. |
given repos web and api configured and a mission naming docs then the lanes are web, api, docs in that order |
| UI-64 | An activity page lists what happened, newest first, filterable by intent, repo, kind and time. /activity shows moves and loop log lines, each naming the intent by title. Kinds: moves, waits, errors. Filters live in the URL (?intent=&repo=&kind=&since=). 100 at a time, then "Show more". |
given /activity?repo=api&kind=errors then only error lines of api intents show and a reload keeps the filter |
| UI-65 | The page talks to the loop through a small JSON API. GET /api/state (the overview's data, with a version that changes when anything shown changes); GET /api/intents/<id>; GET /api/queue; GET /api/repos; GET /api/board; GET /api/activity?…; GET /api/runs/<id>/<file> (<file> or <stage>/<file>, names [A-Za-z0-9._-]+, only files under the mission's run folder runs/<id>/, no symbolic links out, text only, at most 2 MB); GET /api/events (Server-Sent Events: a version event on each change); POST /api/actions with {action, intent, text?} (addRepo takes {action, github, name?} instead, UI-130; features takes {action, repo}, UI-144). |
given GET /api/runs/7/..%2Fsecret then refused; given a 3 MB run file then refused; given a change to a mission then /api/events sends a new version |
| UI-66 | A button in the page does exactly what the owner would do on GitHub or in the CLI. merge → squash-merges, as the owner, the PR the intent waits on (LOOP-38); reply → a PR comment as the owner (no PR yet → the answer command); retry → a /retry comment (no PR → the retry command); drop → closes the current PR as the owner (no PR → the drop command); pause, resume → the commands. The reply says in one sentence what happened, or the error. |
given a mission waiting on plan PR #41 when Merge is pressed then PR #41 is squash-merged under the owner's GitHub account; given a question with no PR when a reply is sent then an answer command is recorded |
| UI-67 | The page updates on its own and never throws away what the owner is doing. It listens to /api/events and refetches on a new version, polling every 5 s while the stream is down. Typed text, open menus and expanded sections survive an update; "Updated s ago" shows freshness. |
given half-typed text in a reply box when a new version arrives then the text, caret and open menus remain |
| UI-68 | The page is calm, readable and usable on a phone. Light and dark following the system setting; the system font stack; a type scale of 4 sizes; an 8 px spacing grid; contrast at least WCAG AA; state colours always paired with an icon or word; visible keyboard focus; usable at 360 px wide. No raw commands, field names or IDs as primary text (IDs appear small, next to titles). | manual: every page in light, dark and at 360 px wide; check contrast, focus rings and that no page scrolls sideways |
| UI-69 | Every page is rendered in a real browser in tests, and the screenshots are kept. A test run starts the server on a fixture home with intents in every state and saves <page>-<light|dark|mobile>.png for each page; CI uploads them (PIP-12). The same run is the factory's own screenshots block (REPO-10). |
given the screenshot run then each page has a light, a dark and a mobile image |
| UI-70 | Every section says something true when it is empty, never an empty box. "Nothing needs you." "Nothing is running." "Nothing queued: add an intent to the intents repo." "No problems." | given a home with no intents then the overview shows "Nothing needs you.", "Nothing is running." and "Nothing queued: add an intent to the intents repo." |
| UI-71 | Every text on the page is plain language a person can act on. Times are relative ("12 min ago") with the exact time on hover; costs in dollars with 2 decimals; durations as 1 h 05 min. Errors say what failed and what to do. |
given a cost of 1.5 and a duration of 3900 s then the page shows $1.50 and 1 h 05 min |
| UI-72 | The server works out the path an intent took once, as an ordered list of stage visits, so the page only draws it. GET /api/intents/<id> returns, per attempt, a map with visits, stages (every stage with its outcome, time, rounds and files), open and the two totals, built from the mission's moves and its stage folders (LOOP-39). Each visit, in order: stage, round (1 the first time in that stage), start, end (none while it runs), outcome (passed, red, sent back, waiting on you, working now, skipped), reason (the plain reason it moved on, or why it was skipped), returnTo and returnLabel (the stage it went back to and why, if it did) and files. Stages in pipeline order, with plain names: Writing the spec (design), Your spec review, Planning (plan), Your plan review, Building (build), Running checks (verify), Proving the tests (proof), Opening the PR (publish), CI (ci), Reviewing (review), Deciding (gate), Your code review, Merging (merge), then the ship stages of UI-123. Waits on the owner (the three reviews and questions) are visits too. A stage never reached has no visit and is listed as not reached. Time per stage and the two totals (the factory working; waiting on you) are sums of the visits. Each file has stage, round, a plain kind ("Prompt sent", "Agent's answer", "Check output", "Errors", "Screenshots", or "Details" for agent settings, system prompt and guard file), name, size and time. Empty files are left out. A folder of screenshots is one entry ("Screenshots", with the image count) that is not opened as text. |
given moves build → verify (red) → build → verify (passed) then visits are build 1, verify 1 red with returnTo build, build 2, verify 2 passed; given an empty errors.log then it is not listed |
| UI-73 | The intent page shows one diagram of the path the intent took through the pipeline, from picked up to merged or to where it is now. It sits right under the three PRs and the needs-you card (UI-62). One node per stage in pipeline order, with the plain names of UI-72. A node shows its outcome as an icon plus a word (passed, red, sent back, waiting on you, working now, not reached, skipped) and its total time (UI-71). The current stage is marked as the current step for assistive technology, carries the state chip and, while working, the spinner. Stages not reached are hollow and quiet. A skipped stage says "skipped" and why (for example "no tests changed"). Waits on the owner use the "Needs you" colour (UI-56). Under the map: time the factory worked and time spent waiting on you. Look and feel follow UI-68 and the page's design notes; the map uses no new dependency or external resource (UI-55). | given an intent in Building after a skipped Proving stage then Building is the current step with a spinner, Proving the tests says "skipped" with its reason, later stages are hollow |
| UI-74 | The map shows the real path: a stage that ran several times shows each round, and every step back is drawn with its reason. A stage with more than one visit shows numbered rounds inside its node (round 1, 2, 3). Each move back (checks red → Building, CI red → Building, review asked for changes, the owner asked for a new spec) draws a return link between the two nodes, labelled in plain words, for example "checks failed: 2 tests red". The link is also text for screen readers: "sent back to Building: checks failed". | given checks red twice then Running checks shows rounds 1, 2, 3 and two links back to Building, each read aloud as "sent back to Building: checks failed" |
| UI-75 | Choosing a stage opens its files right under the map, grouped by round, and a file opens inline. A click, tap or Enter/Space on a node opens the panel; the panel lists the stage's files by round with plain kind names, sizes and relative times (UI-71); "Details" (agent settings, system prompt, guard file) is folded. A file opens in a monospace viewer showing the last 200 lines first, with "Show all" (up to the API's 2 MB) and "Open raw" in a new tab; JSON is pretty-printed. The panel opens by default on the stage waiting on the owner, else the last red one, else the current one, else the last stage. The open stage, round and file are in the URL (/i/<id>?stage=verify&file=…), so a link can be shared and survives reload. Live updates (UI-67) move the map and append files without closing the open stage or file or moving the reader's scroll position. |
given a 1,000-line check log when opened then lines 801–1000 show and "Show all" shows all; given /i/7?stage=verify&file=checks.log on reload then that file is open |
| UI-76 | When an intent had several attempts, the map follows the attempt picked, and it replaces the old time and file sections. With more than one attempt a switcher above the map picks one (the latest by default); the map, totals and files follow it, and the choice is in the URL. The map replaces the "Time per step" card and the "Run files" section of UI-62. The timeline stays, folded under the map as "All moves", with exact times. | given two attempts then the switcher shows attempt 2 by default and picking attempt 1 changes the map and the URL; no "Time per step" or "Run files" section exists |
| UI-77 | The map works at 360 px wide, by keyboard and by screen reader, and moves no more than the spinner. On desktop it runs left to right and wraps to a second row, never scrolling sideways. At 360 px it is a vertical stepper and a stage's files open inline beneath it (accordion). It is an ordered list of stages, each a button that says whether it is expanded and has its outcome and time in its accessible name; arrow keys move between stages; return links are text (UI-74). No motion beyond the spinner, and none at all when the system asks for reduced motion. The UI fixture has an intent with a repair loop, a skipped stage, a wait on the owner and two attempts; the screenshots (UI-69) capture the map with a log open in light, dark and mobile. | manual: at 360 px the map is a vertical stepper; with keyboard only, arrow keys move between stages and Enter opens one; a screen reader reads each stage's outcome and time |
| UI-78 | The overview shows every open intent as a tiny plane on one metro line, at the station it is at now, so the whole situation fits in one glance. A full-width strip under the headline, above the cards. Stations, left to right: parked (a dashed siding), queue, 00 gate, 01 spec, you (spec review), 02 plan, you (plan review), 03 code, ci, you (code review), landed (today). Review stations are diamonds, the others squares. A header line reads twr://airspace and the counts: in flight, need you, landed today. Where a plane sits: a wait for a review at its review station; a question or block at its phase's station; CI at ci; any other step at its phase's station (spec, plan or code); an intent waiting on the owner without a mission at the gate; the picker's list (UI-61) without a skip at the gate, with a skip in the queue in pick order, parked on the siding; intents done today (local date) in the landed block. Dropped intents aren't shown. The server builds it once (airspace in GET /api/state): each plane's intent, title, repo, state (UI-56), station, two brief lines and phases; the landed summary (count, cost, agent time, the latest titles) and the counts. |
given one mission waiting for plan review, one in CI and one parked intent then planes sit at the second you, at ci and on the siding; a dropped intent has no plane |
| UI-79 | Active planes move a little where they stand, each state in its own way, and every motion stops for people who ask for less motion. Colour is the state's colour (UI-56); motion tells states apart without it: Working bobs 2 px with a square ping; Waiting on CI loops around its station; Needs you blinks a beacon; Cooling down is still with a frost mark *; Stalled jitters 1 px with !; Up next blinks next; Queued stands still; Parked is dim and drifts a z. Light packets flow along the line from gate to landed. A plane that changes station glides there in 0.6 s on the next update. Only position and opacity animate, declaratively, with no script loop. With reduced motion asked for, nothing animates and moves are instant. Nothing flashes more than 3 times a second. |
given the system set to reduced motion then no plane, packet or beacon moves and a station change is instant |
| UI-80 | Pointing at a plane opens a small radar-style card under it with a short brief and a way into the intent. Hover, keyboard focus or a tap opens it: ID and repo, the state, the title, two brief lines (step and time in step, then cost and model; or the ask and how long it has waited; or the place in line; or why it's parked), a spec · plan · code strip, and "Open intent ›" to /i/<id>. Corner brackets and a stem in the state's colour; one scanline sweep as it unfolds. The landed block's card sums today (count, cost, agent time, latest titles) and links to /activity?since=24h; a +n marker's card lists the planes it hides, each a link. The card stays open while the pointer is on the plane or the card, closes 0.25 s after it leaves both or on Escape, never runs past the strip's sides, and opens upward when it would run off the bottom of the screen. A click on a plane whose card is open goes to the intent; on touch the first tap opens the card. |
given a working plane when hovered then its card shows step, time in step, cost and model; when the pointer leaves for 0.25 s then it closes; given a tap on touch then the card opens and a second tap opens /i/<id> |
| UI-81 | The owner can hide the strip, and the page remembers it. "Hide airspace" in the strip's header collapses it to the header and the counts in one line; "Show airspace" brings it back. Remembered in this browser's local storage under the key towercab.airspace; shown by default; survives reloads and live updates. |
given the strip hidden when the page reloads then it is still one line; given storage cleared then it shows |
| UI-82 | The strip reads well in light and dark, on a wide screen and on a phone, and with crowded stations. Its own colour tokens for light (a near-white panel) and dark (a near-black panel); a minimal futuristic look: faint dot grid, hairline track, monospace labels; no glow, gradient or shadow. Drawn at the strip's real width (1 drawing unit = 1 px, so text stays 11 px), 190 px tall, steps spaced evenly. Below 560 px wide it runs top to bottom with planes pointing down. A step station shows up to 3 planes (2 and a +n marker beyond 3), the queue up to 4, the siding up to 3, the landed block up to 6 (more only in its count). Planes are links named "title, state, place"; the strip says its counts in one sentence for screen readers. |
given 5 planes at 03 code then 2 planes and +3 show; given a 400 px wide screen then the line runs top to bottom |
| UI-100 | An intent whose plan is approved but waits for the build slot shows as ready to build, behind the intent that holds the slot. State Queued with the sentence "Ready to build, behind " (UI-56, UI-59). On a repo's page (UI-63, UI-141) "In progress" lists the running intent, then ready intents in build order, then the intents ahead of build (with a count n of <ahead> ahead), then the queued ones with the reason "repo has intents ahead". On the airspace (UI-78) a ready plane sits at 03 code. |
given intent B with an approved plan while A builds in the same repo then B reads "Ready to build, behind A" and its plane sits at 03 code |
| UI-101 | The owner can start an intent's design now from the page. "Start now" on the intent page (UI-62), on its Up next row (UI-61) and in the plane card (UI-80), only for an intent that has no mission yet and is not parked. It does what towercab start does (LOOP-63), including going over the limit. The reply says in one sentence that design started, or the error. |
given an unstarted, unparked intent then "Start now" shows; given a parked intent or one with a mission then it does not |
| UI-102 | The API has a start action for the button. POST /api/actions accepts {action: "start", intent} (UI-65, UI-66) and records the start command (LOOP-63); an unknown or already started intent → the command's error text. |
given {action: "start", intent: "web-3"} for an unstarted intent then a start command is recorded; given a started intent then the reply is the command's error |
| UI-105 | After the owner merges, drops or starts over from the page, every view shows that action as done, with no button for it, until the loop moves the mission on. A successful merge, drop or retry on a PR (UI-66) stores a note on the mission: the action, the PR number and the time. The note is for display only: it never moves the mission (only the state machine does, after GitHub confirms) and it is removed when the mission leaves that wait (any move out of the approval stage, or a new attempt). While the note exists, the needs-you card (UI-58) on the overview and the intent page (UI-62) and the plane card (UI-80) replace the actions with one line: "Merged by you · the factory moves on within a minute", "Dropped by you · …" or "Started over by you · …". The note comes from the API (UI-65), so a reload or a second tab shows the same card. The count of things needing the owner (UI-57) still counts the mission until it moves on. A reply, or an action that failed, stores no note. |
given a successful merge when the page reloads in another tab then the card reads "Merged by you · …" with no Merge button and the mission is still in its approval stage |
| UI-106 | A successful merge, drop or start over asks the loop to look at that PR at once, so the mission usually moves on within seconds. After GitHub accepts the action, the action sets a stored flag poll:<mission id>. On its next pass (LOOP-12) the PR poll reads that mission's PRs at once, ignoring the 60 s throttle, and clears the flag; a move clears it too. The state machine still moves the mission only on what GitHub reports. If the flag is lost, the normal 60 s poll still moves it. |
given a successful merge then the flag is set and the next pass reads the PR before 60 s have passed; given a failed merge then no flag is set |
| UI-107 | Merging a PR that is already merged does nothing on GitHub and says so, with no error. Before merging, the merge action reads the PR's state as the owner. MERGED → no merge call; it stores the note (UI-105), asks for the poll (UI-106) and replies "Already merged; the factory is moving on." CLOSED without merge → error "The # is closed, so there is nothing to merge." Otherwise it merges as in UI-66. A second merge request while the note exists takes the already-merged path and never calls merge. |
given PR #41 already merged when Merge is pressed then GitHub gets no merge call and the reply is "Already merged; the factory is moving on."; given it closed unmerged then the reply is "The plan PR #41 is closed, so there is nothing to merge." |
| UI-108 | If the mission has not moved on 5 minutes after the action, the card says so and links to the PR. The "moves on within a minute" line (UI-105) applies for the first 5 minutes after the note's time. After that the card says "Merged by you min ago, but the factory has not moved on yet" (and likewise for drop and start over), with a link to the PR, and still shows no button for that action. Times follow UI-71. | given a merge note 7 minutes old and no move then the card reads "Merged by you 7 min ago, but the factory has not moved on yet" with the PR link and no Merge button |
| UI-109 | While the loop is paused, the airspace says so in its header and on the strip, and offers Resume when that helps. Owner pause (LOOP-15): the twr://airspace header reads GROUND STOP · paused since <time> by you (time per UI-71; GROUND STOP · paused when there is no pause time, UI-111); the strip gets a calm amber hatched band; a Resume button sits next to that text and does what the Resume action does (UI-66, resume command); the reply shows as for other actions. Health pause (a health block is open, LOOP-10): the header names the service and the reason, such as GROUND STOP · can't reach GitHub, from the same problem the Problems section shows (UI-60); no Resume button, because resuming would not help. If the owner has also paused, the owner's header and button show. airspace in GET /api/state (UI-78) carries stop: null, {kind: "owner", since} (since null without a pause time) or {kind: "health", service, reason}. Text follows the factory's writing rules. Hiding the airspace (UI-81) keeps the one-line header with GROUND STOP in it, and Resume where it applies. |
given an owner pause at 09:00 then the header reads GROUND STOP · paused since <09:00, relative> by you with Resume; given GitHub unreachable and no owner pause then GROUND STOP · can't reach GitHub and no Resume |
| UI-110 | During a ground stop, the stations that feed new work are dimmed and marked held, while planes already flying keep moving. Parked, queue and 00 gate are dimmed and carry a small held mark. A plane past the gate (spec, plan, code, ci and the review stations) is drawn and moves as usual, because a pause lets running work finish. A plane waiting at merge shows a held tag, because merges wait until resume (PIP-22); its card (UI-80) says "Held until you resume" (owner pause) or "Held until is reachable again" (health pause). The band and marks are static and add no motion; with reduced motion nothing new moves (UI-79). The held marks are also text for screen readers (UI-82), and the strip's sentence says the loop is paused or why it is stopped. Looks right in light, dark, wide and phone (UI-82). |
given an owner pause with a plane waiting at merge then queue, siding and gate show held, the merge plane shows held and its card says "Held until you resume" |
| UI-111 | The loop remembers when the owner paused it, so the page can say since when even after a restart. Applying the pause command also stores the pause time paused_at (ISO time, the loop's clock) in the same transaction that sets the paused flag; applying resume deletes both. Pausing when already paused keeps the first paused_at. Nothing else about the commands changes (LOOP-15). A pause stored without paused_at gives since: null in UI-109. |
given pause at 09:00 and pause again at 10:00 then paused_at is 09:00; given resume then neither the flag nor the time remains |
| UI-112 | While the loop is paused, the browser tab title starts with ⏸, so the owner notices it from another tab. The title is ⏸ before the usual title for an owner pause and for a health pause; it goes back when the stop ends, on the next live update (UI-67). |
given an owner pause then the tab title starts ⏸ ; given resume then the next update removes it |
| UI-120 | The airspace shows a change after its merge, until it is live and tested. Two square stations sit between the code review's you and landed: 04 deploy and 05 e2e. A plane at deploy or rollback, or waiting after one, sits at 04 deploy; at e2e or revert, or waiting after one, at 05 e2e; a plane in a fix round sits at its phase's station as usual. The two stations are drawn while a plane sits at one, and for 7 days after any mission entered deploy, so the line keeps one shape while a repo ships; a factory without a live repo sees the line without them. |
given a mission entered deploy 6 days ago and no plane there now then both stations are drawn; given 8 days then they are not |
| UI-121 | The intent's state says in words where a shipping change is. deploy: "waiting for the deploy"; e2e: "running e2e"; rollback: "rolling back"; revert: "reverting"; a build round after a rollback: "fixing (n of 2)"; a mission held at the gate by SHIP-14: "repo on hold". rolled back, revise and rollback failed are needs-you cards with their asks. |
given the first build round after a rollback then the state reads "fixing (1 of 2)" |
| UI-122 | The intent page links the last e2e log, so the owner sees why a change was rolled back. The intent page (UI-62) shows "e2e log" linking the newest runs/<intent>/e2e/*.log, served redacted (SHIP-2), when there is one. |
given two e2e logs then "e2e log" links the newer; given none then no link |
| UI-123 | The stage map follows a change after its merge, so the owner sees the deploy, the e2e run and any rollback in the path. After Merging the map has Deploying (deploy), e2e tests (e2e), Rolling back (rollback) and Reverting (revert) (SHIP-1), each listed only when the attempt visited it, so a repo without a live block sees the map without them. Merging → Deploying is a pass, not a step back. A red deploy or e2e run is red with a return link to Rolling back, labelled with its reason ("e2e tests failed: e2e suite drill failed twice …"). A rollback that is back on the last good version is passed, with a return link to Building labelled "fix round: back on 2d6ad8c; fix 1 of 2". e2e logs written while rolling back (the smoke suites) belong to Rolling back; the others to e2e tests. |
given merge → deploy → e2e red → rollback passed → build then e2e tests is red linked to Rolling back, and Rolling back is passed linked to Building "fix round: back on 2d6ad8c; fix 1 of 2"; given a repo without live then no ship stage is listed |
| UI-124 | The intent page lists every code PR of a shipping change, so the PR that was rolled back stays one click away. A fix round (SHIP-11) remembers the code PR it replaces in the mission's ship.prs. The page shows the first code PR as "Code", each later one as "Fix 1", "Fix 2", and the revert PR (SHIP-12) as "Revert". Earlier ones are merged. A code PR is merged once the mission is past Merging (deploy, e2e, rollback or revert), not only when the intent is done. |
given code PR #50 rolled back and fix PR #53 open then the page lists "Code #50 merged" and "Fix 1 #53 open" |
| UI-125 | The airspace tells a plane that is rolling back or fixing a live failure apart from ordinary work. A plane at rollback or revert, or waiting after one, flies backwards in the stalled colour with a ↩ mark. Its card's first line says which suite went red and which version it goes back to ("drill red · back to 2d6ad8c"); without a red suite it says "Rolling back" or "Reverting". A plane in a fix round (SHIP-11) carries a fix n/2 mark at its station. The marks come from the plane's ship field ({kind: "rollback" | "revert" | "fix", mark}), absent for every other plane. |
given a rollback after suite drill failed with last good 2d6ad8c then the plane shows ↩ and its card starts "drill red · back to 2d6ad8c" |
| UI-126 | A needs-you card says in plain words what is asked, why and what the change means for its users; the technical text stays one click away under "Details". Order: the intent's title; the ask (LOOP-14 Waits, PIP-110); for an approval, the plain reason it waits; the plain brief (the card's brief, PIP-107: spec → the design's, plan → the plan's, code → the review's, else the worker's; question → the asker's; a blocked wait without a PR → the factory's note); the risk block (AUT-20) and the open points with it; then the question's options, the access list, the reply box and the actions. The card's details (Markdown) fold under a "Details" toggle, closed at first and kept open across updates: spec → the design's PR text, its concerns and the questions it left open; plan → the plan's approach and risks; code → the review's findings; a question with a brief → its context (shown in the open otherwise); a blocked wait → the reason the factory saw (a check's name, an error line). A mission from before briefs keeps the old card: the review's summary leads, nothing folded; only the card falls back to it, and the intent page never shows it as plain (UI-127). |
given a plan approval with a brief then the brief comes before a closed "Details" toggle, and opening it shows the plan's approach and risks and it stays open after a live refetch |
| UI-127 | The intent page puts the latest plain brief first, with its technical text folded. With a needs-you card, the card carries it (UI-126). Without one, the page's brief is the latest plain brief of the newest attempt (code, else plan, else spec) under "In plain words", with the page's details folded under "Details" and the risk block beside it. |
given an intent building with a plan brief and no code brief then "In plain words" shows the plan brief |
| UI-128 | The screenshots show a needs-you card with its plain brief first and its Details folded. The screenshot run (UI-69) saves card-brief-<mode>.png of the plan card and checks that the brief comes before the "Details" toggle, which is closed, that opening it shows the technical text, and that it stays open across a live refetch; it also saves the opened card as card-brief-open-dark.png. |
given the screenshot run then card-brief-light.png, card-brief-dark.png, card-brief-mobile.png and card-brief-open-dark.png exist and the order and toggle checks pass |
| UI-130 | The owner adds a repo from the page by typing only its owner/name. The Repos page's All view (UI-142) ends with an "Add repo" form: one field (owner/name) and an "Add repo" button; not shown when the page is read-only (UI-2). It posts {action: "addRepo", github, name?} to /api/actions, which runs LOOP-66's repo add in the loop (source ui). The request ends once LOOP-66's checks pass (the server's 15 s request limit stays; a clone may take longer): the reply is Adding <name> (<owner/name>, base <base>): the factory is cloning it now. It shows on the Repos page when done, and its intents start from then on., or the refusal's reason (LOOP-66) shown on the form. The clone goes on in the loop; the live update shows the new repo when it is stored (the page's version covers the stored repo list). A clone or store that fails becomes a problem (UI-60), The factory couldn't add the repo <name> (<owner/name>) with the reason, until the repo is added again (stored flag repo-add-failed:<name>). No repos → "No repos yet." |
given acme/shop typed then the reply starts Adding shop (acme/shop, base main) and the repo appears after the clone; given the clone fails then the Problems section shows The factory couldn't add the repo shop (acme/shop) with the reason |
| UI-131 | An intent that waits because its repo is unknown offers to add that repo right on its card. The unknown repo card (UI-58) says Its repo “<name>” isn't one the factory knows yet. Check the GitHub repo below and press Add this repo, or change the intent's repo. and carries addRepo: {name, github}: the intent's repo as the short name, and owner/name prefilled with the owner most known repos share ("" when there are none). The page shows the field and an "Add this repo" button, which adds the repo under that short name (UI-130). Once the repo is known, the card says The factory knows its repo “<name>” now and queues it again within a minute. and offers no button; the loop clears the wait (LOOP-68). |
given an intent naming repo shop and known repos acme/web, acme/api then the field is prefilled acme/shop; given the repo added then the card has no button and says it queues again within a minute |
| UI-133 | A Board tab shows every intent as a card on a grid, one column per station and one row per repo, so the owner sees at a glance where each repo's work stands. /board, a top tab after Overview. Columns, left to right, in the airspace's words: Queue, Gate, Spec, Your spec review, Plan, Your plan review, Code, CI, Your code review, Deploy, e2e, Done; Deploy and e2e appear exactly when the airspace draws its ship stations (UI-120). Rows: one per [repos] repo in config order, then any other repo an intent names or that has spend (UI-137), then "Unknown repo" for intents the listing doesn't know; a row without cards says so in one line. A card shows the intent's title and its state chip (UI-56) and links to the intent page (UI-62). The server builds it (GET /api/board): columns (key, label, count), repos (repo, GitHub name, open and done counts, spend of UI-137, the cards per column), the spend totals and error. It updates live like the other pages (UI-67). When the intents repo can't be read, the page says so above the board (the reason, and that only started intents are shown) instead of showing empty rows as if nothing were there. |
given no ship stations then the columns run Queue … Your code review, Done; given the intents repo unreadable then a notice with the reason shows above the board |
| UI-134 | A card sits in the column of its plane on the airspace, so the board and the airspace never disagree. Open intents take the station of their airspace plane (UI-78, UI-100, UI-120), worked out by the same logic; a plane whose intent the listing doesn't know sits in the "Unknown repo" row. Parked intents sit in Queue below the queued ones, with their Parked chip. Done holds the intents done in the last 7 local days (the same days as the spend of UI-137), newest first, saying when; dropped intents aren't shown. A row header counts its open intents and those done in the last 7 days. | given a plane at ci then its card is in the CI column; given an intent done 8 days ago then it is not on the board |
| UI-135 | Each repo row folds to its header with one toggle, and the page remembers it in this browser. The toggle is a button on the row header that says whether it is expanded. Folded repos are kept in this browser's local storage under towercab.board.folded (a JSON list of repo names); every read and write tolerates storage being unavailable, so without storage a row still folds until the page reloads. |
given api folded when the page reloads then api is still folded; given storage blocked then folding works and is forgotten on reload |
| UI-136 | The board stays usable with many repos and intents, and on a phone. Only the board scrolls sideways, never the page. Column headers stick to the top of the board and repo names to its left while it scrolls. A column with cards is at least 144 px wide and one with no cards in any repo 64 px, so a board with seven busy columns fits a 1280 px screen without scrolling; a wider one scrolls inside the board. At 720 px wide or less the board is one column: a section per repo listing its cards under the station names, stations without cards left out. Light and dark like the rest of the page (UI-68). | given seven busy columns at 1280 px then nothing scrolls; given 700 px then each repo is a section and empty stations are absent |
| UI-137 | The factory keeps what it spent on each repo today, in the last 7 days and in all time, with the totals, and shows it on each board row. Today and 7 days sum the runs' costs by local day, over the same days as MET-29; all time sums each mission's recorded cost (MET-28) over every mission of the repo, finished, dropped or open. The totals are the sums over all repos, so today's total equals the top bar's spend today. The board's repo header reads "$1.20 today · $8.40 7 days · $52.10 all time", and the board's heading shows the totals. Every repo that cost something has a row, so the rows add up to the totals. One shared computation serves every page; all time is one grouped sum, and only missions updated in the last 8 days have their runs read. | given web runs of $0.70 and $0.50 today and missions totalling $52.10 then its header reads "$1.20 today · … · $52.10 all time" and the totals equal the top bar's spend today |
| UI-140 | A Repos tab shows one repo at a time, picked from a row of chips. /repos, the third top tab; /queue (its old name) shows the same page, so old links work. A chip per repo (the queue's lanes, UI-63) with its open intents (in progress, up next, waiting, parked) and a dot when one waits on the owner, after an "All" chip with the total. The chosen repo is in the URL, /repos?repo=<name>, so it can be linked; an unknown name shows All and says so. Data: GET /api/repos[?repo=<name>] returns chips, selected, repo (UI-141) or all (UI-142), the spend totals and error. The page updates live like the others. |
given /repos?repo=nope then the All view shows with a line saying nope is unknown; given /queue then the Repos page shows |
| UI-141 | A repo's page says what people can do with it, then what the factory is doing in it and what that cost, in a few short sections. In order: the repo's name, GitHub name and "n of m ahead" (UI-100); "What people can do" (UI-143); "In progress" (its missions as work rows, UI-59, owner waits included); "Up next" (numbered, in pick order); "Waiting" (each with its reason in words, UI-61; only when there is one); "Parked" (folded by default; only when there is one); "Recently done" (UI-63's last 10 done or dropped, folded; only when there is one); "Spend" (today, 7 days, all time, UI-137). | given a repo with nothing waiting or parked then the sections are name, What people can do, In progress, Up next, Recently done (if any) and Spend, in that order |
| UI-142 | With no repo chosen, the page shows one short card per repo. A card per repo: its name, GitHub name, counts in words (in progress, needing the owner, up next, waiting, parked; zero counts of the last three left out) and its spend; it links to the repo's page. The "Add repo" form (UI-130) ends the All view. | given a repo with 2 in progress and 0 parked then its card reads "2 in progress" and does not mention parked |
| UI-143 | The repo's page leads with its feature list, grouped as In progress, Live and Planned. From features/<repo>.md as the local checkout of the intents repo has it (INT-37, with no pull) and the planned intents of INT-40. A line the factory added for an intent links to the intent's page. "Edit the list" links to the file on GitHub when it exists. Nothing listed → "No features listed yet." |
given features/shop.md with two live lines then they show under Live; given no file and no planned intents then "No features listed yet." |
| UI-144 | The first time a repo's page is shown, the factory asks once for its feature list to be read from the repo, and the page offers to read it again. GET /api/repos?repo=<name> for a configured repo whose read (INT-39) was never asked for, and whose file has no line of its own, asks for it through the features action, only while the factory runs; the All view and later looks never ask again. Under the list, one sentence says where it stands: asked, reading, Last read from the repo <when>: <n> live features., or the failure and its reason. "Refresh from the repo" (shown while the factory runs, unless a read is asked for or running) posts {action: "features", repo}; the reply says the list is rewritten within a few minutes, or that a read is already on its way. The read's stored state (features:<repo> flags) is part of the page's version, so an open page shows a read asked for, started, done or failed without anything else moving. |
given a first look at shop with an empty file then one features request is made and a second look makes none; given the read finishes then the open page shows Last read from the repo … : 3 live features. |
| UI-145 | The Repos page works at phone width and in light and dark, like the rest of the page. Chips wrap onto more lines; the All view is one column at 720 px or less; nothing scrolls sideways (UI-68). | manual: Repos page at 360 px in light and dark; chips wrap, one column, no sideways scroll |
Data model
The page stores nothing of its own beyond two browser conveniences; it exchanges these shapes with the loop's API (UI-65). Times are ISO 8601; money is US dollars.
| Entity | Field | Type | Required | Limits / values |
|---|---|---|---|---|
| Action request | action |
string | yes | merge, reply, retry, drop, pause, resume, start, addRepo, features |
| Action request | intent |
string | for intent actions | an intent ID |
| Action request | text |
string | for reply |
the reply text |
| Action request | github |
string | for addRepo |
owner/name |
| Action request | name |
string | no | short repo name; default from LOOP-66 |
| Action request | repo |
string | for features |
a configured repo name |
| Action reply | message or error |
string | yes | one sentence |
| State | version |
string | yes | changes whenever anything shown changes |
| State | airspace |
object | yes | planes, landed summary, counts, stop |
| Plane | intent, title, repo, state, station |
string | yes | state from UI-56; station from UI-78 / UI-120 |
| Plane | brief |
two strings | yes | the card's two lines (UI-80) |
| Plane | phases |
spec, plan, code | yes | each done, current, to do or skipped |
| Plane | ship |
object | no | {kind: rollback|revert|fix, mark}; absent otherwise |
| Ground stop | stop |
object or null | yes | null, {kind: "owner", since} (since may be null), {kind: "health", service, reason} |
| Done note | action, pr, at |
string, number, time | yes | merge, drop or retry; removed when the mission leaves the wait |
| Card | brief |
string | no | plain brief (UI-126) |
| Card | details |
Markdown | no | folded technical text |
| Card | addRepo |
{name, github} |
only on unknown repo |
github may be "" |
| Stage visit | stage, round, start, end, outcome, reason, returnTo, returnLabel, files |
mixed | stage, round, start, outcome yes |
round ≥ 1; end absent while running; outcome one of UI-72 |
| Stage file | stage, round, kind, name, size, time |
mixed | yes | kind one of UI-72; empty files omitted; served ≤ 2 MB |
| Board | columns, repos, totals, error |
object | yes | see UI-133 |
| Repos | chips, selected, repo or all, totals, error |
object | yes | see UI-140 |
| Browser storage | towercab.airspace |
string | no | hidden or shown; default shown |
| Browser storage | towercab.board.folded |
JSON list of repo names | no | default empty |
Out of scope
- Editing intents in the page: the owner edits them in the intents repo or with
/intent. - Remote access: the page is for this machine only.
Decisions
- Designed from the operator's jobs, not from the data: an earlier version listed every field it had; this one shows what needs a decision first and hides the rest one click away.
- Actions act on GitHub as the owner, so the PR history stays the one record of who decided what.
- A small component-based single-page app with live reload in development keeps the UI quick to change at a few KB; the reference build uses a lightweight component library and a bundler, which the spec does not require.
- Screenshots in every test run: a UI nobody looks at drifts; the review and CI see it.
- The board and the airspace place intents with the same logic, so the two views can never disagree.
- The done note is for display only; the state machine alone moves a mission, and only on what GitHub reports.
- This spec drops the source's reserved rows UI-103 and UI-104, which carried no behaviour, and its removed rows (UI-3, UI-5..27).
- File kinds and texts that named the coding agent by product are written generically here ("Agent's answer", "agent settings"); the reference build uses Claude Code.
From MajidBabaeifar/software-factory at fb34378, licence CC-BY-4.0.