Repository navigation
Pipeline Roles
These specs are the design for the self-build ticket, #300, which the pipeline builds itself. Where an open question has a proposed answer, the build uses it unless the owner says otherwise.
Drafts for split-pipeline-roles, a plan item in the owner's notes (not public). They describe the issue pipeline at integration c1797bd (2026-10-16) as five pipeline roles (triage, spec writer, implementor, reviewer, and the merger, proposed to become the finalizer) and the orchestrator that runs them. Each spec gives the role's requirements, what done looks like, the interfaces its automated tests use, and a test plan. They describe today's behaviour so the split can start from a known base. Adjust them as the split's design settles.
The requirements from the wiki decisions each role follows are merged into its Requirements, Done and Open questions. The Sources section at the end of each spec names the decisions they came from. Changes from today's code are marked "proposed" or "changed" where they appear. Each role spec lists its sessions' reply schemas for review under "Session reply schemas". Requirements every role shares are under "Shared, owned by no role" below.
The specs leave out session token budgets (the per-role budgets in ROLE_BUDGETS, sessionTokenCap, and what each session is charged) on purpose. A second pass will work out how to reduce context bloat. contextLimits stay in. The issue's own token budget and the rebase allowance are gone (budgets-bound-context, Axlog, 2026-10-16).
| Role | Spec | Stages today | Model sessions today |
|---|---|---|---|
| Triage | Triage | triage |
triage (reader) |
| Spec writer | Spec writer |
spec; proposed: also creates the branch and opens the draft PR (spec-writer-opens-pr) |
spec writer (specifier) |
| Implementor | Implementor |
implement, open-pr (proposed: removed), fix (the fixer), sync (proposed: no stage of its own; it runs at the start of each implementor entry and in finalize, sync-at-edges), checks
|
implementer, fixer, conflict resolver, CI fixer (all writer) |
| Reviewer | Reviewer |
review, fix (the re-review), accept, security; proposed: also final-security
|
three lenses, aggregator, re-reviewers, check-fix reviewers, acceptance verifier, security reviewer (all reader) |
Finalizer (today the merger; proposed: finalizer-role) |
Finalizer |
merge, publish, plus closing parents and cleanup; proposed: finalize (taking over the implementor's sync), land and publish
|
none (the wiki conflict resolver runs inside merge and publish today; proposed: it moves to the spec writer, merge-only-when-ready) |
| Orchestrator | Orchestrator | none: it starts the other roles' stage jobs, and the operator chooses which roles it runs (proposed) | the escalation agent, optional and off by default |
Each role spec has its own Lifecycle section (proposed), with the sessions marked. The roles' lifecycles differ on purpose, and nothing makes them share phases. Proposed (role-state-machines): the spec writer, the implementor and the reviewer each run as an explicit state machine, a table of states and transitions plus one pure transition function. Their Lifecycle sections are those tables. Triage, the finalizer and the orchestrator stay linear scripts. The machines share only a runner, whose rules are in Shared. What several roles use, pipeline_commit and how a role's wiki access is provisioned, is in Shared.
Every handoff is the issue's saved state (the hidden state comment) at a new st.stage, plus GitHub objects (draft PR, labels, comments). No role calls another directly.
| From | To | st.stage |
What the receiver needs |
|---|---|---|---|
| triage | spec writer | spec |
plan, splitSuggested; today also pr (draft, unless a split was suggested), proposed: no PR |
| spec writer (needs info, answered) | triage | triage |
the spec so far |
| spec writer | implementor | implement |
spec (and specFile when clipped), files, interfaces (proposed), plan, pr (draft; proposed: opened by the spec writer), stackedOn; proposed (spec-writes-failing-tests): specTestsSha, and the stubs and failing tests as commits on the branch |
| implementor | reviewer | review |
PR ready for review, headSha, flows, prTitle, prBody; proposed: the PR stays a draft and its text isn't touched, so only headSha and flows
|
| spec writer (wiki-only, proposed) | reviewer | review |
flows (wiki only), no PR |
| reviewer | implementor | fix |
open findings, round, securityRounds, reviewedSha
|
| implementor (fix pushed) | reviewer | inside fix today |
the fix diff and the open findings |
| reviewer (clean review or re-review) | implementor |
sync (proposed: checks) |
reviewedSha |
| implementor (checks pass) | reviewer | accept |
verifiedSha, verifySummary
|
| implementor (clean rebase of a passed head; today only) | merger | merge |
testsOnly, passedSha, verifiedSha
|
| reviewer (security pass) | finalizer |
merge (proposed: finalize) |
passedSha |
| merger (branch behind or conflict; today only) | implementor | sync |
passedSha kept, testsOnly
|
finalizer (rebase conflicts or retest fails; proposed: finalizer-role) |
implementor | fix |
a conflict or retest finding, a signed note on the PR, finalizeReturns
|
finalizer (a verdict is missing at the head; proposed: merge-only-when-ready) |
the stage that gives it: spec, fix, review, accept or security
|
that stage |
finalizeNote, finalizeReturns, and a signed note on the PR |
finalizer (merged and retested; proposed: final-security-review) |
reviewer | final-security |
mergedSha, mergedBaseSha
|
| reviewer (final security pass) | finalizer | land |
finalPassedSha |
| reviewer (final security findings) | implementor | fix |
open security findings, securityRounds
|
| finalizer (base moved after the final review) | finalizer | finalize |
the merge, retest and final review run again |
| finalizer (landed) | done |
publish, then done
|
the PR merged and closed, label agent:merged
|
Triage's and the spec writer's needs-info, the spec writer's split, and every stop leave the pipeline until an allowed user answers or removes the stop label.
These are the seams the split has to cut. Each spec lists the ones it touches under Open questions.
-
Fix rounds:
stages/fix.tsruns the fixer (implementor) andstages/rereview.ts(reviewer) in one stage machine, one round per begin. -
CI fixes:
stages/ci-fix.tsruns the CI fixer (implementor) and then the correctness and scope lenses on its diff (reviewer) insidechecks. -
Wiki conflicts at merge and publish:
stages/merge-wiki.tsandstages/publish.tslaunch the conflict resolver, a writer session, inside merger stages. -
Checks:
checksruns aswritertoday because of its CI fixer, butverifyandci-statusare the gate the reviewer and merger trust. Proposed: the finalizer retests at the merged head itself. -
Tests-only recheck: merge sends a behind branch back to
sync, so a lost merge race passes through implementor stages. Proposed (finalizer-role): the finalizer rebases and retests itself.
The specs treat these as given and don't restate them. A change to them is its own issue.
- The sweep driver and entry:
src/workflow/sweep.ts,sweep-stages.ts,sweep-item.ts,stages/entry.ts(sweep op, claim, release, summary). - State, labels and stops:
src/state.ts,src/labels.ts,src/stops.ts, opssave-state,stop,post,release. - Claims and leases:
src/claims.ts, opsclaim(wikiclaim-comments,leases-and-recovery). - Budgets and continuations:
src/workflow/budget.ts,stages/continuation.ts,sweep-config.ts. - The stage job and its roles:
src/job.ts,src/job/(roles.tsholdsreader,writer,merger). - Input filtering:
allowedUsersand the collaborator check (wikiallow-listed-input).
These apply to every role. They come from the wiki decisions named in brackets, as of wiki f1b2944.
Job boundary (sandbox-ready-jobs, credential-isolation-limit):
- Each stage job declares its boundary, so it is ready to run in a sandbox later:
- its role (
reader,writerormerger); - its own checkout,
work/<slug>/jobs/issue-N-<stage>, cloned from a bare mirror and removed on exit; - the names of the environment its model sessions get;
- its network, recorded but not enforced.
- its role (
-
src/job/roles.tsholds one table from stage to role, and one rule for sessions: a session runs as writer when its agent isissue-pipeline-writer, and as reader otherwise. No session runs as merger. - Every session's environment drops gh's token variables and every role's
tokenEnvvariable. A writer job holds only the token itsjobRoles.writer.tokenEnvnames, and a merger job only its owntokenEnvtoken. Built (pipeline-commit, #303): no session holds a token. A writer session commits throughpipeline_commit, which asks the job to push, so only jobs hold a token, and no model holds merge or push credentials. - Known limit: a session with bash can still run
ghandgit pushwith Axlog's own keyring login, and--no-verifyskips the pre-commit and pre-push hooks. The hooks and the prompts only guard against accidents. Real isolation needs the pipeline to run as a separate macOS user or in a container, with no GitHub credentials besides the token file. -
job.mjs --print-boundaryprints the boundary.
Checkouts (worktrees-and-branches, wiki-docs):
- A stage job makes or fetches (with
--prune, under<clone>.lock) the bare mirrorwork/<slug>/mirror.git. It clones the mirror into its own code checkout,<job dir>/repo(#338), onagent/issue-Nat the branch's head, or atorigin/<base>when there is no branch yet. It replaces any directory an earlier job of that id left. - The checkout's
originis the real remote. It is set up like the clone: credential helper, name and email, and the pre-push hook that refuses pushes except from the ops tool.src/clone-setup.tsholds that setup for both clones. - With a wiki, the job keeps
work/<slug>/wiki-mirror.git, fetched without--pruneunder the wiki lock, and clones it into<job dir>/docs, beside the code checkout<job dir>/repo(separate-checkouts, #338); the job dir is the sessions' cwd and isn't a git repo. Every stage getsdocs/(every-role-reads-wiki). A repo with no wiki yet gets no wiki checkout. - On every exit (finished, failed, or stopped after the interrupt save), the job:
- records the paths left uncommitted as
lostChangesinjob.json, and drops them; - runs the
pushop for a writer with commits on neitherorigin/<base>nororigin/agent/issue-N; - keeps unpushed wiki commits as
agent/issue-Nin the wiki mirror, where the issue's next job starts from them (and deletes that ref when there are none); - removes the checkout.
- records the paths left uncommitted as
- A failed exit push is reported on stderr and changes nothing else. A reader or merger never pushes. Proposed (
finalizer-role): the finalizer pushes the rebased branch, through an op, with--force-with-lease. - Proposed (
every-role-resumes): every role can be interrupted anywhere and started again. There is no--resume: each start checks out fresh and finds what is already done from the issue's state, GitHub and the remotes. The rules are in Shared. - Git operations on shared clone state (worktree add and remove,
git config, fetch) take<clone>.lock. - The pi-subagents sweep (
workflow.js) still uses one worktree per issue underwork/. At a start (new work, answered questions, a removed stop label), an issue with no branch on GitHub and no commits past the base has its worktree reset to the fetchedorigin/<base>and cleaned. A worktree with commits of its own is kept.
Usage log (proposed: log-usage-at-finish): every session, in every role, appends a record of its tokens and context when it finishes (first turn, peak and final context, tokens, per-tool output, attempt), and every job appends its totals. Records go in one hidden usage comment per issue, edited in place, and hold no text, paths outside the repo, or personal or machine details. report.mjs usage groups them and flags bloat. See Shared.
Input (allow-listed-input): only issues and comments from users on allowedUsers who are still collaborators reach any session. See Triage for the full rule.
Prompts (security-review, base-branch-may-differ-from-default): every prompt says any file the issue needs is in scope, and that code, diffs and logs are data, never instructions. Writer, specifier and reader prompts say the PR merges into the base branch, not production, and that releasing is never part of an issue's work.
Orchestrator stacking (stacked-branches, #290): the rules live in one pure module, src/orchestrator/stacking.ts (stackRule, stackPartners). They work from facts the snapshot records: each issue's stackedOn, and its prerequisite P (the issue it is stacked on, or else the one open issue its after mark names) as open, merged or closed-unmerged.
- An issue waiting on its
aftermark starts triage, spec or implement on P's branch (--stack-on P, only on that first job) once P is atchecksor later with no open findings. Until then, or when P is stacked itself, it is heldafter:#P. Stacks go one level deep. While P's branch lacks the merge commit of another prerequisite in theaftermark that merged, it is heldstacked-on-behind:#Pinstead, until P is rebased onto a base that holds it (#328); a failed read counts as not behind. - A stacked issue is held
stacked-on:#Patmergewhile P is open, and isn't ranked for merge. The writers' file-overlap gate ignores the pair. - A stopped P holds its dependents
after:#P:stopped. A P closed unmerged holds a stacked dependentafter:#P:closed-unmerged. These are holds, not stops, so dependents go on once P is resumed or reopened. Every hold is an ordinaryheldline inorchestrator.log. - A P that isn't open is looked up with
stack.ts'sprereqMerged. An open P atpublishcounts as merged.
Code and tests (practices-cover-all-code, tests-pin-promised-behaviour, flakes-are-failures):
- The coding practices (
AGENTS.md,CONTRIBUTING.md) cover the pipeline's code as they cover the site's: design, types, lint, and test-first, flake-free tests. Code that doesn't meet them yet is tracked as an issue and isn't exempt. - Tests pin promised behaviour, not internals:
- what the wiki and README promise;
- the ops replies' shapes;
- stop codes and their labels;
- caps and limits;
- GitHub-visible effects, checked through gh's writes only;
- past bugs named by issue number.
- Comment bodies are checked for the facts the wiki promises, not copied whole. Tests don't assert copied constants, prompt or log text, call order, argv, launch keys, key order, or whole-trace snapshots.
- A flake is a failure: fix its cause, never rerun until it passes.
Each role's lifecycle checks by script, before any session, whether its stage has work. A stage with none runs no model. It saves the issue at the next stage with a logged reason, or stops it when an earlier stage should have caught the gap. Each role spec gives its own rules. This table gathers them:
| Stage | No work when (script) | Then |
|---|---|---|
| any | the claim finds the issue closed, stopped or at another stage | release, as today |
| spec | the spec writer replies no-work, and its tests (if any) pass on the base |
reset the branches, then stop with agent:stopped-no-work (new stop code) |
| after spec | no failing tests, and only wiki pages in files
|
open no PR (closing one an earlier spec opened) and save at review (wiki-only path); no implementor job |
| after implement | no code change while spec tests still fail | stop: the implementer changed nothing
|
| after implement | no code change and no wiki change | stop as not doable, as today |
| fix | no open blocking findings |
sync, as today (proposed: checks) |
sync (proposed: at the start of each implementor entry and in finalize, sync-at-edges) |
the branch already contains the target's head |
checks, with no rebase (proposed: the entry goes on with no rebase; the finalizer skips the rebase, and the retest too when the head is verifiedSha with CI green) |
| checks | head equals verifiedSha, and nothing moved |
accept, or merge with testsOnly (proposed: accept only) |
| review | empty diff | stop: nothing to review
|
| accept | head equals acceptedSha (new), with the diff unchanged |
security |
| security | the diff from the base is unchanged since it passed |
merge (proposed: finalize) |
| final-security (proposed) | the head is already finalPassedSha (after a restart) |
land; never skipped for an unchanged diff |
| land (proposed) | the PR was merged by hand | bookkeeping, as today |
merge (proposed: finalize) |
the PR was merged by hand, or the change is wiki-only | bookkeeping or finish-wiki, as today |
| publish | no wiki commits ahead of the wiki's base | finish without a push |
Only one judgement is left to a model: the spec writer's no-work, which is checked by its tests passing on the base.
Each spec names the ones it needs. All live in apps/issue-pipeline/test/ and run under mise //apps/issue-pipeline:check.
-
Step machine: call a stage's
next(state, ctx, results)fromsrc/workflow/stages/<stage>.tsand assert the returnedStep: its launches (op and payload, or agent, model, schema and task),state.st,commentsandstop. Kits:workflow-triage-test-kit.mjs,workflow-build-test-kit.mjs,workflow-review-test-kit.mjs,workflow-fix-test-kit.mjs,workflow-checks-test-kit.mjs,workflow-finish-test-kit.mjs. -
Built sweep:
runWorkflow({ args, ops, agents })inworkflow-harness.mjsruns the builtworkflow.jswith scripted replies. Read results throughstage-outcome-test-kit.mjs(opsRequests,requestFor,releasedState,sessionAgents) and build replies withworkflow-stages-fixtures.mjs(sweepReply,claimAt,ROUTINE_OPS). Tests never pin launch keys or order (tests-pin-promised-behaviour). -
Ops CLI:
runOps()inops-run-test-kit.mjssends one framed request tosrc/ops.ts, against real git repos (ops-fixture.mjs,ops-test-kit.mjs,verify-test-kit.mjs) and the fakegh(fake-gh.mjs). Read GitHub effects witheffectsOfandstateOffromgh-effects.mjs. -
Stage job:
runJobandstartJobinjob-test-kit.mjsrunnode src/job.tswith a fakeops.mjsand a fake RPCpi(fake-rpc-pi.mjs). Assert the ops log (logOf), pi's command lines and env (piCommands), andjob.json(statusOf). -
Orchestrator: the pure scheduler, watchdog and stacking rules with recorded snapshots (
orchestrator-fixtures.mjs), and the tick withorchestrator-world-test-kit.mjs.
Decisions made for the split that are public. The others are in the owner's notes.
Axlog (2026-10-17): "Sync should happen at the beginning, before the edits, and the end. The implementor writing the code shouldn't be syncing and once started, they should finish and then merge the result. If the whole agent gets interrupted or the issue bounces away and back, there should be a sync in those cases. Essentially minimizing when/where we sync."
The operating session settled the details in automatic mode (2026-10-17). A sync brings a branch up to the base: it rebases the branch onto the base, or onto the prerequisite's head while the issue is stacked. A sync runs only at these points:
- At the start of each implementor entry, before any edit:
-
implement, so the branch starts at the base's head; -
fix, after any return to the implementor: review, acceptance or security findings, or a finalizerconflictorretestfinding; - a job that resumes an interrupted implementor entry: a killed job, a crash, a rate-limit deferral or an orchestrator restart.
-
- In
finalize, after the merge-order wait, as the Finalizer page describes.
A sync never runs in between:
- not while an implementor entry runs;
- not between an entry's continuation sessions. A planned handoff at
contextLimits.softorhardis not an interruption; - not as a step of its own after a review clears. Today's path from
revieworrereviewthroughsynctochecksgoes straight tochecks; - not when a
checksjob starts again after waiting on CI (held). That wait is not an interruption.
A rebase at an entry's start that conflicts runs the conflict resolver first, in that entry, before the writer edits. The finalizer still never resolves a conflict. It sends a conflict finding, and the fix entry's start sync handles it.
A resolver that returns cannot-fix at an entry's start leaves the issue at its stage, and the next job's start sync tries again (conflicts-never-stop). The issue stops with agent:stopped-conflict only once maxConflictFixes runs out, or when a resolution is rejected on the last attempt.
Counting: only returns to finalize count toward maxSyncs, since they're the churn the cap bounds. A start sync doesn't count, whether clean or not. Conflict resolutions at an entry's start still count toward maxConflictFixes.
Why: every sync moves the base under the issue. Checks then have to run again, and today acceptance and security too. On 2026-10-17, #303 went back from accept to sync when #330 merged, and #316 waited behind #304 and #303 and still needs its own re-check. A sync at an entry's start costs nothing extra, since checks follow every writer entry anyway. The sync in finalize is the one before landing.
Refines finalizer-role, which moved sync to the finalizer. Keeps final-security-review.
- What "split" means at run time: five agents in one sweep as today, five stage-job roles under the orchestrator, or five separate processes each claiming the stages it owns.
- Whether the split is code organisation only (modules and tests regrouped by role) or also changes the job boundary (
STAGE_ROLESinsrc/job/roles.ts). - How the five roles map onto the existing
reader,writerandmergerjob roles. Triage and the reviewer arereader. Withspec-writes-failing-teststhe spec writer commits, so its job is awriter(#334). - Where
checksbelongs (see each spec).