Repository navigation
Issue Pipeline
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). The one other stop is a resolver that runs out of room after its stage's continuations (implementer-handoff, #269). - No agent session may exceed
contextLimits.hard(175k) tokens of context;contextLimits.softis 150k (#269). The writer prompts (implementer, fixer, CI fixer, conflict resolver) state the hard limit in tokens: past it, a session commits what builds, writes its handoff and returnshandoff. The stage job enforces both limits on each turn's context (input, cache reads and cache writes of one assistant message) (#271): it sends one steer to a writer session pastsofttelling it to hand off, and stops any session pasthard. A stopped writer's result is ahandoff. Any other session is relaunched once, fresh, not resumed, with its task plus this line: "Your earlier session on this task passed the context limit and was stopped: read the diff in parts, one file or line range at a time, and keep your context small." If the retry passeshardtoo, the launch fails withcontext passed the hard limit (N) twice, and the stage stops the issue witherror. The retry is charged to the issue's token budget like any session (#272). Split work into small steps spread across agents so sessions don't autocompact repeatedly. Sonnet children are the exception (seesonnet-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. A stacked issue's sync-base, finish-sync, prepare-review and verify requests carry an optional stackedOn: {issue, headSha} (#268): the first rebases onto the prerequisite's branch, or onto the base once the prerequisite merged, and replies the new stackedOn; the others measure from headSha instead of the base. Without it, requests and replies are as before, and only a stacked sync-base reads GitHub. 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, token allocation and, for an issue built on another's unmerged branch, stackedOn: {issue, headSha} (stacked-branches; null otherwise, and a state without it reads as null). 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. Three exceptions keep an implementer's unpushed work (#283). A stop during implement first pushes the worktree's commits that GitHub lacks to agent/issue-<n> (a plain push, which only fast-forwards), and the wiki checkout's to the wiki's agent/issue-<n>; a failed push never blocks the stop, and the stop comment then says that checkout's commits are only local, with its head SHA. A claim never drops local commits the branch on GitHub lacks: it saves HEAD as refs/pipeline/rescue/<n>/<stamp> (UTC YYYYMMDDTHHMMSSZ) in the worktree and keeps HEAD, or resets to the branch when someone pushed to it since, and logs which in the state. A kept HEAD holds commits no review saw, so that restart goes to review even when the review of the branch's head stands. A restart after a too-large stop goes to triage, even when the branch holds work, keeping the PR; triage reads the implementer's handoff and the branch's commits, because a spec that was too large shouldn't be reviewed half built. Rejected: --force-with-lease for the stop push, which could overwrite a maintainer's commits; a non-fast-forward just leaves the commits local and says so. Rescue refs are not pruned.
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-suggested, or reject (agent:stopped-triage-rejected). A split-suggested issue is too big for one session: triage keeps its notes as for ready, records its reason as splitSuggested, logs triage: split-suggested - <reason>, opens no draft PR (see linked-draft-prs), and sends the issue on to the spec, whose writer decides whether and how to cut it (#267). Triage opens no sub-issues itself, because it hasn't read enough of the code to cut along file boundaries; the spec writer has. A ready triage clears an earlier splitSuggested. Sub-issues can't split again: triage rejects a sub-issue's split, and the spec stage stops one (see spec-step). 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. Every writer stage (the implementer, the fixer, the CI fixer and the conflict resolver) hands off the same way when a session passes contextLimits.hard: it commits what builds (the resolver finishes the rebase steps it can and leaves the rest in progress, never aborting), writes handoff.md, and returns handoff. A continuation session of the same stage then starts from the handoff, up to maxContinuations (default 1) per stage per issue. A continuation is not another fix round, CI fix or conflict fix. A handoff past the cap stops the issue with agent:stopped-too-large, a note that it needs splitting; a resolver's rebase is aborted first (#269). The counts are kept per stage in the state's continuations, read only through stages/continuation.ts, which reads the older single number as the implementer's. Rejected: a counter field per stage on the state, which would repeat the field list and the cap in four stages.
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. Each continuation from a handoff (implementer-handoff) is charged its role budget again. 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 stage job's job.json records each session's context size apart (contextTokens, its latest message's total) and its budget (tokenBudget), which the orchestrator's watchdog stops a job on (orchestrator-watchdog, #281). 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. An issue that changed the wiki runs wiki-check first, and a failure goes to a CI fix like a failed test; a wiki-only change runs only wiki-check (#217). 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. A stacked issue isn't ranked while its prerequisite is open (stacked-branches, #290), so it neither heads the queue nor holds another merge.
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). A resolver that runs out of room hands off to a continuation on the rebase still in progress, charged to the same allowance; past maxContinuations the rebase is aborted and the issue stops with agent:stopped-too-large, because #269's acceptance requires that cap for every writer stage.
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. An issue triage suggests splitting gets no draft PR, since the split op never closes one and a split would leave a draft with "Closes #N" open; open-pr creates its PR if the spec doesn't split it (#267). 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. A push that changes neither code nor the wiki pushes nothing, and the issue stops as not doable. A change that lives only in the wiki closes its draft with a signed comment naming the wiki branch, and a later fix that adds code reopens it (#217; see Running-the-Issue-Pipeline's Flows). 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) or suggests a split, 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.
A spec writer may split an issue too big for one session into 2 to 5 GitHub sub-issues (decision split, #267). Its task offers the split only at depth 0. When triage suggested one (splitSuggested), the task opens with triage's reason and asks for a split unless the spec shows the change fits one session. Each part is a complete, independently testable change with its own acceptance criteria, its slice of the spec in its body, its files from the spec's file list, and its after: the 0-based indexes of earlier parts that must merge first. The stage ends each part's body with a Files: line naming its files (none when it has none) and hands the parts to the unchanged split op, which writes the After #a merges. line and labels the parent agent:split. No ## Spec comment is posted and no spec is kept; a failed split op aborts the sweep, as failed questions do.
The spec writer cuts a split along file boundaries, because sub-issues 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, and fewer parts are preferred to many that share files (Autumn, 2026-10-15, #208; #162's parallel parts each spent about an hour on conflicts). A sub-issue's split stops the issue at spec with a sub-issue can't split again, and a split with fewer than 2 parts with the spec writer's split had fewer than 2 parts. Rejected: triage writing the sub-issues, as it did before #267, since it cuts them before anyone has read the code closely enough to find the file boundaries.
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 runs it first for an issue that changed the wiki, and alone for one that changed only the wiki, which then ends with finish-wiki (publish, close the issue as merged) instead of a PR merge (#217).
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. The job also watches each session's context over the same RPC handle (#271, src/job/context-watch.ts): one steer to a writer whose turn context passes contextLimits.soft, and the stop's abort then SIGKILL for any session past contextLimits.hard, whose result the watch builds (a writer's handoff filled from its output schema, any other session's error), recorded as contextStop beside peakContext in job.json. The watch's rule also covers the retry (#272): launch.ts runs a session stopped with an error once more, fresh, as <key>.retry, and returns the retry's result with both sessions' usage. A context-stopped session is never resumed, since it is already past hard; a job interrupted during a retry resumes the retry. Rejected for the retry: recording it as a field on the first session's record, which would merge two sessions' file, usage and peak into one record, and retrying in each stage, which would repeat the rule in six stages and charge the retry as a separate result. Rejected for that watch: putting it in job.ts's record hook, which can't change a launch's result and would split one rule between job.ts and launch.ts. 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. How the scheduler starts and holds stacked issues is in stacked-branches (#290).
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, unless the watchdog made it itself (below). 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.
The budget rule (#281) stops a job whose session's context went past its token budget: each session launch's job.json record carries tokenBudget, min(roleBudgets[role], sessionTokenCap) (sessionBudget in src/workflow/budget.ts, the one place the min lives; the preamble shows the same number, and the implementer's now shows its role budget capped rather than the cap alone), and contextTokens, the token total of its latest assistant message that reported usage, and the rule fires when contextTokens > tokenBudget. It compares the live context, not the cumulative usage, because the role budgets are context budgets (the preamble's "Context budget" line, token-allocation) and usage sums every turn, cache reads included, so a session at 30k context passes a 60k reviewer budget within a few turns. Rejected: the cumulative usage total, the issue's suggested reading, which would stop nearly every multi-turn session. The rule sends SIGTERM first and stops the issue only once the job has exited, as the silence rule does, because a job saves the issue's state at its stage on SIGTERM and would overwrite the stop's stopped state, so removing the label would continue from the stale state without resetting the limits. Rejected: stopping and sending SIGTERM in the same tick, as the issue's text says. The rule lives in its own pure module, src/orchestrator/watchdog-budget.ts, and is checked before the dead-job rule, so a job it holds is never revived. It remembers the jobs it sent SIGTERM in watchdog.json's overBudget, by <issue> <job startedAt>, with whether the issue is stopped yet; the tick reads that to tell its own budget stops apart from the sweep's: an agent:stopped-budget on an issue whose current job (same startedAt) the watchdog stopped for budget isn't escalated, and every other one still escalates as unexpected-stop. Rejected: adding budget to the expected stops, which would hide the sweep's own budget stops that the operator is promised to hear about.
The escalation agent (#282) is optional and off by default: with escalationAgent set, a tick that wrote new escalations runs that agent once, as a read-only pi --mode json with no gh token, and logs its recommended action for each escalated issue (recommended); only with escalationAgentActs does the tick carry the recommendations out. src/orchestrator/escalation-agent.ts owns its config keys and their defaults (kept beside their one reader, as the watchdog's are in tick.ts, since config.ts is at its line limit), picking each issue's recommendation, the log lines and carrying them out; src/orchestrator/escalation-consult.ts owns one run: the prompt, the pi command line, the reply's zod schema (written as JSON Schema for the structured-output extension) and what a run's end means. Every failure (no definition, a timeout, a failed run, a reply that doesn't parse, a command that throws) is an agent-failed line rather than an error, so the tick still saves the watchdog's memory and starts jobs. It acts only through the watchdog's own commands: the steer, a --resume restart through the same startGate (so an agent's restart is held by the same caps and rate limit as any start), and the stop op with the issue's saved state; it never edits labels, files or state, so what it can do is what the watchdog can, and acting stays off by default because a steer's message comes from a model that read logs, which can carry injected text. Its resumes don't count towards maxResumes. The tick passes in runAgent(argv, { timeoutMs, cwd, env }) => ProcessExit, the real one a spawnSync of pi in src/orchestrate.ts, so tests inject a fake pi. Chosen over consult(agent, prompt) => reply with the pi command line and the reply parsing in orchestrate.ts, which would leak the agent's prompt, pi's flags and the reply's shape into the entry point; this way the pi command line sits beside agent-definition.ts's and the test fake is trivial. The spec named one module; the 300-line file limit split it in two, the run and the advice. Rejected: running more than one agent per tick or running it in the background, which would need a second process to track.
Autumn (#173, #217, 2026-10-16): each issue's change has two flows, code (the code branch changes files against the base, git diff --quiet origin/<base>...HEAD) and wiki (the wiki branch has commits of its own), so it is code only, wiki only, both or neither. The ops that push or rebase the code branch (push, open-pr, a clean sync-base, finish-sync) reply codeChanged and wikiAhead, from branchChanges in checkouts.ts; the stages work the flows out of those replies after implementation, every fix round and every sync, and save them as st.flows. Stages never run git. src/workflow/flows.ts holds both decisions as pure functions: flowsOf and prFlowAction (stop, close, reopen or go), which the shared step in flow-step.ts carries out for open-pr, fix, ci-fix and sync. Neither stops the issue as not doable ("the implementation changed nothing"). Wiki only closes the draft PR with a signed comment naming the wiki branch (close-pr, st.prClosed), skips verify and CI and runs only wiki-check, and at merge runs finish-wiki, which publishes the wiki, closes the issue with a comment listing the published commits and sets agent:merged. Both runs wiki-check before verify and CI. A later fix that adds code reopens the PR, or opens one, through open-pr with reopen, and the code flow applies from then on. Reviews, fix rounds, acceptance and the security review run unchanged on whatever diff prepare-review makes, which for a wiki-only change is only the wiki section. push no longer throws when the branch changes no files; it reports what changed. Rejected: a separate reopen-pr op, which would repeat open-pr's edit-and-ready logic; keeping push's throw and parsing its error text to tell "neither" apart (push reports the flows instead, defining the error out of existence).
Autumn (#268 part 1, #288, 2026-10-16): an issue may start on a prerequisite issue's unmerged branch (job.mjs --stack-on P) and stays stacked on it, in st.stackedOn, until the prerequisite merges. src/stack.ts holds every stacking decision on the ops side: where a claim starts the worktree, which ref sync-base rebases onto (the prerequisite's head while it is open, the base once its PR merged or its issue closed as completed) and what prepare-review, verify and finish-sync measure from. src/workflow/stages/stacked.ts is the stages' side: they pass st.stackedOn in their payloads and copy the stackedOn a sync replies. Only the dependent's own commits are replayed (rebase --onto <target> <saved prerequisite head>), so a squash-merged prerequisite leaves none of its commits behind. Rejected: a separate stack-status op the stage runs before sync-base, which adds a round trip and splits the merged rule between two places; sync-base resolving its own target keeps it in one module, and the stage only applies the reply. Taking issue closure as merged (state reason completed) unstacks early if an issue is closed as done without its PR merging: the rebase onto the base then drops the prerequisite's commits, and the dependent's checks run on the base alone. At merge (#289), ops.merge replies queued while the prerequisite is open, naming it in waitingFor and stackedOn (with its open PR in waitingForPr, or 0), with or without merge order; once the prerequisite merged it replies behind until sync-base unstacks the branch. It reuses stack.ts's merged rule (prereqMerged) through stackedQueued in src/ops/merge-order.ts. Rejected: folding the wait into the merge order's queued(), which only runs with mergeOrder on, so the wait would need a second gate inside it or depend on the opt-in.
The orchestrator schedules stacked issues (#290). The rules live in one pure module, src/orchestrator/stacking.ts (stackRule and stackPartners), over facts the snapshot records: each issue's stackedOn, and its prerequisite P (the issue it is stacked on, or, when it isn't stacked, the one open issue its after mark names) as open (stage, open findings, head, whether P is stacked itself, whether it is stopped), merged or closed-unmerged. An issue that waits on its after mark starts its triage, spec or implement on P's branch (job.mjs --stack-on P, only on that first job) once P is at checks or later with no open findings; before that, or when P is stacked itself, since stacks go one level deep, it is held after:#P as before. A stacked issue is held stacked-on:#P at merge while P is open, and isn't ranked for merge (merge-order); the writers' file-overlap gate ignores the pair, in both directions. When P's head differs from stackedOn.headSha, or P has merged, a stacked issue at sync through merge is started at sync, and the stage job moves an issue saved at checks, accept, security or merge back to sync for it (restackToSync in stages/stacked.ts; only from merge does it keep the passed head, as merge's own rebase does, so a clean rebase re-runs only the tests; from checks, accept or security the rebased head goes through acceptance and the security review again), where any other stage mismatch is still wrong-stage. A stopped P holds its dependents after:#P:stopped, and a P closed unmerged holds a stacked dependent after:#P:closed-unmerged: held, not stopped, so they go on once P is resumed or reopened. Every hold is an ordinary held line in orchestrator.log, and no stop label was added. P that isn't open is looked up with stack.ts's prereqMerged, the one merged rule, and an open P at publish counts as merged. Rejected: folding these rules into the scheduler's verdictHold and holdReason, which spreads them over the scheduler and puts schedule.ts past its line limit; and reading P's saved state for whether it merged, which can't tell a squash-merged PR from an issue closed undone.