Pipeline Roles Spec Writer - axolotl-logic/project-buddy GitHub Wiki
Draft from integration c1797bd (2026-10-16). It describes today's code, plus the changes marked "proposed". Adjust it as the split settles. Index: Pipeline roles.
The spec writer turns an issue that triage took into exactly what to build. It reads the practices, the wiki, the code and, if needed, the web. Then it hands the implementor one of three things: questions for the issue's author; a split into sub-issues; or a spec with everything needed to build it. A spec comes with:
- the interfaces the tests will drive, whose
newentries'wheres, the entry-point stubs, are the only paths it lists: a spec names no other files the change touches (Axlog, 2026-10-17, #388, #391); - proposed (
spec-writes-failing-tests): commits on the issue's branch that stub each entrypoint that doesn't exist yet, and failing tests for every Behaviour item.
The implementor makes those tests pass. The spec writer edits only tests and stubs, never the behaviour itself.
Proposed (spec-writer-opens-pr): the spec writer's lifecycle also creates the issue's branch and opens its draft PR, both by script. Triage and the implementor no longer touch the PR.
-
Stage:
spec(src/workflow/stages/spec.ts,spec-task.ts,spec-split.ts, and the spec half ofbuild-replies.ts). -
Ops:
ask,split,save-spec(src/ops/ask.ts,split.ts,save-spec.ts). Proposed (spec-writer-opens-pr): alsostart-pr, from triage, andclose-pr, from the implementor (src/ops/pr.ts). -
Session: spec writer, agent
issue-pipeline-specifier, Opus, replies withSPEC_SCHEMAin the sweep andSPEC_WRITER_SCHEMAon the role runner. Built (#334): its tools areread,grep,find,ls,edit,write,bash,web_search,fetch_content,get_search_contentandcodemode, and the job addspipeline_commit. Its env holds no gh token. Its task, in the sweep's spec stage and on the role runner, carries the writers' rule against artificial load on the shared machine (NO_ARTIFICIAL_LOAD_RULE,writers-make-no-load, #409). Since #335 it is held tocontextLimits, as the writers' sessions are, andjob.jsonrecords its context budget (roleBudgets.spec, 300000 by default, which must be abovecontextLimits.hard). -
Job role:
writer(#334), so a spec job runsexit-pushas it exits, though the session holds no token (pipeline-commit). The orchestrator still schedulesspecas a reader, because a spec-stage issue has no stubs yet to order writers by. -
Proposed (
spec-writes-failing-tests):- verify's
fails-before-fixcheck moves here, as a check of the spec writer's tests (see Lifecycle); - (
spec-writes-wiki) it has wikiwriteaccess, and commits the spec's Wiki updates itself.
- verify's
From src/workflow/schemas.ts. Every object is closed (additionalProperties: false) and every field is required. The stage checks the reply by hand (build-replies.ts).
// SPEC_SCHEMA: the spec writer's reply.
{
decision: "ready" | "needs-info" | "split"; // proposed: | "no-work"
spec: string; // the spec, ending with "<!-- end of spec -->"
questions: string[]; // at most 5; asked when decision is "needs-info"
subIssues: Array<{ // at most 5; used when decision is "split"
title: string;
body: string; // the part's slice of the spec and its acceptance criteria, sent as written
after: number[]; // 0-based indexes of earlier parts that must merge first
}>;
}Since #391 neither the reply nor a split part has files (#388's decision: issues and specs name only entry-point stubs). Before, files listed the repo paths or globs the change would touch, was saved as st.files, and each part's files became a Files: line at the end of its body.
Built (#391), added to SPEC_WRITER_SCHEMA only:
{
wikiOnly: boolean; // true only when the wiki pages it changed are the whole change, with noTestsReason set; a reply without it reads as false
}Proposed (spec-names-test-interfaces), added to SPEC_SCHEMA:
interfaces: Array<{ // at least 1 when decision is "ready", unless noTestsReason is set
name: string; // e.g. "parseQuota", "ops.mjs merge", "POST /api/plans", "/settings page"
kind: "function" | "module" | "cli" | "ops-request" | "http" | "ui" | "config";
where: string; // "file:line" for an existing interface, or the new file's path
status: "existing" | "new" | "changed";
signature: string; // its call shape: parameters and return, flags, request and reply, or route and roles
behaviours: string[]; // the Behaviour items tests check through it, quoted from the spec
}>;Proposed (no-tests-of-implementation), added to SPEC_SCHEMA:
noTestsReason: string | null; // set only when every test that could be written would test implementation, not promised behaviourBuilt (#333): these proposed fields, and decision no-work, are in SPEC_WRITER_SCHEMA (src/roles/spec-writer/reply.ts), which spreads SPEC_SCHEMA's fields, with noTestsReason typed ["string", "null"]. The reader specReadingOf(output, depth) checks a reply with zod. SPEC_SCHEMA itself stays as it is for workflow.js's stage.
Built (#335): SPEC_WRITER_SCHEMA also requires status ("done" | "handoff") and summary, as the writers' schemas do. A reply with status handoff reads as a handoff with its summary, whatever its other fields; that covers the session's own handoff and the reply the job builds for a session it stopped at contextLimits.hard, which fills the other fields with empty values. A finished session sets done with a one-line summary.
For review:
-
questionsandsubIssuesare required even when unused, so areadyreply sends empty arrays. - A split must have 2 to 5 parts, but the schema allows 0 to 5, and the stage enforces the minimum.
- Proposed: a sub-issue's body carries its slice of
interfacesin its spec section.subIssuesgets nointerfacesfield of its own, because each sub-issue gets its own spec.
The spec writer runs as a state machine. The shared rules (the table and transition, effects, the runner, restarts) are in Shared. Only the session state runs a model. Wiki access: write (spec-writes-wiki), in the job's docs/, at the issue's wiki branch, or at the wiki's base when that branch doesn't exist. Code access: write, in repo/ (#334). pipeline_commit lets the session commit any path; the test-file and stub rules are checked by spec-tests-check after the session, and their git-only part also when the session replies (see Reply check in the session below).
Built (#334): job.mjs --stage spec runs this machine (roleOf("spec") in src/roles/registry.ts), and refuses --resume. Its script checks are src/roles/spec-writer/checks.ts (the reply check with coverage, publish-spec and close-old-pr) and checks-git.ts (the start heads and what the stage committed since them, spec-tests-check, the no-work test run, the removal of the spec draft and the handoff, and the resets). Its end states are in ends.ts (#335). claim and retry answer ok, because the job has already claimed the issue. workflow.js's spec stage is unchanged.
Entry: stage spec. data: attempts (sessions run, at most 2), continuations (sessions started from a handoff, at most the config's maxContinuations, #335), both counted within one spec stage and back at 0 after a return to triage (#454), startSha, startWikiSha, committed (what the stage committed since the start heads, as checkout or resume listed it), reply, checkOutput, specTestsSha.
| State | Kind | Does | Result → next |
|---|---|---|---|
claim |
script | claim |
ok → checkout; not ours → end released
|
checkout |
script | checks out agent/issue-N when it exists on GitHub; otherwise creates it locally from the base, or from the prerequisite's branch for a stacked issue (--stack-on). The first pipeline_commit push creates it on GitHub. Checks out the wiki at the issue's wiki branch. Saves startSha (null when the branch is new) and startWikiSha, unless an earlier start saved them; lists what the stage committed since them, measured from the spec's base (see The spec's base below) (#335: test files, other files such as stubs, whether the spec draft is at the head, and the wiki pages changed); runs commands.setup; writes issue.md, triage's plan, splitSuggested and the practices list |
ok → session
|
session |
session | the spec writer, with checkOutput when retrying, and the committed files to continue from (Locate); it commits through pipeline_commit as it goes and replies with SPEC_WRITER_SCHEMA; its reply is checked before the session ends (Reply check in the session). Held to contextLimits (#335): past soft the job steers it once to commit the tests, stubs and spec draft it has and write the handoff; past hard the job stops it, writes the handoff (stop-handoff.ts), and its reply is a handoff. A handoff, stopped or replied, gets the session's read ledger appended (read-ledger.ts) |
reply → check-reply; failed → retry, except a transient provider error, which ends the run held as rate-limited with attempts and continuations as the run began (#446) |
check-reply |
script | the end marker, the required sections, interface coverage |
ready → check-commits; no-work → confirm-no-work; needs-info or split → reset; bad → retry; handoff → resume, adding 1 to continuations and none to attempts, while continuations < maxContinuations; else end stopped(too-large) (out-of-room) |
resume |
script |
spec-commits: lists what the stage committed since the start heads again (#335) |
ok → session, a continuation whose task begins with the handoff lead |
check-commits |
script |
spec-tests-check (below) |
pass, with code to build → publish-spec; pass, from a wikiOnly reply with noTestsReason set → publish-spec (wiki-only); fail → retry
|
confirm-no-work |
script | runs the spec writer's tests, if any, at the head, which is the base plus those tests | pass → reset; fail → retry
|
retry |
script | records the failure in checkOutput
|
attempts < 2 → session; else end stopped(the spec writer failed twice)
|
reset |
script | resets the branch to startSha and the wiki branch to startWikiSha, and pushes both (--force-with-lease). A branch this stage created (startSha null) is deleted on GitHub instead. Deletes the handoff file (#335) |
needs-info → ask; split → split; no-work → stop-no-work
|
ask |
script | ask |
ok → end saved(triage), with agent:needs-info and the spec so far, and attempts and continuations saved at 0 with the start heads kept (#454) |
split |
script | split |
ok → end released, with agent:split on the parent |
stop-no-work |
script | posts the evidence | ok → end stopped(no-work), label agent:stopped-no-work
|
publish-spec |
script |
save-spec when the spec is too long for the state; posts the ## Spec comment; then removes the spec draft from the branch in one pushed commit and deletes the handoff file, so the implementer doesn't take it for its own (#335) |
ok, not wiki-only → open-pr; ok, wiki-only → close-old-pr
|
open-pr |
script |
start-pr: finds the branch's PR, or opens a draft PR with the derived title and body (Branch and PR, below); updates the body of one it finds |
ok → end saved(implement)
|
close-old-pr |
script | closes a PR left open by an earlier spec of this issue, with its signed comment; nothing when there is none | ok → end saved(review) on the wiki-only path, with no PR |
Ends save these hand-off fields: spec or specFile, interfaces, wikiOnly, specTestsSha, noTestsReason, specWritesTests (true), and from open-pr pr, headSha and implemented: false (#334). Since #391 they save no files. Locate reads the saved wikiOnly; a state saved before #391, with files and no wikiOnly, keeps the old reading: wiki-only when noTestsReason is set and every entry of files is under the wiki's folder.
From the finalizer (proposed: merge-only-when-ready, finalizer-role). The finalizer sends an issue back to spec with a finalizeNote in two cases:
-
No PR: locate finds the spec published and its tests committed, so the job goes straight to
open-pr, which opens the draft, and saves atimplement. The implementor's no-work checks then carry it on. -
Wiki conflict or failed
wiki-check: awiki-syncentry rebases the issue's wiki branch onto the wiki's base. On a conflict, the spec writer's session resolves it with the note and the conflicting pages. It then runswiki-check, pushes with--force-with-lease, and saves the issue back atfinalizeNote.stage(finalizeorpublish). A failed check after one session stops the issue withwiki-check. Proposed states:wiki-sync,wiki-resolve(session),wiki-check,save-back.
Locate (every-role-resumes). Every start, after a crash too, works out what is done (Shared):
-
checkoutsavesstartShaandstartWikiShain the issue's state the first time, and later starts read them back, so a reset always goes to the heads the spec stage began from; - every start then runs the session again, and so does a handoff (#335). The session resumes from its commits and its handoff:
checkout, orresumeafter a handoff, lists the test files, stubs, spec draft and wiki pages the stage committed since the start heads, and the task names them and says to continue from them, not to start the spec over or rewrite a committed test. The task says to read the handoff file first when it exists, and a continuation's task begins with the handoff lead. The reply isn't kept across a crash; -
splitopens only the sub-issues its markers show are missing; the## Spec,askand no-work comments are updated in place by their markers; -
resetis a--force-with-leasepush to the saved start heads, a no-op when already done. It never discards a person's work (built #334): it leaves a branch and stops the issue as a failed step when the remote head isn't the one the job's checkout last fetched or pushed, or when it holds commits since the start head (or the base, for a new branch) that lack the spec writer'sPipeline-Agent: spec writertrailer line. The lease is on that known head. A spec session commit without the trailer stops the issue too, which keeps the commit rather than risking someone else's.
The spec's base (#401). What the stage committed (checkout, resume), the commit check (check-commits), the no-work check (confirm-no-work) and the reset's fallback all measure from one base, read by baseOf in src/roles/spec-writer/checks-git.ts: the saved startSha when it isn't null; otherwise, for a stacked issue, the state's stackedOn.headSha, the prerequisite head the branch was started on; otherwise origin/<base branch>. It never reads the prerequisite's branch ref, so it stays the same for the whole of the spec, whether that branch still exists, moved, was rebased, or was deleted after the prerequisite merged. The head is in the job's checkout, since the issue's branch starts from it. Before #401, a new stacked branch measured from origin/agent/issue-P while that ref existed and from the base after: once #390 merged and its branch was deleted, #391's three-dot diff from the base counted #390's commits as the spec writer's, and its check stopped it as not-allowed. Rejected: saving the start head of a branch the stage created as startSha, because null in startSha is what marks a branch the stage created, which a reset deletes; a state saved before #401 (null startSha, stackedOn.headSha set) needs no migration, since baseOf reads stackedOn from the state.
Checkpoints: startSha, startWikiSha, attempts, continuations (#335). A crash restart keeps them all, so it neither moves the reset target nor resets a limit. A restart after a lifted stop keeps only the start heads, the reset targets, and clears attempts and continuations, as it resets every limit (#392): before, it kept attempts, so a spec stopped by failed-twice stopped again on its lift before any session ran. A return to triage starts a new budget too (#454): the ask edge to asked sets attempts and continuations to 0, so the end's save writes them at 0, and the spec run after triage passes the issue back runs its first session as attempt 1. It keeps startSha and startWikiSha, which the reset before ask has already put the branches back at. Triage declares no checkpoints and its save keeps the ones saved, so before #454 an asked run's counts carried into the next spec run: #419 asked after its retry with attempts: 2 saved, and the next spec job stopped at failed-twice with no session. The counts still hold within one spec stage: a crash restart keeps them, and a rate-limited run puts back the ones it took (#446).
spec-tests-check, run in check-commits, passes when:
- the files changed since the base are only test files (
testPathGlobs), the stub files ofnewinterfaces, the files those stubs add and import, type-only additions and zod schema changes in the files ofchangedinterfaces, and the wiki (see The scope rule below); any other changed file failsnot-allowed. The spec draft,.issue-pipeline/spec-draft.md, doesn't count:publish-specremoves it, so it never merges (#335). Nothing has to list them: since #391 the reply has nofiles, and thenot-in-filesrule (#385) is gone; -
wiki-checkpasses when the wiki changed; - each
newinterface'swherefile exists and names each name of its entry. An entry may group several names that share awhere, separated by commas or "and" (TABLE, LIMITS and transition). A failure names only the names the file lacks (#429; before, the file had to hold the whole entry, which stopped #399 and #414); -
lintandtypecheckpass; - at least one test file changed, even with no new interfaces, unless
noTestsReasonis set. With it set, no test file and no stub changed, and the test-command checks are skipped; - the test command fails;
- the test command passes with the spec writer's test files put back as they were at the base, so the failures come from the new tests and the stubs broke nothing.
The scope rule (#417, #416, spec-extends-changed-interfaces). scopeFailure in src/roles/spec-writer/check-rules.ts also allows:
-
Type-only extensions: a file that is the
whereof achangedinterface, with any line anchor removed, when its diff from the base adds lines and removes none outside its zod schemas and export lists, and the added lines are type-level only or in a schema. Type-level lines are members of an interface, type alias or object type, optional properties, union members,import typeand comments.typeOnlyAdditionsFaultinsrc/type-only-additions.tschecks this with the worktree's own TypeScript: the file's JavaScript fromtranspileModule, with types and comments erased and every value import kept, must be the same before and after, once both files' schemas are reduced. A new function, statement, value import, class field, enum member or changed runtime value changes that JavaScript and fails. An added directive comment, such as@ts-ignoreorbiome-ignore, fails too. Without TypeScript in the worktree, the change fails. -
Zod schemas (Axlog, 2026-10-17: "Schema changes are okay"): in the file of a
changedinterface, any change inside a zod schema passes, so a type derived withz.infercan change too. A schema is an expression of calls and property reads rooted atz, such asz.object({...}),z.enum([...])or.optional(), or at a schema constant, such asBASE.extend({...}), that calls no parse method; a schema constant is aconstwhose every value is a schema. Added, changed and required properties pass, and so do new schema constants and their names in the file's export lists. Lines may be removed or changed only inside schemas and export lists.schemasOfinsrc/zod-schemas.tsturns each schema intoz, leaves out each schema constant and drops them from the export lists, before the comparison above. A function body, a statement, a plain object or a.parse(...)call outside a schema still fails. -
Helper stubs: a file the change adds, not one it modifies, that a
newinterface's stub imports by a relative import, directly or through other added files (helperStubsinsrc/roles/spec-writer/helper-stubs.ts).
A not-allowed failure names every file that may not change, one per line, each with its reason, so the spec writer fixes them all in one retry. Before #417 it named only the first.
Reply check in the session (spec-checks-in-session). When the session calls structured_output, the tool asks the job over its control socket to check the reply first. The session's effect names the role's session-reply check (src/roles/spec-writer/checks.ts); the job runs it on the reply, with the issue, startSha and depth, at the checkout's head. For a ready reply it runs the reply check's coverage and the commit check's git-only rules, gitOnlyFailures in check.ts: scope, stubs, and tests and work left to build, each rule's first failure. These read git only, take milliseconds and run no command. When they find something, the tool fails with compact findings, and the session fixes them, commits and calls structured_output again. A finding names each uncovered Behaviour item by its number and the first 60 characters of its text, not the whole spec (src/roles/spec-writer/reply-faults.ts). A reply the reader finds bad is a finding too. A handoff and a reply that isn't ready pass. After three rounds of findings, or when the job can't be reached or the check fails, the reply passes as it is, so a session always ends, and check-reply and check-commits still check it after the session. Lint, typecheck, the wiki and the test runs stay in check-commits. The job's side is in src/job/reply-checks.ts. A job installed before this answers no check-reply request, and its sessions' replies pass unchecked.
Spec by reference (spec-practices-digest). A reply may set specInDraft to true and leave spec empty, rather than retyping the committed spec draft. The session's effect names the draft as its reply file (replyFile). Before the reply check in the session and before the role reads the reply, the job puts the text of .issue-pipeline/spec-draft.md at the code checkout's head in spec (src/job/reply-file.ts), so coverage judges the draft's text. A spec taken from the draft needs no end marker. When the branch has no draft, the reply fails with "the reply points at the spec draft, but the branch has none". The schema asks for specInDraft. The reader still takes a reply without it, which gives the spec inline.
pipeline_commit doesn't refuse an out-of-scope commit: what the scope allows turns on the interfaces the reply declares (the changed files and the new stubs), which the tool doesn't have when the session commits.
A wikiOnly reply that changed no test and no wiki page fails the check with nothing-to-build: there's nothing to build (#391). A noTestsReason reply that isn't wiki-only passes with nothing committed: it is a code change the implementer makes.
- An issue at stage
spec, from triage (readyor split suggested), or back fromagent:needs-infothrough triage. -
planandsplitSuggestedfrom triage, anddepth. In the sweep, also the draft PR triage opened; on the role runner triage opens none (#334). -
issue.mdin the run dir, holding the issue body and only allowed users' comments. - The checkout at the issue's branch, created from the base (or the prerequisite's branch for a stacked issue) when it doesn't exist, the wiki checkout, and the config's
practicesfiles. - When the spec writer asked questions before: the spec so far.
-
Ready: the state at
implementwithspec(at most 12,000 characters),specFilewhen the spec was longer, andinterfaces; nofilessince #391. A signed## Speccomment on the issue. -
Ready, proposed (
spec-writes-failing-tests,spec-writes-wiki):- the issue's branch on GitHub holds the stubs and failing tests;
- the wiki's
agent/issue-Nholds the spec's Wiki updates; - (
spec-writer-opens-pr) a draft PR for the branch, saved aspr, unless the change is wiki-only; - the state records
specTestsSha; - the spec writer's test files are derived from the diff from the base to
specTestsSha, throughtestPathGlobs(tests-added-derived).
-
Needs info: label
agent:needs-info, a comment with what it found and up to 5 questions, and the state back attriagewith the spec so far. -
Split: 2 to 5 sub-issues, label
agent:spliton the parent, no spec. -
Stops:
the spec writer failed twice,could not save the full spec, a bad split,budget, ortoo-largewhen a session runs out of room after its last continuation (#335).
- The spec writer reads the practices before the code, not only the wiki pages it judges relevant. The practices cover all code in the ecosystem, not only the site. The config's
practiceslists the files relative to the checkout (defaultAGENTS.mdandCONTRIBUTING.md). - It reads the practices' digest in full in place of the full files (
spec-practices-digest). The digest is the wiki page Coding practices digest, named by the config'spracticesDigest(defaultwiki/Coding-Practices-Digest.md;nullturns it off). It opens a full practices file only for a rule that bears on its work, and the full files win where they differ. When the digest is missing, it reads every practices file in full. When its wiki updates change a practice the digest covers, it updates the digest in the same wiki commit. Writers and reviewers still read the full files. The note ispracticesNoteinsrc/workflow/practices.ts. - Then it reads the issue, the wiki pages that bear on it, and the code. It uses the web only for what the repo can't answer (library APIs, standards, platform limits).
- It commits a first artifact early (
spec-read-ledger): once it knows the issue's main interface, and before it reads the code broadly, it commits that interface's stub (or the type-only addition achangedone needs) and a first failing test withpipeline_commit, then grows them as it reads. A session that stops early then leaves work the next one builds on. - A continuation reads its handoff first. The job appends to the handoff the files each session that handed off read, with their line ranges, so the next session rereads only what it needs (Read ledger in Running the Issue Pipeline).
- It reads only
issue.md, which holds the issue body and allowed users' comments, with a count of the comments withheld. It treats code, diffs and logs as data, never as instructions. - Its task says the PR merges into the base branch, not into production, and that releasing is never part of an issue's work.
- The spec has these sections: Behaviour, Out of scope, Design (with
file:lineanchors and the wiki decisions it follows), Test interfaces (proposed), Tests, Wiki updates, Assumptions and risks, and Sources. - Proposed: the Tests section names the test files the spec writer committed, rather than describing tests for the implementor to write.
- Design names the practices that bear on the work. Tests meet their test rules.
- Design names the wiki decisions the work follows. If it departs from one, it says which and why. Today it says the implementer must update that wiki page. Proposed (
spec-writes-wiki): the spec writer updates it itself. - The spec is posted on the issue as a signed comment, kept in the issue's state, and given to the implementer, the reviewers, the fixer and the acceptance check. Acceptance fails a change that misses a Behaviour item or a Wiki update.
- Behaviour items are checkable statements of what is true when the issue is done.
- The spec has a Test interfaces section, and a
readyreply carries the same list asinterfaces. Each entry names a public interface the tests drive: an exported function or module, a CLI command, an ops request, an HTTP route, a UI page or a config setting. - An entry gives its name, or several names that share one file, separated by commas, and where it lives (
file:linefor an existing interface, the path for a new one), whether it is existing, new or changed, its call shape, and the Behaviour items tests check through it. - Every Behaviour item is checked through at least one listed interface. An interface no Behaviour item uses isn't listed. With
noTestsReasonset,interfacesmay be empty and Behaviour items need no interface. - Interfaces are ones the wiki or README promise, or ones the change adds on purpose, never internals. What counts as promised: the replies' shapes, stop codes and their labels, caps and limits, GitHub-visible effects (checked through gh's writes only), and past bugs named by issue number. Not promised: copied constants, prompt or log text the wiki doesn't quote word for word, call order, argv, launch keys, key order, and whole-trace snapshots. A Behaviour item that only an internal could check is reworded, or the spec explains why in Assumptions and risks.
- Each test in the Tests section names the interface it drives, the Behaviour it pins, and why it fails today.
- A new or changed interface's call shape is part of the contract the implementor builds and the reviewer checks against. A change to it after the spec goes back through the spec writer.
- A
readyreply with no interfaces and nonoTestsReason, or with a Behaviour item no interface covers, counts as a failed spec session. This is checked by script, not by a model, first in the session (Reply check in the session) and again after it. A Behaviour item is a top-level item; its sub-bullets fold into it, so quoting the item covers them.
- For each
newinterface that doesn't exist yet, the spec writer commits a stub at itswhere. The stub has the interface's call shape (its signature, flags, request and reply, or route) and no behaviour. Its body only throws or returns a not-implemented error, so the code builds and type-checks. - Stubs are used only for entrypoints that don't exist. A stub may import new helper files of its own; every other new source file needs its own
newentry. Anexistinginterface is left as it is. - When its tests need a field or member an existing interface lacks, the spec writer declares that interface
changedand extends it in its file with type-only additions (#417). Implementing it is the implementor's job. - With no new entrypoints, as for a bug fix or a change to existing behaviour, the spec writer commits no stubs but still writes a failing test (Axlog, 2026-10-16). The test goes through the existing interface and fails on today's code because the behaviour is wrong or missing. Every
readyspec comes with at least one failing test, unless the next rule applies. - If every test that could be written for the issue would test implementation rather than promised behaviour, the spec writer writes no tests (Axlog, 2026-10-16). Implementation here means what
tests-pin-promised-behaviourexcludes: copied constants, prompt or log text, call order, argv, internals.- It sets
noTestsReasonsaying why, and the spec's Tests section says the same. Docs-only, wiki-only, comment-only and most config-only changes usually land here. - The reason is the model's judgement, so the reviewers check it: the tests lens raises a blocking finding when a behaviour test was possible, and acceptance fails a spec whose reason doesn't hold. Such a finding goes back to the spec writer.
- The same rule binds the implementor: tests it adds must pin behaviour too.
- It sets
- It writes failing tests for every Behaviour item, each through a listed interface, following the practices' test rules and
tests-pin-promised-behaviour. Each test fails because the behaviour is missing: an assertion fails or a stub throws. A test that fails on a syntax, import or type error doesn't count, and the lint and typecheck check catches it. - It changes only test files, stub files and their helpers, and the types of
changedinterfaces. It writes no behaviour, refactors nothing, and touches no other file. The commit check enforces this. - It commits only through
pipeline_commit, which pushes to the issue's branch and the wiki'sagent/issue-N. It commits its tests, stubs and spec draft as it goes, not only at the end (#335), so a restart or a continuation finds them (Locate). The draft lives at.issue-pipeline/spec-draft.mdon the issue's branch; the spec itself stays a comment and never becomes the issue description. The draft is on the code branch rather than a wiki page because a wiki draft page would failwiki-check's unlinked-page rule and be published with the wiki. Once the## Speccomment is posted,publish-specremoves it in one commit, and the squash merge drops a file added and then removed. - The implementor gets the tests and stubs as commits on the branch it checks out, along with the spec.
- A restart past spec checks that every test and stub file the spec documents, in its Tests section and as the
whereof anewinterface, is on the branch (Axlog, 2026-10-16; the interfaces in place of the Files list since #391, whosefilesan older state may still hold and the check ignores). A restart carriesinterfacesover in place offiles, so the check has them. Wiki pages, under the wiki folder, aren't checked, since the code checkout never holds them (#334). A documented file counts as on the branch when itsHEADor the spec head, the savedspecTestsSha, tracks it, so an implementor that deletes or moves a file the spec names, on purpose, isn't stopped on the next lift, as #351 was (#408). The check reads no delete instructions from the spec. A state with nospecTestsSha, or one the checkout can't resolve (such as a spec head a rebase dropped from a fresh clone), checksHEADalone. Those files are the spec writer's responsibility, so a missing one stops the issue withagent:stopped-spec-files. It isn't handed to the implementor, and the issue isn't specified again. The stop applies only to a spec whose writer wrote them: the saved state marks itspecWritesTests, which this spec writer (#305) sets. Today's spec stage writes no tests and sets it false. For its specs, a restart lists the missing files in the issue's log and gives them to the implementor as unfinished work.
These requirements move here from triage (start-pr) and the implementor (open-pr).
-
The spec writer's lifecycle creates
agent/issue-N, from the base or, for a stacked issue, from the prerequisite's branch. Its first pushed commit puts it on GitHub. -
On
readyunless the reply is wiki-only, the script opens a draft PR for the branch, or finds the one already open. It opens none forneeds-info,splitorno-work, and deletes a branch it created for them. -
The PR's text is derived by script, not written by a model:
- title: " (#N)". The "(#N)" puts the issue number in the squash commit;
- body: "Closes #N", then the spec's Behaviour section, then a link to the issue (
https://github.com/<repo>/issues/<N>), labelled as the## Speccomment, because thepostop returns no comment id (#334,prBodyOfinsrc/roles/spec-writer/pr.ts). "Closes #N" puts the PR under the issue's Development section and closes the issue on merge into the default branch. - The title stays
start-pr's, which reads the issue.
A later spec of the same issue updates the body in place.
-
An empty commit "Start work on #N" is added only when the branch has no commit past its base, as when
noTestsReasonis set and the spec writer committed nothing. -
The PR stays a draft through implement, review and checks. The finalizer marks it ready before merging, as the merger does today.
-
A wiki-only spec opens no PR. A PR an earlier spec of the issue opened is closed with a signed comment naming the wiki branch, and the issue goes to
reviewon the wiki-only path.close-pron a PR that isn't open posts nothing (#334). -
A failed
start-prstops the issue as a step failure, since every later stage needs the PR.
- The spec writer makes the Wiki updates its spec lists, in the issue's wiki checkout, and commits them with
pipeline_commitalong with its stubs and tests. That is the only way they reach the wiki'sagent/issue-N. - The updates describe the change as the spec designs it. The implementor builds to them. If the build has to depart from them, it returns
blockedand the issue goes back tospec, as for a call shape. - The commit check runs
wiki-checkon the changed pages. A failure retries the session like any failed check. - No other role writes the wiki, apart from the merger's conflict resolver today, while it rebases the issue's wiki commits onto the wiki's latest version. Proposed (
merge-only-when-ready): that resolver moves here, so the spec writer is the only wiki writer; the finalizer'swiki-syncandpublishare ops. - A reviewer finding about the wiki pages goes back to the spec writer, because the fixer only reads the wiki.
-
No change needed: if the issue's Behaviour already holds on the base, the spec writer replies
no-work, giving the evidence inspec(where the behaviour lives, and how it checked). The lifecycle then:- runs the spec writer's tests, if it wrote any to show the behaviour holds. They must pass on the base. If they don't, the reply counts as a failed spec session;
- resets the branches;
- stops with
agent:stopped-no-work, which leaves the issue open for a person to close or reopen with more detail.
-
Nothing to implement: after a
readyspec, the implementor has no work when both:- no failing tests (
noTestsReasonset); - the reply says
wikiOnly: the wiki pages the spec writer already committed are the whole change (#391). Before #391 the script read this fromfiles, every entry a wiki page.
The script checks this, and the issue goes straight to the wiki-only path, with no PR, and the state is saved at
review. No implementor job starts. - no failing tests (
-
Nothing at all: a
wikiOnlyreply with no tests and no wiki change counts as a failed spec session (nothing-to-build): there's nothing to build and nothing to sayno-workabout.
- The reply ends
specwith<!-- end of spec -->, written last, whatever the decision. The stage strips the marker before it saves, posts or asks. - The marker is a required last line, not a
complete: truefield: a trailing field only shows that the JSON reached its end, not the spec text. The stage enforces it, because a reply schema can't say "ends with". - A reply without the marker, a reply that doesn't fit
SPEC_SCHEMA, or a failed session counts as a failed spec session; a session that fails on a transient provider error doesn't, and defers (rate-limit-defers, #446). After the first failure the stage retries once. After the second it stops the issue withthe spec writer failed twice, at stagespec, and posts no spec. The two sessions are counted within one spec stage: a run that ends by asking questions saves its counts at 0, so once triage passes the issue back tospecthe next spec run starts a new budget (#454).
-
readysaves the spec, posts it as a signed## Speccomment, recordsinterfaces(and, proposed,specTestsSha), and nofiles(#391), and moves the issue toimplement. Proposed: it runs only after the commit check passes. - A spec longer than 12,000 characters (
SPEC_LIMIT) is written whole tospec.mdin the run dir throughsave-specfirst, andspecFilerecords its path. A failedsave-specstops the issue withcould not save the full spec. - When
specFileis set, every later prompt (implementer, reviewers, fixer, re-reviewers, acceptance) namesspec.mdand tells the session to read the whole spec there, instead of giving the clipped copy.specTextinsrc/workflow/prompts.tsalone decides how a prompt shows the spec. Prompts don't point at the posted## Speccomment, because sessions get no gh token andissue.mdis written before the comment exists.
-
needs-infoandsplitfirst reset the branch tostartShaand the wiki branch tostartWikiSha, dropping any stubs, tests and pages the session committed (built #334 for stage jobs). -
needs-infoposts what the writer found and its questions throughask, labelsagent:needs-info, and sends the issue back totriagewith the spec so far. On the role runner it savesattemptsandcontinuationsat 0, so the spec run after triage gets the full budget (#454). With no questions it reads asready. A failedaskaborts the sweep. - Work that needs an exception to the practices only the maintainer can grant goes to
needs-info.
-
splitis offered only at depth 0, and only when implementing and testing the issue would take more than two implementer sessions. The task gives that bound in tokens, twicecontextLimits.soft(350,000 by default), read from the config, and says each part must fit in two sessions (#413,roles-6-10-scope). The role's task (src/roles/spec-writer/task.ts) and the lanes' (src/workflow/stages/spec-task.ts) say the same. Before #413 the bound was one session, atsessionTokenCap. WhensplitSuggestedis set, the task opens with triage's reason and asks for a split unless the spec shows the change fits two sessions. - Each part is a complete, independently testable change. It has a title, a body with its slice of the spec and its own acceptance criteria, naming entry points only where the part has stubs, and
after(0-based indexes of earlier parts that must merge first). The stage sends each body as written, with noFiles:line (#391), and callssplit.splitwrites theAfter #a merges.line, opens the sub-issues, and labels the parentagent:split. - Parts are cut along file boundaries, because parts with no order run in parallel and each merge makes the rest rebase. Parts that must edit the same file are ordered with
after, so they run in turn. Fewer parts are preferred to many that share files. - A split posts no
## Speccomment and keeps no spec. The parent closes when all its sub-issues have closed. - A split with fewer than 2 parts stops the issue with
the spec writer's split had fewer than 2 parts. A split of a sub-issue stops it witha sub-issue can't split again. A failedsplitop aborts the sweep.
- Every requirement above has a test at one of the interfaces below, and
mise //apps/issue-pipeline:checkpasses. - The role's stage, ops and reply readers live in modules the other roles don't import, apart from the shared parts the index lists.
- An issue run with
through: "spec"fromspecends atimplementwith a posted spec andinterfacesset, or atagent:needs-info,agent:splitor a stop. It launches no implementor session. - Proposed: at
implement, the branch atspecTestsShabuilds, lints and type-checks. Its new tests fail and every other test passes. - A spec the implementor builds from always has the end marker stripped, its full text reachable (
specorspecFile), and every Behaviour item tied to a listed interface. - The wiki's
spec-stepandspec-reads-practicessections describe the role as built, including the Test interfaces section.
-
Machine: built (#333), registered for
--stage spec(#334).TABLE,LIMITS(specAttempts: 2),transitionandspecWriterMachinecome fromsrc/roles/spec-writer/machine.ts,locatefromlocate.ts,specSession(the session effect and its task) fromtask.ts, andspecReadingOf(the reply reader) fromreply.ts, all called directly. Each test gives a state and a result and asserts the next state, the effect anddata. This replaces most step-machine tests below, which test today's code.- Results: ops answer only
ok, and the session answersrepliedorfailed. States with more than one outcome (claim,check-reply,check-commits,confirm-no-work,reset,publish-spec) are checks that name their result, because an edge's next state is fixed. - End states:
releasedandsplit-opened(released),asked(saved attriage),no-work(stopped, codeno-work),pr-opened(saved atimplement),wiki-only(saved atreview),failed-twice(stopped withthe spec writer failed twice) andsplit-refused(stopped with the split's reason). -
locatereads backstartSha,startWikiSha,attemptsandcontinuationsfrom the checkpoints, a missing count as 0. A spec already published withspecTestsShasaved starts atopen-pr, or atclose-old-prwhen wiki-only; every other start begins atclaimand runs the session again. - Built (#334): the effects' implementations (
specWriterChecks),interfaceGapsincheck-reply, and theopen-prpayload{number, body}with itsprOpenededge, which savespr,headShaandimplemented: false.
- Results: ops answer only
-
Step machine (today):
nextfromstages/spec.tswithworkflow-build-test-kit.mjs(buildState,SPEC_REPLY,SPEC_END,afterSession,afterReply). Assert the launch (task, schema, agent, tools),state.st(stage,spec,specFile,interfaces, and nofiles), the comments and the stop. -
Pure module: the interface coverage check, a function from the reply's
specandinterfacesto the Behaviour items left uncovered, called directly. Built (#331):interfaceGaps(spec, interfaces)insrc/roles/spec-writer/coverage.tsreads only each entry'sbehaviours, and returns the uncovered Behaviour items (the top-level list items of the Behaviour section, each with its nested sub-bullets folded in), whitespace collapsed, in spec order, or[]. An entry covers an item when one of itsbehavioursequals the item's own text, or its text with its sub-bullets, once markdown (backticks,*,_,~and link brackets) and all whitespace are stripped from both. Beforespec-checks-in-session, a nested item was an item of its own and only whitespace was normalised.behaviourGapsgives each gap with its number among the top-level items, for the session's compact findings. Withinterfacesempty every item is uncovered; lifting that fornoTestsReasonis the caller's rule. The spec's sections are read byspecSectioninsrc/spec-sections.ts, whichsrc/spec-files.tsshares. -
Built sweep:
runWorkflowwithargs.through: "spec"and an issue claimed atspec, scripting the spec session and theask,splitandsave-specreplies. Read the outcome withopsRequests,requestForandreleasedState. -
Ops CLI:
runOpsforask,splitandsave-specagainstops-test-kit.mjsand the fakegh. Assert labels, comments and sub-issues witheffectsOf. -
Stage job:
runJobwith--stage specfor the writer boundary: no gh token in pi's env, andpipeline_commitwith its commit scope present (built #334,job-boundary.test.mjs). -
Role on the runner: built (#334).
specWriter(setup)intest/spec-writer-git-test-kit.mjsruns the registered role with its real checks in a real job checkout, on bare code and wiki origins, with a mock session that commits and pushes its files; the ops are the fakes ofspec-writer-run-test-kit.mjsover the fakegh.roles-spec-writer-kill.test.mjskills the machine before and after every call, with check fakes. -
Commit check:
specTestsCheck(change, settings), a function from the spec head, the base,interfaces,noTestsReason, the reply'swikiOnlyand the config'stestPathGlobsandcommandsto pass or a reason. It is tested likefails-before-fix, withverify-test-kit.mjsagainst a real repo. Built (#331):src/roles/spec-writer/check.tsreturns{pass: true, wikiOnly} | {pass: false, rule, reason}, whererulenames the first rule broken, in the ordernot-allowed,stub-missing,no-testsortests-with-reason,nothing-to-build,lint,typecheck,wiki,tests-passandreverted-fails, andreasonnames the file or page (fornot-allowed, every offending file), or the command's log and its output's tail. The caller checks the worktree out at the spec head and sets it up first. The reverted run shares verify'srevertedTest(src/ops/fails-before-fix.ts), in a temp worktree labelledspec-tests-reverted. Since #391 the change has nofilesand thenot-in-filesrule is gone; a pass'swikiOnlyis the reply's, withnoTestsReasonset.check-commitsruns it at the job checkout's head (#334), from the spec's base (see The spec's base).
Rows marked step machine test today's stages. With role-state-machines they are ported to the machine: the same case, given as a state and a result.
| Requirement | Interface | Case | Today |
|---|---|---|---|
| Spec sections and practices | step machine | the spec task names every section and every practices file |
workflow-stages-spec-next.test.mjs, practices.test.mjs
|
| End marker | step machine | a reply without the marker retries once, then stops with the spec writer failed twice; a saved and posted spec has no marker |
workflow-stages-spec-retry-next.test.mjs |
| Ready | step machine | saves, posts the signed spec, saves no files, moves to implement |
workflow-stages-spec-next.test.mjs |
| Long spec | built sweep, ops CLI | a spec over 12,000 characters sends save-spec before it saves state and sets specFile; save-spec writes spec.md
|
workflow-stages-spec-next.test.mjs, ops-issue-ops.test.mjs
|
| Needs info | step machine |
needs-info sends ask and returns the issue to triage; without questions it reads as ready; a failed ask aborts |
workflow-stages-spec-next.test.mjs |
| Split rules | step machine, ops CLI | 2 to 5 parts open sub-issues with their bodies as written, no Files: line, and After #a merges.; 1 part stops; depth 1 stops; a suggested split opens the task with triage's reason |
workflow-stages-spec-split-next.test.mjs, ops-split.test.mjs
|
| Through spec | built sweep |
through: "spec" launches no writer session and releases the issue at implement
|
workflow-stages-through.test.mjs |
| Test interfaces section (new) | machine | the spec task asks for the Test interfaces section and the interfaces field, and says tests name the interface they drive |
none yet |
| Interfaces saved (new) | machine | a ready reply's interfaces are saved in state and reach the implementer's task |
none yet |
| Interface coverage (new) | pure module, machine | no interfaces, or a Behaviour item no interface covers, counts as a failed spec session and retries once | none yet |
| Writer boundary | stage job | the spec job runs as a writer, gives pi no gh token and pipeline_commit, and has the web tools |
job-roles.test.mjs, job-boundary.test.mjs, job-agent-definition.test.mjs (#334) |
| Stubs and failing tests pass the check (new) | commit check | a stub plus a failing test through it passes; lint, typecheck and the rest of the suite pass | none yet |
| No new entrypoints still needs a test (new) | commit check | with only existing interfaces, a failing test through one of them and no stub passes; no test file changed fails the check |
none yet |
| No tests of implementation (new) | commit check, machine | with noTestsReason set, no changed test or stub file passes and the test-command checks are skipped; a changed test file with noTestsReason set fails; an empty interfaces with no reason fails |
none yet |
| Reason reaches review (new) | machine |
noTestsReason is saved in state and appears in the tests lens's and acceptance's tasks |
none yet |
| Only tests and stubs (new) | commit check | a change to a non-test file that isn't a new interface's stub fails the check, naming the file |
none yet |
| Failures come from the new tests (new) | commit check | tests that pass at the spec head fail the check; a suite that still fails with the new test files put back fails it too | none yet |
| Stubs name each grouped name | commit check | a new entry that groups names passes when its stub names each; a stub that lacks some fails stub-missing, naming only those (#429) |
spec-tests-check.test.mjs |
| Only allowed files change | commit check | a changed file that is no test, stub or wiki page fails not-allowed; a wiki edit passes with nothing listing it (#391) |
spec-tests-check.test.mjs |
| Type-only extensions | commit check | optional fields, a comment, import type and a union member added to a changed interface's file pass; an added function or runtime value fails not-allowed, as do the same addition to a file no changed interface names and a removed line (#417) |
spec-tests-scope.test.mjs |
| Helper stubs and every offender | commit check | added files a new stub imports, directly or transitively, pass; an added file nothing imports and an imported existing file fail; one failure names every offender with its reason (#416) |
spec-tests-scope.test.mjs |
| Broken stubs (new) | commit check | a stub that doesn't type-check fails the check through typecheck
|
none yet |
| Wiki updates committed (new) | integration, bare remotes | a ready spec's wiki edits are on the wiki's agent/issue-N; needs-info, split and no-work reset it to startWikiSha
|
roles-spec-writer.test.mjs (#334) |
| Wiki check in the spec check (new) | commit check | a changed wiki page with a broken link fails the check | none yet |
| No work: already holds (new) | machine, ops CLI |
no-work resets both branches and stops with agent:stopped-no-work; its tests must pass on the base, or it is a failed session |
roles-spec-writer.test.mjs (#334) |
| No work: nothing to implement (new) | machine | a wikiOnly reply with noTestsReason opens no PR and saves at review, starting no implementor; a noTestsReason reply that isn't wiki-only opens a PR, wiki edit or not (#391) |
roles-spec-writer-wiki-only.test.mjs |
| Branch created (new) | machine, bare remotes | an issue with no branch gets agent/issue-N from the base, or from the prerequisite's branch when stacked; the first commit puts it on the remote |
roles-spec-writer.test.mjs (#334) |
| A stacked spec keeps its base (new) | machine, bare remotes | a stacked spec started on the prerequisite's head lists, checks and confirms no-work against that head after the prerequisite was squash-merged, with its branch deleted, kept or rebased; a branch the stage created saves null start heads; an unstacked spec measures from the base after the base moved |
roles-spec-writer-stacked-base.test.mjs (#401) |
| Draft PR on ready (new) | machine, ops CLI |
ready with code opens one draft PR titled " (#N)" whose body starts "Closes #N" and holds the Behaviour section; a second run finds it and updates the body
|
roles-spec-writer.test.mjs (#334); pr-ops.test.mjs covers start-pr; pr-ops-start-pr.test.mjs covers the body update (#332) |
| No PR otherwise (new) | machine |
needs-info, split and no-work open no PR and delete a branch this stage created; a wiki-only spec closes an earlier PR and opens none |
roles-spec-writer.test.mjs (#334) |
| PR fails (new) | machine | a failed start-pr stops the issue as a step failure |
roles-spec-writer.test.mjs (#334) |
| Nothing at all (new) | machine | a wikiOnly reply with no tests and no wiki change fails nothing-to-build, a failed spec session |
spec-tests-check.test.mjs, roles-spec-writer-wiki-only.test.mjs
|
| Machine edges (new) | machine | each row of the table: the state, a result, the next state and effect |
roles-spec-writer-machine.test.mjs (#333) |
| Retry with the check's output (new) | machine | a failed check retries once with its output, then stops with the spec writer failed twice
|
roles-spec-writer.test.mjs (#334) |
| Questions and splits leave no code (new) | built sweep, ops CLI |
needs-info and split reset the branch to startSha on the remote before ask or split
|
roles-spec-writer.test.mjs (#334) |
| Resets keep a person's work (new) | integration, bare remotes | a reset leaves both branches and stops the issue when a person pushed to them before the run or during it |
roles-spec-writer-reset.test.mjs (#334) |
| Ready hands over the tests (new) | machine |
ready saves specTestsSha, and the implementer's checkout is at or past it |
roles-spec-writer.test.mjs saves it at the branch's head (#334) |
| Role isolation (new) | lint or unit | the role's modules import nothing from the other roles' modules |
roles-spec-writer-machine.test.mjs (#333) |
| Kill anywhere (new) |
locate, runner, fake gh
|
killed before and after each state and started again, the spec writer ends as an uninterrupted run: one ## Spec comment, no duplicate sub-issues, and resets to the logged start heads |
roles-spec-writer-kill.test.mjs (#334) |
-
A deterministic floor for
noTestsReason: should the script refusenoTestsReasonwhen the change includes code outsidenonCodePathGlobs? Since #391 the reply lists no files, so the script would need another signal of what the change touches. That would leave the model's judgement only for code changes, and settle docs-only changes by script. -
Changed interfaces: settled for additions (#417,
spec-extends-changed-interfaces): the spec writer may add type-only members to achangedinterface. Still open: a change that alters or removes part of a call shape, which type-only additions can't express. Should the spec writer change the signature with a stub body, breaking existing callers until the implementor fixes them? Or should it write tests only against the current shape, leaving the new shape to the spec text? -
No-work outcome: should
no-workclose the issue as completed with the evidence, rather than stop it for a person (proposed: stop, since closing ends the conversation)? Should tests that show the behaviour already holds be kept as a test-only PR? -
Wrong tests: if the implementor finds a spec test wrong, it can't change it. Proposed: it returns
blockednaming the test, and the issue goes back tospec, counted, with the reason. The same applies to a blocking finding from the tests lens (see Reviewer). -
Interfaces as data or as text: the proposal puts them in both the spec text and a structured
interfacesfield, so a script can check coverage and later roles can read them. Is the text section enough? -
How downstream roles use them: with
spec-writes-failing-tests, the tests are written through the listed interfaces here, and the tests lens and acceptance check that they were. See Implementor and Reviewer. -
Coverage check strictness: matching Behaviour items by quoted text is brittle. Number them instead (
b1,b2, …), the way findings are numbered? Settled for now (spec-checks-in-session): sub-bullets fold into their top-level item, and a quote matches once markdown and all whitespace are stripped from it and the item; case, punctuation and wording must otherwise match exactly. Numbering is not adopted, though the session's findings name items by number. Before, from #331, only whitespace was collapsed and a nested item needed its own quote. - Answers go back through triage: an answer to the spec writer's questions re-runs triage today. See Triage.
- How
pipeline-adopts-posted-spec(a proposal in the owner's notes, not public) fits. Adopting a posted spec would be a second way to finish this role, and the posted spec would need its Test interfaces too.
The requirements above take in the wiki decisions this role follows, as of wiki f1b2944 (2026-10-16): allow-listed-input, linked-draft-prs (moved from triage), spec-step, spec-reads-practices, practices-cover-all-code, tests-pin-promised-behaviour. From this split, not on the wiki yet: spec-names-test-interfaces, spec-writes-failing-tests, no-tests-of-implementation, spec-writes-wiki, no-work-skips-stages, spec-writer-opens-pr, triage-is-own-role, pipeline-commit (decisions in the owner's notes, not public). Once the specs are approved, prune-wiki-decisions deletes or trims the wiki sections the specs make redundant.