Issue Pipeline - axolotl-logic/project-buddy GitHub Wiki
The issue pipeline turns GitHub issues from the users its config allows (here, only the owner) into merged pull requests, with no human in the loop. It acts as the owner's GitHub account, and every comment it posts is signed with the agent, model and run that wrote it. The labels it uses are on Agent labels, and how to run and steer it is on Running the issue pipeline.
This page holds its goal, rules and design decisions. "(Autumn, date)" marks a decision the owner made.
A reusable pi-subagents workflow that watches a repo's GitHub issues and, on its own, takes each allowed user's issue through branch, PR, agent review, fixes, verification and merge. It works on any repo that has a config file. The code lives in apps/issue-pipeline/ and runs from an installed copy in $ISSUE_PIPELINE_ROOT (default ~/.pi/agent/issue-pipeline) and $PI_CODING_AGENT_DIR/agents/issue-pipeline/ ($PI_CODING_AGENT_DIR defaults to ~/.pi/agent); see installed-not-merged.
- Act only on issues opened by users on the repo config's
allowedUserslist, who must also be collaborators; for project-buddy that is only the owner's account (Autumn, 2026-10-14). Agents see only the issue body and those users' comments. Code enforces this filter, not prompts. It matters because project-buddy is public: only collaborators can open issues and PRs, but visitors may comment and react (Autumn, 2026-10-14), so non-collaborator text will sit in every thread the pipeline reads. - Merge without a human, including CI workflows, auth code, lockfiles and migrations, once review and every check pass. Autumn removed the human-path feature entirely (2026-10-13): no
humanPathGlobssetting, no merge stop, no override run. - No file is off limits because of what it is (Autumn, 2026-10-13). Agents may edit CI workflows, auth, dependencies, migrations and config when the issue needs it, and no prompt or reviewer judges a change by the kind of file it touches.
- The only content-based stops are the security categories (Autumn, 2026-10-13): changes that harm users, violate their rights, cause financial harm, or introduce a risk of abuse. They are checked as the last review round, just before merge (decision
security-review). - Merge conflicts never wait for a human (Autumn, 2026-10-13). The resolver keeps trying, one attempt per sweep, until the rebase is clean. Only the rebase allowance (
rebaseTokenBudget) bounds it, not the issue's token budget (2026-10-15, #201). - No agent session may exceed 150k tokens of context. Split work into small steps spread across agents so sessions don't autocompact repeatedly. Sonnet children are the exception (see
sonnet-compaction-default). - At most 1M tokens per issue. Each sub-issue is worked on separately and has its own 1M ceiling (Autumn, 2026-10-13).
- Every review and fix loop has a limit. A stop is a failed control: it gets the control's own
agent:stopped-<control>label and always a comment explaining the control, the reason and how to resume (decisionstop-labels, Autumn 2026-10-13). project-buddy setsmaxSyncsandmaxConflictFixesto 100, so in practice the rebase allowance (rebaseTokenBudget) is the limit for conflicts (2026-10-15, #201). - No model holds merge credentials.
- One GitHub account does all the pipeline's GitHub work: PRs, every agent's review comments, and merges. Each comment names the agent that wrote it.
- GitHub is the only source of truth. Local files are a disposable cache.
- Runs on the owner's Mac as scheduled pi workflows, one session-only schedule per lane, each skipping a run while its previous one is still going. It must move to an always-on host without changes, but project-buddy's screenshot baselines are
-darwinonly, so its lanes must run on a Mac until they change. - Each sweep works on one issue (Autumn, 2026-10-14). Running more issues at once used to mean running more lanes (decision
lanes); the orchestrator now starts stage jobs for several issues at once, up to its caps (decisionstage-jobs-replace-lanes). - The pipeline never starts on its own (Autumn, 2026-10-14). She starts it in one dedicated pi session, and it then sweeps continuously until she pauses it. No other pi session may launch it. This replaces the old rule "don't pause a schedule while issues remain". Never create a project-wide schedule, because any pi session in its project fires it.
- To replace a schedule (needed after every
workflow.jsedit, since a schedule runs a copy), run/pipeline-pausein its session, delete it after its sweep-finished notice, and run/pipeline-startagain.ops.mjs, configs and agents are read fresh on every step. - project-buddy issues: don't park an issue without stating what it waits on.
Each stage job declares the boundary a sandbox will later enforce (sandbox-ready-jobs, #249). Nothing enforces it yet; only the agents' environment changes.
-
Roles. A stage job runs as one of three roles (
src/job/roles.ts):readerfor triage, spec, review, accept and security;writerfor implement, fix, open-pr, sync (its conflict resolver writes) and checks (its CI fixer writes);mergerfor merge. -
Sessions. A model session runs as
writerwhen its agent isissue-pipeline-writer(implement, fix, the conflict resolver and the CI fixer), and asreaderotherwise, including the re-reviewers in fix and the check-fix reviewers in checks. No session runs asmerger, since the merge stage launches only themergeop, so no model holds merge credentials. -
Credentials. Each
pi --mode rpcgets the job's environment lessGH_TOKEN,GITHUB_TOKEN,GH_ENTERPRISE_TOKEN,GITHUB_ENTERPRISE_TOKENand every variable a role'sjobRoles.<role>.tokenEnvnames. A writer or merger whosetokenEnvnames a set, non-empty variable gets its value asGH_TOKEN; a reader never gets a token. An unsettokenEnvvariable means no token, not an error. -
Ops (
ops.mjs) keep the job's full environment. -
Network. Each role has a list of hosts, recorded only: reader
api.anthropic.com; writerapi.anthropic.comandregistry.npmjs.org; mergerapi.github.com. A config'sjobRoles.<role>.networkreplaces a role's list. -
Checkout. Each stage job works in its own checkout,
work/<slug>/jobs/issue-N-<stage>, cloned from the bare mirrorwork/<slug>/mirror.git, not in the sharedissue-Nworktree (#250, seeworktrees-and-branches). Itsoriginis the real remote. Every pi session runs there, and every ops request the job makes names it. -
--print-boundaryprints a stage job'srole,checkout(its own checkout's path, which it doesn't create),env(the sorted names, never values, of the env its role's sessions get) andnetworkas one JSON line, and starts nothing.
Each repo's config lists allowedUsers, the GitHub usernames the pipeline trusts (Autumn, 2026-10-14; project-buddy lists only its owner's account). ops.mjs trusts a user only if they are on that list and are still a collaborator, by the collaborators API. A config without the list is refused, so a new repo never trusts everyone by default. Issues from anyone else are skipped, including when one issue is run by number. Comments from anyone else are left out of the issue.md file that agents read, with a note giving how many were withheld, and don't count as answers to the pipeline's questions. The pipeline's account is always allowed, so the sub-issues it opens pass the same check. This replaces trusting every collaborator.
The workflow script has no shell, so every git and gh action goes through ops.mjs, run as the external-cli agent issue-pipeline-ops. It reads one JSON request between BEGIN_OPS_REQUEST and END_OPS_REQUEST on stdin and prints one JSON reply. It is the only code that reads the token files. Its operations are sweep, claim, save-state, save-spec, ask, split, stop, open-pr, push, prepare-review, sync-base, finish-sync, verify, ci-status, merge, close-parent, release and post. Children get no contact_supervisor tool (intercomBridge: {mode:"off"}); pi-subagents adds it to every child otherwise. A request may carry checkout: {issue, dir} (#250): while it runs, issue issue's worktree is dir (and its wiki checkout <dir>/<wiki dir>) for every op, through paths.worktree in src/config.ts; other issues keep their issue-N worktrees, and a request without it acts as before. A malformed checkout is refused, naming it. A stage job sends it on every request; workflow.js never does. Rejected: a bare path keyed on the request's number, since close-parent names the parent and sweep names no issue.
State lives in a hidden comment on the issue, not on the PR, because no PR exists when an issue is claimed. The comment holds the stage, lease, PR number, head, reviewed and verified SHAs, findings, round counts and token allocation. Only a copy written by the pipeline's account is trusted.
agent:working, agent:needs-info, agent:split, agent:merged, and the stop labels agent:stopped-<control> (see stop-labels). There is no opt-out label (no-skip-label). Finer stages live in the state comment. A sweep picks up unlabeled issues, agent:needs-info issues with a newer collaborator reply, and agent:working issues whose lease has expired or was released. A run named by issue number waits too: the claim of an agent:needs-info issue with no reply from an allowed user after the questions returns waiting, posting no claim comment and changing no label, and the sweep's summary reports waiting-for-answer until such a reply arrives (#243). A run named by issue number also leaves an issue with a stop label alone, reporting it as left-stopped, unless launched with takeStopped: true (#251).
Autumn (2026-10-13): a stop is a failed control, not a call for a human, and each control has its own label agent:stopped-<control>. Every stop posts a signed coordinator comment on the issue and its PR, naming the control, what it checks, the reason, details and how to resume. The controls are the STOPS table in src/stops.ts: budget, fix-loop, review-rounds, checks-unrelated, check-fixes, no-ci, triage-rejected, triage-limit, not-doable, too-large, acceptance, branch-changed, conflict, sync-limit, push-rejected, merge-refused, and error for a failed step or session. The workflow sends a free-text reason (op stop), and ops.mjs picks the first matching control or takes an explicit code. Removing the label restarts the issue. The Agent labels page documents every label (status and stop), what triggers it and what to do; each GitHub label's description names its control and points to the stop comment. In a fix round or a CI fix, a push op that fails before git pushes, rather than with git's rejection, is retried once in the same sweep, and a second such failure stops as error, not push-rejected (#263). #74 was moved to agent:stopped-push-rejected with a new comment. The single old stop label is gone from the code and GitHub (Autumn, 2026-10-13).
Claiming an issue writes a lease with the sweep id and time. Each sweep releases unfinished issues, keeping their stage, so the next sweep continues them. A crashed sweep's issues become claimable when the lease is older than leaseHours (default 3). If the PR branch on GitHub differs from the recorded head, the pipeline stops rather than overwrite it. Removing the stop label starts a fresh attempt with fresh limits: the worktree resets to the branch on GitHub and review starts again. When the branch is unchanged since review and no blocking finding is open, the issue goes straight to rebase and re-verify instead.
One clone per repo under clones/, one worktree per issue under work/, on branch agent/issue-<n> from the configured base. Agents run with the worktree as cwd and only commit. A pre-push hook in the clone refuses pushes except from ops.mjs. Worktrees and branches are deleted after merge or split, and the config's optional commands.teardown then runs in the issue's run dir to free what setup made elsewhere. Each sweep also removes a closed, unclaimed issue's worktree, branches and teardown resources once nothing local is unsaved, holding <clone>.lock for one issue at a time; open and stopped issues keep theirs (#174, see Running-the-Issue-Pipeline's Cleanup). Git operations that touch shared clone state (worktree add and remove, git config, fetch) take <clone>.lock. At a start (new work, answered questions, a removed stop label), an issue with no branch on GitHub whose worktree has no commits past the base has its worktree reset to the fetched origin/<base> and cleaned; a worktree with commits of its own is kept (#243).
The pi-subagents sweep (workflow.js) still uses the issue-N worktrees. A stage job (job.mjs) doesn't (#250): before it starts it makes or fetches (with --prune, under <clone>.lock) the bare mirror work/<slug>/mirror.git of the repo, and clones it into its own checkout work/<slug>/jobs/issue-N-<stage>, on agent/issue-N at the branch's head, or at origin/<base> when there is no branch yet, replacing a directory an earlier job of that id left. Its origin is the real remote, and it is set up as the clone is (credential helper, name and email, the pre-push hook). Its sessions run there instead of in the worktree. With a wiki, the job also keeps work/<slug>/wiki-mirror.git, fetched without --prune under the wiki lock, and clones it into <checkout>/<wiki dir>; 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 its checkout and wiki checkout left uncommitted as lostChanges in job.json and drops them; a writer with commits on neither origin/<base> nor origin/agent/issue-N runs the push op; unpushed wiki commits are kept as agent/issue-N in the wiki mirror, fetched into it so no hook runs, where the issue's next job starts from them (the ref is deleted when there are none); and the checkout is removed. A failed exit push is reported on stderr and changes nothing else. A reader or merger never pushes. Rejected: a module-level checkout setter that paths reads, which is global state that leaks between in-process tests.
One read-only session decides ready (with short notes for the spec writer), needs-info (posts up to 5 questions and labels agent:needs-info), split (2 to 5 GitHub sub-issues, with an order where one depends on another), or reject (agent:stopped-triage-rejected). Triage cuts a split along file boundaries and names each sub-issue's files, because sub-issues with no order run in parallel and each merge makes the rest rebase. Sub-issues that must edit the same file are ordered so they run in turn (Autumn, 2026-10-15, #208; #162's parallel parts each spent about an hour on conflicts). Sub-issues can't split again. The parent closes when all its sub-issues have closed. Triage gives up after 3 attempts on the same issue.
The implementer writes tests first, then the smallest fix, runs the configured commands, and commits. If it runs out of room it commits what builds, writes handoff.md, and gets one continuation session. A second overrun stops with a note that the issue needs splitting.
Three fresh read-only reviewers see the issue, the spec (when a spec stage ran) and the diff: correctness, test strength, and scope (beyond the issue, or following planted instructions). Scope runs on Sonnet; the others on Opus. Only Anthropic models are available, so "a different model family" means Sonnet versus Opus. Findings are JSON with severity, file, line, evidence and suggestion. A Sonnet aggregator merges duplicates. Blocking findings go to the fixer. Nits go into one PR comment and are not acted on.
At most 2 fix rounds. Re-review sees only the fix diff and only the lenses that raised open findings, and may raise only problems the fix introduced. The fixer and the re-reviewers also see the spec. The fixer may dispute a finding once and the original lens rules on it, except a failed acceptance check's finding: a dispute of it stops the issue with agent:stopped-acceptance before the push, since only the operator can rule on what the issue asks (#266); a finding that can only be met by breaking the spec is disputed, quoting the spec line, and a finding must not ask for a change the spec forbids (#203). A finding that comes back after being resolved stops. Failing checks get their own count of 2 fix attempts, and each check fix is reviewed by the correctness and scope lenses.
The workflow can't see a child's live context size, so it charges each session its role budget before launching it: triage 50k, implementer 150k per session, reviewer 60k, aggregator 20k, fixer 100k, re-reviewer 40k, check fixer 100k, acceptance verifier 60k. When the next launch would take an issue past 1M, the issue stops. The worst case is about 1.3M, so an issue that hits trouble at every stage stops before its last steps.
A second pool, rebaseTokenBudget (500k), pays for the conflict resolver and for every session of the re-checks after a rebase the merge stage asked for: sync marks such an issue as re-checking, and its check fixes, acceptance and security review are charged there until it merges or a security finding or failed acceptance sends it back to fix, since that fix is new code (2026-10-15, #201). It resets when the issue merges or restarts. Every charge is logged in the issue's event log with its pool. An issue near its 1M can still finish a rebase it was sent back for, as #204 couldn't at 940k. The worst case rises to about 1.8M. Rejected: naming the pool at every call site, which repeats the re-check rule in four stages; the pool comes from the issue's state, and only the resolver names it. Reported usage is logged too, but it sums input across turns, so it is not context size. The 150k per-session cap depends on prompts and compaction, not on a hard limit.
Sonnet children (scope reviewer, aggregator, acceptance verifier) keep the default reserveTokens and compact near 984k, not 150k. Autumn chose no Sonnet override (2026-10-13), because it is a global setting that would also affect her interactive Sonnet sessions.
Before acceptance, ops.mjs runs the setup, test, lint and typecheck commands in the worktree. It checks that the tests fail when the non-test changes are reverted (fails-before-fix), which only counts once the tests pass on the PR. The check is skipped when no code changed: nonCodePathGlobs, and JS or TS files whose change is only comments (Autumn, 2026-10-13). It regenerates changed lockfiles and compares them, and runs changed migrations up, down and up against a scratch database. Then it waits for the required CI checks on the exact head SHA. Last, a fresh Sonnet session judges whether the result does what the issue asks. A failed acceptance becomes a blocking finding in the acceptance lens, with the verifier's reason as its evidence, and sends the issue to fix; its round counts toward the fix rounds, its re-reviewer (Sonnet, the acceptance model) judges it like any other lens, and the issue then goes through sync, checks and acceptance again. It stops with agent:stopped-acceptance only when the fix rounds run out or the fixer disputes the finding (#266). Rejected: stopping on every fail, which the pipeline lifted on its own and restarted at review with every limit reset, where neither the reviewers nor the fixer saw the reason, so the same gap came back (#234's runs 707eb608 and d28a9e12). Ops steps set control.needsAttentionAfterMs to their own timeout, so long verifies don't raise silence alerts.
One account does everything. It can be Autumn's own. Every comment the pipeline posts starts with a signature naming its role, the agent definition, the model, and the run id, and carries a hidden agent-pipeline:comment role=... marker. The pipeline treats a comment as its own only if it has the marker and the configured account wrote it, never by author alone, so Autumn's replies from the same account count as a person's. Each reviewer, re-reviewer and fixer posts its own PR comment. Agent commits end with a Pipeline-Agent: trailer. Each sweep first checks that gh acts as the configured account. Comments are queued in the workflow and posted by the next ops call for that issue.
There is no approving review: GitHub doesn't count a PR author's approval, and the author is the reviewer. Branch protection should require CI but not an approval, or let the account bypass it (mergeAdmin merges with --admin). ops.mjs squash-merges with --match-head-commit set to the verified SHA, then posts a signed merger comment. Before merging, the branch is rebased onto the base. A rebase that changed the head goes through verification and acceptance again but not review. The exception (Autumn, 2026-10-14): when a head that already passed every check, acceptance and the security review is rebased without conflicts because it lost a merge race, only the tests run again (setup, the test command and the lockfile check, skipping fails-before-fix), and then it merges. The change itself is the same, so the earlier acceptance and security review still hold. A resolved conflict or a check fix changes code, so every check runs again. A conflict goes to a writer, the conflict resolver: sync-base leaves the rebase stopped in the worktree, the resolver finishes it keeping both sides' intent and fixes code that merged cleanly but broke, and finish-sync checks the rebase finished on the current base with no markers left before pushing. No human resolves conflicts (decision conflicts-never-stop). The branch counts as behind if GitHub says so, if GitHub refuses with "Base branch was modified", or if the verified commit doesn't contain the current base (checked locally, since without branch protection GitHub would merge a stale branch). A behind branch is rebased in the same sweep once, re-running only the tests before merge tries again, and after that by later sweeps, still up to maxSyncs (3) rebases in all (#205). A conflict from merge still waits for the next sweep.
A slow issue's verify (two e2e runs plus the fails-before run, about 10 minutes) kept losing merge races to quick issues in the other slot, and each loss used one of its maxSyncs. Now the older PR merges first: when an issue reaches merge and another open agent:working issue past review (stage sync, checks, accept or merge) has an older PR, ops.merge returns queued. The issue stays at merge and tries again next sweep, at the cost of ops calls only. Stopped issues and issues with an expired lease don't hold the line. The order is total, so two issues can't wait on each other. The workflow opts in with mergeOrder: true, so schedules running an older copy are unaffected. Chosen at Autumn's request (2026-10-13) over raising maxSyncs or not counting rebases after a lost race, which would leave the slow issue re-verifying until its budget ran out.
The orchestrator (stage-jobs-replace-lanes) passes its own ranked queue (--merge-queue, issues ranked by how many waiting issues they unblock), and ops.merge then waits only for an issue ahead of it in that queue; without a queue the order above applies.
Autumn: a conflict must not wait for a human; the agent resolves it until it doesn't conflict (2026-10-13). When sides contradict, the resolver keeps what the base now does, re-applies the branch's change on top as far as it still makes sense, and says what it chose. If it can't finish the rebase at all, it returns cannot-fix, and the issue stays at sync for a fresh resolver next sweep. The limits are maxConflictFixes (code default 2, project-buddy 100) and the rebase allowance: each attempt is charged 100k against rebaseTokenBudget (500k), so about five fit, fewer once re-checks have used some. A resolver the allowance can't pay for stops the issue with agent:stopped-budget, not agent:stopped-conflict (2026-10-15, #201).
Superseded by stage-jobs-replace-lanes (#234). Lanes still work, but new running happens through the orchestrator.
Autumn (2026-10-14): a sweep works on one issue, and running more issues at once means running more lanes, never a code or config change. A lane is one pi session where Autumn ran /pipeline-start: a session-only schedule, every 2 minutes with overlap: skip (the interval is only the idle gap after a sweep). Lanes coordinate through claim comments on GitHub (claim-comments), so they may share the pipeline directory $ISSUE_PIPELINE_ROOT (sharing its clones and per-issue worktrees) or run from separate copies or machines; a lane on a fresh copy rebuilds an issue's worktree from its PR branch, and its wiki checkout from the wiki's agent/issue-<n> (#213). The one local-only guard left is the parent reservation, so lanes in separate directories may both close a finished parent and post its closing comment twice. Lanes may share a session cwd, since each schedule is bound to its session. /pipeline-start passes the lane id (the session id's first 8 characters) as args.lane. This replaces an earlier design in which one sweep could take several issues in lockstep and maxInFlight and itemsPerSweep capped them. Each verify runs the e2e suite twice, so watch the machine's load as lanes are added. Autumn kept the 2-minute ticks over an extension that starts the next sweep when one finishes, a long-running workflow loop, or a manager script launching headless pi.
Autumn (2026-10-14), so that concurrent workers can scale up: a lane marks an issue claimed, comments, waits a random time, and proceeds only if its claim is the first; otherwise it notes that it is abandoning the issue and takes another. In src/claims.ts (raceForClaim, run by the claim op, which lives in src/ops/claim.ts):
- The sweep returns up to
claimCandidates(default 5) waiting issues, oldest first. It skips issues whose live lease belongs to another lane, and puts this lane's own leased issue first. The workflow claims them in order until one wins, and lists the lost ones in its result asabandoned. - A claim is a pipeline comment carrying
<!-- agent-pipeline:claim lane=<lane> sweep=<sweep> -->. Posting it also addsagent:working. The lane then waits a randomclaimWaitSeconds(default[5, 20]) and lists the issue's live claims. The earliest by comment id wins. A loser edits its claim toclaim-closedwith "Abandoned …: laneXclaimed this issue first". A lane that sees a live claim before posting skips the issue without commenting, and a lane whose own claim is already the live one keeps the issue without a new comment. - A claim is live until it is closed or goes
leaseHourswithout an edit. Each sweep that ends with the issue unfinished keeps the lease on its lane (releaseno longer clears it) and edits the claim with a "Lane still working at" line.stop,ask,splitand a merge close every live claim on the issue. - Costs: a lane that goes away holds its issue until the lease and claim expire (3 hours for project-buddy), and every new issue waits 5 to 20 s before work starts. Agents' issue context skips claim comments.
- The wait must exceed the time GitHub takes to list a new comment. A shorter wait risks two winners.
Autumn (2026-10-13): each issue's PR opens right away as a draft, named so it links to the issue. When triage says ready, start-pr pushes agent/issue-<n> with an empty first commit "Start work on #N" (GitHub refuses a PR with no commits; the squash merge drops it). It then opens a draft PR titled " (#N)", whose body starts with "Closes #N" and carries the plan. "Closes #N" puts the PR under the issue's Development section and closes the issue on merge; "(#N)" in the title puts the issue number in the squash commit. A failed start-pr is only logged, and open-pr creates the PR later. After implementation, open-pr pushes the code, replaces the title and body with the implementer's (keeping "(#N)" and "Closes #N"), and marks the PR ready for review; merge also marks a leftover draft ready. Every push except start-pr's must change files. A stopped issue whose draft has no implementation yet (implemented: false) restarts at triage. Backfill: every earlier pipeline PR already had "Closes #N" and was linked; 18 merged PR titles gained "(#N)".
Autumn (2026-10-13) removed agent:skip, as she did the old single stop label. Every open issue from an allowed user is queued; triage asks (agent:needs-info) or rejects (agent:stopped-triage-rejected) when it can't act, so each parked issue states what it waits on. To keep the pipeline off an issue, close it. Stop labels still keep sweeps away. The label is gone from ops.mjs (the LABELS table, the sweep filter and the run-now check), the README, LABELS.md and GitHub.
Autumn (2026-10-13): every issue gets a spec before implementation. After triage says ready (and the draft PR opens), the spec writer (issue-pipeline-specifier, Opus, 80k) reads the issue, the wiki pages that bear on it, the code, and the web only for what the repo can't answer (library APIs, standards, platform limits). It has web_search, fetch_content and get_search_content but no edit or shell tools. The spec has Behaviour, Out of scope, Design (with file:line anchors and the wiki decisions it follows), Tests, Wiki updates, Assumptions and risks, and Sources. It is posted on the issue as a signed comment ("documented in the ticket"), kept in the issue's state, and given to the implementer and to the acceptance check, which fails a change that misses a Behaviour or a Wiki update. A spec writer that needs a maintainer's answer posts what it found and asks, as triage does (agent:needs-info); after the answer the issue goes back through triage.
The spec writer ends spec with the line <!-- end of spec -->, written last, after every section, whatever its decision (#280). The spec stage strips the marker before it saves, posts or asks. A spec without it may have stopped partway, as #235's did, so it is never saved, posted or moved to implement: a reply without the marker, a reply that doesn't fit the schema and a failed session all count as a failed spec session. After the first, the stage charges one more spec session within the issue's budget and launches the spec writer again (a budget that can't take it stops with the budget's reason); a second failure stops the issue with the spec writer failed twice, at stage spec, with no spec posted. The marker is a required last line rather than a complete: true field, because a trailing field shows only that the JSON reached its end, not the spec text; the reader enforces it, since the reply schema can't say "ends with". The state keeps at most 12,000 characters of the spec (SPEC_LIMIT). A longer spec is first written whole to spec.md in the issue's run dir, beside handoff.md and issue.md, by the save-spec op (a failure stops the issue with could not save the full spec), and the state records its path as specFile. The implementer, acceptance, review, fix and re-review prompts then name spec.md and tell the agent to read the whole spec there, rather than giving the clipped copy; specText in src/workflow/prompts.ts alone decides how a prompt shows the spec. Rejected: pointing at the posted ## Spec comment, since agents get no gh token and issue.md is written only at claim, before the comment exists.
Autumn (2026-10-13): project docs live in the repo's GitHub wiki, not in docs/. The pipeline clones it rather than using a submodule: config wiki: true (or { dir, branch, url }) makes ops.mjs keep a shared clone at clones/<slug>.wiki and give each issue a worktree of it at <worktree>/wiki on branch agent/issue-<n>, hidden from the code repo by info/exclude. A checkout with no commits of its own is moved at each claim to the wiki remote's agent/issue-<n> when that branch has commits of its own, else to the wiki's latest version. Agents read it, and the implementer commits wiki updates there. prepare-review appends the wiki diff to the review diff, and push refuses uncommitted wiki changes; each push whose wiki checkout has commits of its own also pushes them to the wiki's agent/issue-<n> (--force-with-lease, under the wiki lock), and a claim counts commits there as the issue's work (#213). src/clone-setup.ts holds the git setup both clones share (gh credentials, identity, the pre-push hook), so checkouts.ts can call into wiki.ts without an import cycle. and finishMerged rebases the wiki commits onto the wiki's latest version and pushes them after the PR merges. A conflict there saves the commits as wiki-unpushed.patch in the run dir and comments on the issue, since the code has already merged. A repo whose wiki doesn't exist yet is skipped. GitHub creates the wiki repo only when its first page is made in the browser. The wiki-check op (no model) checks that the links in an issue's changed wiki pages point to existing pages and that its new pages are linked from _Sidebar or another page; the checks stage doesn't run it yet (#173).
Each repo's config is a JSON file on the Mac, outside the target repo and never installed from apps/issue-pipeline/ (only repos/example.json is), so collaborators and agents can't change the rules used to check their own work. It names the repo, base branch, the account and its optional token file, commands, glob lists, lockfile and migration commands, limits, and model and budget overrides.
Autumn (2026-10-15) chose moving the pipeline's code, agents and prompts into apps/issue-pipeline/ over a local git repo outside project-buddy, so changes get review and tests as well as history (#160, PR #161). The pipeline runs from an installed copy, which changes only when the operator runs mise //apps/issue-pipeline:install from a checkout of a merged commit, never when a PR merges, so an agent's PR can't change the code that checks its own work. The install refuses uncommitted changes and the pipeline's own worktrees, builds the workflow and ops from src/ into dist/ with esbuild before copying them (#165), backs up each file it replaces, fills in the local paths in the agents and prompts, and records the commit in INSTALLED.json. Rejected with the move: running the pipeline straight from a checkout that follows integration, since a merged change, possibly the pipeline's own, would then run on the next sweep with no human step.
Autumn (2026-10-15): the spec writer reads the project's coding practices in full before the code, rather than only the wiki pages it judges relevant, since the practices cover all code (practices-cover-all-code in Decisions-Code-and-tooling). The config's practices lists the files, relative to the worktree (default AGENTS.md and CONTRIBUTING.md). The spec's Design names the practices that bear on the work and its Tests meet their test rules. Work that needs an exception only the maintainer can grant goes to agent:needs-info. Since PR #164 (Autumn, 2026-10-15) the implementer, the review fixer, the check fixer and the three first-review lenses read the same files in full. Writers must follow them or say which rule they couldn't and why. Reviewers mark a change that breaks a written rule blocking, quoting the rule; preferences the practices don't settle stay nit. Triage, the aggregator, re-reviewers, the conflict resolver, acceptance and security don't read them.
Autumn (2026-10-14): a repo's baseBranch may be a branch other than its default branch. project-buddy's is integration (decision integration-branch in the repo's Decisions pages). GitHub then never closes the issue from the PR's "Closes #N", so after merging, ops.mjs closes the issue itself with a comment naming the PR. The writer, specifier and reader prompts say that the PR merges into the base branch, not into production, and that releasing is never part of an issue's work.
pi-subagents caps a run at maxSubagentSpawnsPerRun children (default 64), and ops steps count. The workflow counts its launches and holds the issue at a stage boundary when the rest of the stage might not fit (fits). A held issue keeps its stage and continues next sweep.
Each fix round is held on its own (#179). On the step machine, a fix round that leaves findings open ends with an again step, and the sweep's driver begins fix again only if it still fits under the spawn cap (fits), so the hold still comes before every round. Rejected: letting any stage that finishes at its own stage begin again, which would loop forever on a stage that waits for the next sweep; and passing a fits callback to the stages, because the driver's hold log would be overwritten by the stage's copy of the state.
A stage passes data to its own next step within one sweep through carry (#179), JSON-safe and never saved: the review carries its reviewers' raw blocking findings to the aggregate step, which numbers them itself if the aggregator fails. Rejected: a temporary key in the issue's state, which would leak into the saved state and the trace.
The whole sweep is one strict-TS driver, startSweep in src/workflow/sweep.ts (#190). It owns the sweep's state: the claimed issue, the launch sequence, the spawn count, the queued stops and how often each stage has begun. Its interface is next(raw), which takes the raw results of the last batch and returns the next non-empty batch of launch specs, or none once the sweep is finished, and summary(). workflow.js only awaits each batch, since only the sandboxed body may await. It awaits a promise chain once: driveBatches in src/workflow/batches.ts, a plain, non-async helper bundled with the driver, runs a batch with the runs.all the body passes it and calls itself on the driver's next batch in .then. Not a while with await in it, because Biome's noAwaitInLoops forbids the loop and pi-subagents rejects nested async functions; and not a drive function in the body, because the body holds no function keyword and the root policy's useConsistentFunctionStyle rejects a named arrow, so the recursion lives in strict TS. The build's wrapper adds the body's last statement, return sweep.summary();, because Biome can't parse a top-level return; the body keeps its driver in sweep for it (#223). The sweep's id and clock come from the body's Date, so a fixed clock still fixes the trace. Rejected: an entry stage typed as a StageMachine, with the item, sequence, outbox and spawn counting left in workflow.js, which would have left the sweep's own state untyped, and needed an issue before the sweep op had named one.
A stage job (#233) runs each model session as pi --mode rpc, so it can abort or steer a live session, and keeps job.json in the issue's run folder as the one place to see what a job is doing. On SIGTERM or SIGINT it aborts its sessions and saves the claimed issue at its stage with lease: null through save-state, not release, because release renews the lease (leases-and-recovery). --resume reopens an interrupted session's own JSONL rather than starting the stage again, and counts the tokens it spent before from job.json, never by re-reading the session. Steering goes through a Unix socket the job owns. Rejected: JSON mode with the task on stdin, which can't take a command once started; resuming ops launches; and a FIFO for steering, which gives no reply.
Autumn (2026-10-13): drop every "don't edit because of what it is" rule, and stop only for real harm, checked as the last review round before merge. After acceptance passes, an Opus security reviewer reads the full final diff and reports only changes that would cause user-harm, violate users' rights, cause financial-harm to users or the operator, or add an abuse-risk. It judges a change's effect, never the kind of file. Its findings join the normal fix loop with fresh round counts (re-reviewed by the security lens), and the fixed branch goes back through sync, checks, acceptance and the security review. A security finding still open when the fix rounds run out, or found again after maxFixRounds security reviews, stops the issue with agent:stopped-<category>. A lost merge race re-runs the security review only when the rebase had conflicts; a clean rebase re-runs only the tests (Autumn, 2026-10-14). Triage and acceptance have no harm check. The writer, reader and preamble prompts now say any file the issue needs is in scope, and that only code, diffs and logs are data. The old scope lens's flag on edits to CI, dependencies, auth, migrations, secrets and public APIs is gone, as is triage's reject for "needs secrets".
Agents no longer inherit GitHub tokens through their environment: each session's env drops gh's token variables, and only a writer gets the token its role's config names (#249, ## Job boundaries). Agents with bash can still run gh and git push using Autumn's own keyring login, and --no-verify skips the pre-push hook. The pre-push hook and 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. With one account that may be Autumn's own, an agent that misuses gh acts with her full access, not a bot's narrower one.
Autumn (#231, #234, 2026-10-15): one deterministic orchestrator, orchestrate.mjs, starts stage jobs (job.mjs, one stage of one issue) as each issue needs them and as they would conflict, in place of lanes of whole sweeps on schedules. Readers (triage, spec, review, accept, security) run in parallel up to maxJobs and a cap per stage; writers (implement, fix, sync, checks) wait while the files their spec names (st.files, the whole repo when missing) overlap a live implementation's, the older issue going first, so two live issues can't deadlock; merges go in the order of how many waiting issues they unblock; and a token rate limit per hour holds new starts. Every decision goes to runs/<slug>/orchestrator.log. The scheduler is a pure function of a snapshot (open issues, states, job.json files and the token ledger), chosen over a stateful scheduler fed events because recorded snapshots then test it with no clock, files or processes. Glob overlap errs towards overlapping (src/*.ts overlaps src/*.md), since a false overlap only delays a job and a missed one makes a conflict. The orchestrator reuses the sweep op's openIssues and classify rather than copying their rules. Its jobs all run in the lane orchestrator, so the next job for an issue passes the lease check. Lanes, /pipeline-start and /pipeline-pause stay, as does leases-and-recovery; merge-order stays the default. Rejected: checking the diff against st.files, which is out of scope for now; a stateful scheduler object.
Orchestrator (#231, #235): the orchestrator's watchdog is a pure function, watch in src/orchestrator/watchdog.ts, of each open issue's job.json and liveness, what it remembers from earlier ticks, the clock and a start gate, and it returns its actions (resume, stop, steer, SIGTERM) and its next memory; the tick carries them out and keeps the memory in runs/<slug>/watchdog.json. It runs before the scheduler, so a restarted job counts as live and a stopped issue as stopped in the same tick. Chosen, as for the scheduler (stage-jobs-replace-lanes), so that recorded job.json files test every threshold with no real processes. Each restart goes through the start gate, startGate in src/orchestrator/schedule.ts, which holds it for the scheduler's own reasons and counts what it admits against the caps, so a restart is a paid session under tokensPerHour, maxJobs and the stage caps like any start (#235's review). Rejected: exempting restarts from the limits, which would let dead jobs spend past the operator's hourly cap. Restart counts last past the ledger's hour, so they live in their own file, not in orchestrator.json. Stopping an issue at maxResumes clears its counts, as every stop resets the issue's limits once its label is removed. The two thresholds' defaults sit in the module that reads them (tick.ts), not in config.ts's defaults, which is at its line limit. The repeated-failures rule needs the failing tool and its error, so job.json records them per launch (lastToolFailure) and counts a streak of one tool, not of any tool. The steer quotes the error as fenced data, since a steer reaches the session as a user turn and tool output can carry text from files or the web. Rejected: leaving the error out, which the issue's rule asks the steer to quote. Escalations are a second pure function, escalate in src/orchestrator/escalate.ts, of the open issues' labels and what it escalated before (kept in the same watchdog.json), so each label is written once while it stays; only the job stop is expected, being where the rules end; a budget stop escalates, since no rule settles it (#281 decides how its own budget stops are told apart). Rejected: a stateful watchdog object fed events; counting restarts in the ledger; escalating every stop, which would report the watchdog's own agent:stopped-job stops as surprises.