Pipeline Roles Spec Writer - axolotl-logic/project-buddy GitHub Wiki

Spec writer role: spec (draft)

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.

Purpose

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 new entries' 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.

Scope today

  • Stage: spec (src/workflow/stages/spec.ts, spec-task.ts, spec-split.ts, and the spec half of build-replies.ts).
  • Ops: ask, split, save-spec (src/ops/ask.ts, split.ts, save-spec.ts). Proposed (spec-writer-opens-pr): also start-pr, from triage, and close-pr, from the implementor (src/ops/pr.ts).
  • Session: spec writer, agent issue-pipeline-specifier, Opus, replies with SPEC_SCHEMA in the sweep and SPEC_WRITER_SCHEMA on the role runner. Built (#334): its tools are read, grep, find, ls, edit, write, bash, web_search, fetch_content, get_search_content and codemode, and the job adds pipeline_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 to contextLimits, as the writers' sessions are, and job.json records its context budget (roleBudgets.spec, 300000 by default, which must be above contextLimits.hard).
  • Job role: writer (#334), so a spec job runs exit-push as it exits, though the session holds no token (pipeline-commit). The orchestrator still schedules spec as a reader, because a spec-stage issue has no stubs yet to order writers by.
  • Proposed (spec-writes-failing-tests):
    • verify's fails-before-fix check moves here, as a check of the spec writer's tests (see Lifecycle);
    • (spec-writes-wiki) it has wiki write access, and commits the spec's Wiki updates itself.

Session reply schema

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 behaviour

Built (#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:

  • questions and subIssues are required even when unused, so a ready reply 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 interfaces in its spec section. subIssues gets no interfaces field of its own, because each sub-issue gets its own spec.

Lifecycle (proposed: state machine, role-state-machines)

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 at implement. The implementor's no-work checks then carry it on.
  • Wiki conflict or failed wiki-check: a wiki-sync entry 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 runs wiki-check, pushes with --force-with-lease, and saves the issue back at finalizeNote.stage (finalize or publish). A failed check after one session stops the issue with wiki-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):

  • checkout saves startSha and startWikiSha in 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, or resume after 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;
  • split opens only the sub-issues its markers show are missing; the ## Spec, ask and no-work comments are updated in place by their markers;
  • reset is a --force-with-lease push 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's Pipeline-Agent: spec writer trailer 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 of new interfaces, the files those stubs add and import, type-only additions and zod schema changes in the files of changed interfaces, and the wiki (see The scope rule below); any other changed file fails not-allowed. The spec draft, .issue-pipeline/spec-draft.md, doesn't count: publish-spec removes it, so it never merges (#335). Nothing has to list them: since #391 the reply has no files, and the not-in-files rule (#385) is gone;
  • wiki-check passes when the wiki changed;
  • each new interface's where file exists and names each name of its entry. An entry may group several names that share a where, 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);
  • lint and typecheck pass;
  • at least one test file changed, even with no new interfaces, unless noTestsReason is 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 where of a changed interface, 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 type and comments. typeOnlyAdditionsFault in src/type-only-additions.ts checks this with the worktree's own TypeScript: the file's JavaScript from transpileModule, 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-ignore or biome-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 changed interface, any change inside a zod schema passes, so a type derived with z.infer can change too. A schema is an expression of calls and property reads rooted at z, such as z.object({...}), z.enum([...]) or .optional(), or at a schema constant, such as BASE.extend({...}), that calls no parse method; a schema constant is a const whose 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. schemasOf in src/zod-schemas.ts turns each schema into z, 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 new interface's stub imports by a relative import, directly or through other added files (helperStubs in src/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.

Inputs

  • An issue at stage spec, from triage (ready or split suggested), or back from agent:needs-info through triage.
  • plan and splitSuggested from triage, and depth. In the sweep, also the draft PR triage opened; on the role runner triage opens none (#334).
  • issue.md in 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 practices files.
  • When the spec writer asked questions before: the spec so far.

Outputs

  • Ready: the state at implement with spec (at most 12,000 characters), specFile when the spec was longer, and interfaces; no files since #391. A signed ## Spec comment 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-N holds the spec's Wiki updates;
    • (spec-writer-opens-pr) a draft PR for the branch, saved as pr, 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, through testPathGlobs (tests-added-derived).
  • Needs info: label agent:needs-info, a comment with what it found and up to 5 questions, and the state back at triage with the spec so far.
  • Split: 2 to 5 sub-issues, label agent:split on the parent, no spec.
  • Stops: the spec writer failed twice, could not save the full spec, a bad split, budget, or too-large when a session runs out of room after its last continuation (#335).

Requirements

Reading

  • 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 practices lists the files relative to the checkout (default AGENTS.md and CONTRIBUTING.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's practicesDigest (default wiki/Coding-Practices-Digest.md; null turns 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 is practicesNote in src/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 a changed one needs) and a first failing test with pipeline_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

  • The spec has these sections: Behaviour, Out of scope, Design (with file:line anchors 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.

Test interfaces (proposed: spec-names-test-interfaces)

  • The spec has a Test interfaces section, and a ready reply carries the same list as interfaces. 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:line for 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 noTestsReason set, interfaces may 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 ready reply with no interfaces and no noTestsReason, 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.

Stubs and failing tests (proposed: spec-writes-failing-tests)

  • For each new interface that doesn't exist yet, the spec writer commits a stub at its where. 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 new entry. An existing interface is left as it is.
  • When its tests need a field or member an existing interface lacks, the spec writer declares that interface changed and 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 ready spec 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-behaviour excludes: copied constants, prompt or log text, call order, argv, internals.
    • It sets noTestsReason saying 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 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 changed interfaces. 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's agent/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.md on 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 fail wiki-check's unlinked-page rule and be published with the wiki. Once the ## Spec comment is posted, publish-spec removes 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 where of a new interface, is on the branch (Axlog, 2026-10-16; the interfaces in place of the Files list since #391, whose files an older state may still hold and the check ignores). A restart carries interfaces over in place of files, 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 its HEAD or the spec head, the saved specTestsSha, 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 no specTestsSha, or one the checkout can't resolve (such as a spec head a rebase dropped from a fresh clone), checks HEAD alone. Those files are the spec writer's responsibility, so a missing one stops the issue with agent: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 it specWritesTests, 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.

Branch and PR (spec-writer-opens-pr, built #334 for stage jobs)

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 ready unless the reply is wiki-only, the script opens a draft PR for the branch, or finds the one already open. It opens none for needs-info, split or no-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 ## Spec comment, because the post op returns no comment id (#334, prBodyOf in src/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 noTestsReason is 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 review on the wiki-only path. close-pr on a PR that isn't open posts nothing (#334).

  • A failed start-pr stops the issue as a step failure, since every later stage needs the PR.

Wiki updates (spec-writes-wiki, built #334 for stage jobs)

  • The spec writer makes the Wiki updates its spec lists, in the issue's wiki checkout, and commits them with pipeline_commit along with its stubs and tests. That is the only way they reach the wiki's agent/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 blocked and the issue goes back to spec, as for a call shape.
  • The commit check runs wiki-check on 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's wiki-sync and publish are ops.
  • A reviewer finding about the wiki pages goes back to the spec writer, because the fixer only reads the wiki.

No work (proposed)

  • No change needed: if the issue's Behaviour already holds on the base, the spec writer replies no-work, giving the evidence in spec (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 ready spec, the implementor has no work when both:

    • no failing tests (noTestsReason set);
    • the reply says wikiOnly: the wiki pages the spec writer already committed are the whole change (#391). Before #391 the script read this from files, 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.

  • Nothing at all: a wikiOnly reply with no tests and no wiki change counts as a failed spec session (nothing-to-build): there's nothing to build and nothing to say no-work about.

End marker and failures

  • The reply ends spec with <!-- 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: true field: 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 with the spec writer failed twice, at stage spec, 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 to spec the next spec run starts a new budget (#454).

Ready

  • ready saves the spec, posts it as a signed ## Spec comment, records interfaces (and, proposed, specTestsSha), and no files (#391), and moves the issue to implement. Proposed: it runs only after the commit check passes.
  • A spec longer than 12,000 characters (SPEC_LIMIT) is written whole to spec.md in the run dir through save-spec first, and specFile records its path. A failed save-spec stops the issue with could not save the full spec.
  • When specFile is set, every later prompt (implementer, reviewers, fixer, re-reviewers, acceptance) names spec.md and tells the session to read the whole spec there, instead of giving the clipped copy. specText in src/workflow/prompts.ts alone decides how a prompt shows the spec. Prompts don't point at the posted ## Spec comment, because sessions get no gh token and issue.md is written before the comment exists.

Needs info

  • needs-info and split first reset the branch to startSha and the wiki branch to startWikiSha, dropping any stubs, tests and pages the session committed (built #334 for stage jobs).
  • needs-info posts what the writer found and its questions through ask, labels agent:needs-info, and sends the issue back to triage with the spec so far. On the role runner it saves attempts and continuations at 0, so the spec run after triage gets the full budget (#454). With no questions it reads as ready. A failed ask aborts the sweep.
  • Work that needs an exception to the practices only the maintainer can grant goes to needs-info.

Split

  • split is 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, twice contextLimits.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, at sessionTokenCap. When splitSuggested is 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 no Files: line (#391), and calls split. split writes the After #a merges. line, opens the sub-issues, and labels the parent agent: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 ## Spec comment 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 with a sub-issue can't split again. A failed split op aborts the sweep.

Done looks like

  • Every requirement above has a test at one of the interfaces below, and mise //apps/issue-pipeline:check passes.
  • 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" from spec ends at implement with a posted spec and interfaces set, or at agent:needs-info, agent:split or a stop. It launches no implementor session.
  • Proposed: at implement, the branch at specTestsSha builds, 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 (spec or specFile), and every Behaviour item tied to a listed interface.
  • The wiki's spec-step and spec-reads-practices sections describe the role as built, including the Test interfaces section.

Test interfaces

  • Machine: built (#333), registered for --stage spec (#334). TABLE, LIMITS (specAttempts: 2), transition and specWriterMachine come from src/roles/spec-writer/machine.ts, locate from locate.ts, specSession (the session effect and its task) from task.ts, and specReadingOf (the reply reader) from reply.ts, all called directly. Each test gives a state and a result and asserts the next state, the effect and data. This replaces most step-machine tests below, which test today's code.
    • Results: ops answer only ok, and the session answers replied or failed. 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: released and split-opened (released), asked (saved at triage), no-work (stopped, code no-work), pr-opened (saved at implement), wiki-only (saved at review), failed-twice (stopped with the spec writer failed twice) and split-refused (stopped with the split's reason).
    • locate reads back startSha, startWikiSha, attempts and continuations from the checkpoints, a missing count as 0. A spec already published with specTestsSha saved starts at open-pr, or at close-old-pr when wiki-only; every other start begins at claim and runs the session again.
    • Built (#334): the effects' implementations (specWriterChecks), interfaceGaps in check-reply, and the open-pr payload {number, body} with its prOpened edge, which saves pr, headSha and implemented: false.
  • Step machine (today): next from stages/spec.ts with workflow-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 no files), the comments and the stop.
  • Pure module: the interface coverage check, a function from the reply's spec and interfaces to the Behaviour items left uncovered, called directly. Built (#331): interfaceGaps(spec, interfaces) in src/roles/spec-writer/coverage.ts reads only each entry's behaviours, 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 its behaviours equals 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. Before spec-checks-in-session, a nested item was an item of its own and only whitespace was normalised. behaviourGaps gives each gap with its number among the top-level items, for the session's compact findings. With interfaces empty every item is uncovered; lifting that for noTestsReason is the caller's rule. The spec's sections are read by specSection in src/spec-sections.ts, which src/spec-files.ts shares.
  • Built sweep: runWorkflow with args.through: "spec" and an issue claimed at spec, scripting the spec session and the ask, split and save-spec replies. Read the outcome with opsRequests, requestFor and releasedState.
  • Ops CLI: runOps for ask, split and save-spec against ops-test-kit.mjs and the fake gh. Assert labels, comments and sub-issues with effectsOf.
  • Stage job: runJob with --stage spec for the writer boundary: no gh token in pi's env, and pipeline_commit with its commit scope present (built #334, job-boundary.test.mjs).
  • Role on the runner: built (#334). specWriter(setup) in test/spec-writer-git-test-kit.mjs runs 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 of spec-writer-run-test-kit.mjs over the fake gh. roles-spec-writer-kill.test.mjs kills 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's wikiOnly and the config's testPathGlobs and commands to pass or a reason. It is tested like fails-before-fix, with verify-test-kit.mjs against a real repo. Built (#331): src/roles/spec-writer/check.ts returns {pass: true, wikiOnly} | {pass: false, rule, reason}, where rule names the first rule broken, in the order not-allowed, stub-missing, no-tests or tests-with-reason, nothing-to-build, lint, typecheck, wiki, tests-pass and reverted-fails, and reason names the file or page (for not-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's revertedTest (src/ops/fails-before-fix.ts), in a temp worktree labelled spec-tests-reverted. Since #391 the change has no files and the not-in-files rule is gone; a pass's wikiOnly is the reply's, with noTestsReason set. check-commits runs it at the job checkout's head (#334), from the spec's base (see The spec's base).

Test plan

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)

Open questions

  • A deterministic floor for noTestsReason: should the script refuse noTestsReason when the change includes code outside nonCodePathGlobs? 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 a changed interface. 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-work close 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 blocked naming the test, and the issue goes back to spec, 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 interfaces field, 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.

Sources

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.

⚠️ **GitHub.com Fallback** ⚠️