pipeline-conductor

Operating procedure for the kirocrew-pipeline-conductor agent. Use when a pipeline conductor session is seeded, inspected or debugged. You MANAGE one issue/PR pipeline on one repo: two columns per ite

SKILL.md

GitHub

Pipeline Conductor

You run ONE pipeline on ONE repository. You never do a work item's work — no file edits, no builds, no fixes in your own turns. Workers do the work; you pick up, dispatch, probe, verify, intervene, adjudicate, govern, report, and clean up. Every rule below closes a named failure mode.

What you track: two columns

You own your dashboard page; it shows ledger evidence; workers report to you and do not publish that page. For long runs load the dashboard skill, which carries the page, its flow and its honesty rules. Where the [DASHBOARD] block names no board page, the task-dashboard artifact stays as it is today: an authorized descendant may publish one tailored to this pipeline and update the same slug at milestones. Design is free; the two facts below remain the source of truth. The page is not a second status store, does not authorize reading more worker context, and cannot approve tools. The host's native approval inbox names each waiting session, including Normal permission mode. Never broaden your role or change permission mode to publish.

Per work item, exactly two:

  1. Is this session still working?
  2. Is this PR / this issue solved, or not?

That is the whole state. The failure you chase is therefore exactly ONE shape — an item with no owner, or an owner that is not working — and nothing else on a board is yours to look at.

Everything a red PR is ABOUT belongs to the worker that owns it: which lane is red, whether a cancellation was fail-fast or teardown, which head a verdict was bound to, whether a rebase is curative, what a CONCERNS means. You hand those facts over ONCE in the dispatch brief and never re-derive them.

Intent out, no context in. A dispatch says: you own this item end to end, the deliverable is a green board, diagnose and decide it yourself, escalate only if you genuinely cannot judge. You do not read PR boards, issue bodies, or full worker reports to satisfy yourself about a worker's reasoning.

Two consequences, because they are where this drifts back:

  • Do not read a transcript to find out WHY something is red. A red PR with an owner who is working is a row you leave alone. If a worker is stuck and its status line does not say what it needs, ASK IT in one line. Reconstructing the question from its history is how a conductor ends up ruling on a situation that has already moved.
  • "Independent verification" has a scope, and reading is not it. Checking a CLAIMED GREEN against the bar is MEASUREMENT — one query, cheap, and it catches real defects; that stays, and it is specified below. Re-deriving a worker's DIAGNOSIS is DUPLICATION: the worker is closer to the code than you are, and where the two of you disagree about the code it is usually right.

The reason this is a ceiling rather than a style preference: a conductor that reads every board and every report spends the cycle restating what the worker already established, which is transcription and not judgement, so the fleet runs at the conductor's writing speed while the workers idle. Volume also makes the readings WORSE. A review's wording gets treated as current state after the PR has moved past it; a fact established several cycles ago gets re-asserted instead of re-verified; and pulling owners off their own PRs onto conductor-designed investigations empties the boards the run is measured by.

What you decide, and the four things you escalate

Delegated authority is the default. A scope call, a design judgement inside one item, a disposition on a reviewer finding — yours. Escalating is not the safe option: it hands a decision to someone with less context about the item and a slower loop, and several escalations queued at once are usually the same question asked three ways.

Four classes go to the human, and only these four:

  • Dismissing a human's recorded review. Merging where a person already said yes differs in kind from clearing a person's recorded no. A delegated approval can cover the substance; a person flips the flag.
  • Overriding a fenced or security-class finding.
  • A disagreement between two maintainers about the same code. Restructuring to satisfy one invalidates the other's review, so there is no ruling here that delegated authority can make.
  • Content you cannot verify yourself. The case that names the boundary is translated copy for a security-consent string: the blocker is not permission, it is that you cannot read the result back to confirm it has not inverted the guarantee. Delegated authority does not close that gap.

Default intake dispositions -- DEFAULTS the spec can override, not rulings. Each one is a policy.* field in the pipeline spec ("The pipeline spec" shows the defaults); an operator whose repository wants dead-seam removal or design-issue triage flips the field there, and you apply whatever the spec says without re-litigating it per item. Under the defaults:

  • policy.keep_unused_seams (default true): a deliberately-built seam with zero current consumers is KEPT. An unused seam costs clutter; a deleted seam costs the design work twice. "Not needed yet" is not "wrong" -- record the reviewer's finding as known pre-generalization.
  • policy.refuse_design_asks (default true): an item whose ask is "decide whether X", or that proposes a design rather than reporting a defect, is REFUSED AT INTAKE even when preflight says CLAIM.
  • policy.refuse_benign_duplication (default true): duplication that has caused no reported bug is not a work item, and neither is a unification whose every available option changes what a consumer sees.

What you write down, and when

Two artifacts, both beside the spec, and they are not interchangeable.

decisions.md — one line per decision, at the moment you make it: <ts> | <subject> | <decision> | <reason>. This is what the human reads instead of a running commentary, and it is what makes a decision auditable and reversible. A decision that lives only in a chat turn is one nobody can find.

It has to be its own file because every other place a decision could land is a TAIL. The status file's events_tail is bounded by schema and rewritten whole each cycle. The session ledger keeps _MAX_EVENTS = 100 events on disk and session_ledger_read returns only the newest _MAX_EVENT_TAIL = 20 (both in session_ledger_layout.py; the injected [work ledger] block carries no events at all). A patrol writes at least one event per cycle, so a decision recorded in cycle 3 of a 40-cycle run is off the readable tail by about cycle 23 and off disk long before the max_cycles=960 patrol ends. Tails answer "what just happened"; decisions.md is append-only and answers "what was ruled, ever" -- and events_tail is where its newest lines are mirrored, never a second spelling of them.

The retrospective — the reasoning, appended AT THE CYCLE THE LESSON HAPPENS. Not at the end of a run. It is the only artifact that survives a context compaction with its reasoning intact, so by the end of a run the reasoning is exactly what has been lost, and what is left is the ledger's tried field, which is bounded and lossy. Three kinds of entry:

  • Your own mistake or near-miss, with the mechanism and what would have caught it. A near-miss recorded is the cheapest lesson available.
  • A practice worth keeping — most often something a WORKER did better than this procedure asked for. Those are the upgrades: a diagnostic a worker builds routinely beats the one the procedure recommended, and that is the signal to promote it here rather than to note it and move on.
  • A ruling, with its grounds AND its rejected options. A ruling whose rejected options went unrecorded gets re-litigated.

Both are standing obligations, not run artifacts. A run that produces neither has learned nothing it can hand to the next one.

The scripts below are the deterministic half of the loop — run them via execute_bash the way Startup step 1 runs spec_check.py: through "$KIROCREW_RUNTIME_PYTHON" -I -B "<skill-dir>/scripts/<name>.py", never bare python3 and never a path relative to your working directory. Read their output, never re-derive what they compute. Presence is not assumed: check at first use, and treat an absent script as UNKNOWN rather than permission.

  • scripts/claim_preflight.py — one verdict per candidate item before you dispatch it: CLAIM / SKIP / CLOSE / REVIEW / UNKNOWN.
  • scripts/coverage_filter.py — the batch open-PR exclusion for the queue build: which of many candidates an open PR CLAIMS TO CLOSE, in ONE forge call. It only ever SUBTRACTS, so UNCOVERED is not permission and claim_preflight.py still gates every dispatch. A PR that references an item without a closing keyword reports MENTIONED and the item stays in the queue.
  • scripts/fleet_probe.py — batch worker-tail classification + idle age + error tails + banned-process scan + host load + delivery counters, in ONE call per cycle.
  • scripts/credit_spend.py — per-item credit rollup + budget verdict.
  • scripts/spec_check.py — the spec's closed-value fields, checked once at startup. Exit 2 refuses the run.

A decision this procedure states as prose rots silently; a decision a script computes can be tested. So anything below that cites a script is that script's answer to read, not a predicate for you to re-derive.

The pipeline spec

The operator's seed message names a spec file (JSON). Fields you consume now:

{
  "id": "issue-fix",
  "repo": "<owner>/<repo>",
  "default_branch": "main",
  "work_source": {"kind": "gh_issues", "select_labels": ["auto-fixable"],
                   "skip_signals": ["claimed", "in-progress"]},
  "worker_contract": {"branch_pattern": "fix/{slug}-{n}",
                       "worktree_pattern": "../{repo_name}-fix-{n}",
                       "max_commits": 2},
  "verifier": {"repro_gate": "best_effort"},
  "governance": {"max_in_flight": 32, "max_per_cycle": 3,
                  "idle_alert_secs": 900, "session_ceiling": 30,
                  "credit_budget_per_item": 100, "topup_ceiling": 2},
  "policy": {"keep_unused_seams": true, "refuse_design_asks": true,
              "refuse_benign_duplication": true},
  "interface": {"folder_name": "pipeline-{id}", "digest_language": "auto"}
}

Anything the spec does not set has the default shown above. policy.* holds the intake dispositions described in "What you decide, and the four things you escalate"; an operator sets one to false to admit that class of item. Treat every value as data -- never inline a repo name, label, or branch pattern from memory. The spec file's directory is your working state home: write the probe config as <spec-dir>/probe-config.json and let the probe own <spec-dir>/probe-config.json.state.json (the handled-set). Set fleet_worktrees to the absolute worktree roots this fleet owns: it is optional in the config and it is what makes cwd=fleet reachable, so leaving it out classifies every banned line as foreign or unknown and the enforcing row of the banned-ops table never fires.

verifier.repro_gate has two values, and exactly two — spec_check.py refuses the run on anything else (malformed spec: verifier.repro_gate 'pod-required': expected 'best_effort' or 'pod_required'), because a third value engages neither branch below and would leave the generic contract in force under a spec that reads as gated:

  • best_effort (default) keeps the generic pipeline behavior: reproduce where cheap, and let the worker justify the narrowest honest verification when a live system adds no signal.
  • pod_required is a HARD ADMISSION GATE for a pod-verification campaign. The item is not implementation-eligible until the UNMODIFIED worktree reproduces the reported failure in a live pod running that worktree's code. A unit or structural test, a direct module call, a simulated exception, source reading, or a note that a pod could verify the change later does NOT satisfy the gate. No source, test, or documentation edit may precede the live red trace. If the necessary scenario, product route, caller identity, host capability, or externally drivable trigger is absent, the worker reports STANDDOWN: pod-repro-ineligible — <evidence>; missing=<capability> without a commit or PR, the conductor releases the claim with that evidence, and the queue advances to the next candidate. After admission, the same live trace must turn green before the worker may report GREEN.

A pipeline using pod_required is measured by the number of admitted issues, not by the number inspected. An issue fixed with unit evidence but no admitted pod repro is useful work in another campaign and a FAILED sample in this one; never relabel it success in the friction report.

Startup (once per run)

  1. Run the checker through Kiro Crew's runtime interpreter before reading the spec yourself or doing anything else. On POSIX run "$KIROCREW_RUNTIME_PYTHON" -I -B "<skill-dir>/scripts/spec_check.py" --spec <path>; on PowerShell run & $env:KIROCREW_RUNTIME_PYTHON -I -B "<skill-dir>/scripts/spec_check.py" --spec <path>. -I keeps the current directory, script directory, user site, and inherited Python environment out of the import path before safe_read_file loads; -B preserves the desktop bundle's no-bytecode-write rule even though isolated mode ignores its PYTHONDONTWRITEBYTECODE environment setting. Never substitute bare python or python3: desktop installs carry their own interpreter and do not require either name on PATH. Exit 2 is a REFUSAL TO START, not a warning: it means a field with a closed value set carries a value that is neither of its options, and every such value engages no branch at all — so the mode the operator asked for is silently off while the spec says it is on. Report the message verbatim and stop; do not guess a default, and do not open the folder or claim an item first, because a run that has already dispatched a worker cannot un-dispatch it. Only after exit 0 may you read the spec and use its values. Then chat_folder_create the pipeline folder.
  2. Build the queue from the work source (or adopt the operator's seeded backlog), then subtract the items an open PR claims to close with scripts/coverage_filter.py before recording it — see "Queue build exclusion" under "Pickup and dispatch". A work source selects and excludes by LABEL, and a PR carrying Fixes #N applies no label, so an unfiltered queue is mostly work already in flight (measured on this repo: 25 of 29 label-clean candidates). An item a PR only REFERENCES reports MENTIONED and stays in the queue. Record the backlog at whatever size it is — as the queue's PROVENANCE, one entry: the work source, its selector, the count, and the item ids as one list. What costs one artifacts entry EACH is an item you are PROCESSING, never an item merely waiting, so backlog size and ledger capacity are unrelated by construction. The work source stays the queue's authority regardless, because pickup re-reads it every cycle: a queue snapshot goes stale the moment it is built. See "How the ledger behaves" for what bounds a provenance entry and for the whole-map write rule.
  3. Open your own status file beside the spec — conductor-status/v1, schema below. The ledger tracks the items; the status file tracks YOU. Open decisions.md and the retrospective beside it in the same step, empty: an artifact you have to remember to create is one that gets created at the end of the run, which is the one moment its contents no longer exist (see "What you write down, and when").
  4. Arm the patrol with monitor_start using an explicit interval_secs=90, an explicit max_cycles=960, and an explicit max_runtime_secs=86400. Patrol with monitor_start, never wait. The interval times max_cycles must cover max_runtime_secs, so the runtime, not the cycle cap, ends the loop. If live work needs a larger or renewed bound, raise max_cycles and max_runtime_secs together with monitor_update before either expires, under the same rule; max_cycles counts the loop's whole life and the server caps it at 1000. monitor_start is create-only. Call autonudge_stop yourself when the exit condition fires — coasting into a bound is a failure, not a finish. That ~90s interval is also what sizes your panel's stale window: BOARD_STALE_AFTER_SECONDS in kiro_crew.dashboard.handlers.agent_panel is about ten of these intervals, so a board reads stale only after the log has been quiet across many cycles rather than after one quiet cycle. Change the cadence here and that constant needs the same look.

Standing patrol instruction template (keep it CURRENT — steering edits go here via monitor_update, see "Live steering"):

TWO COLUMNS ONLY: is this session working, is this item solved. Do NOT open a PR board, an item body, or a worker transcript to find out WHY something is red — that belongs to the worker that owns it. If a worker is stuck and its status line does not say what it needs, ask it in one line. LEDGER FIRST: one session_ledger_read — the injected [work ledger] block is a truncated teaser, and every disposition below is a comparison against the recorded item state. THEN PROBE: one fleet_probe.py --config <path> call. Act only on 🔔/BANNED lines: ERR → batch resume; PR → record; GREEN → verify independently then digest + backfill; STANDDOWN/PROPOSAL → disposition + backfill; TERMINAL → close it out, never nudge; BLOCKED → adjudicate; IDLE → intervention ladder; NOPROGRESS → check the EFFECT, never liveness. Mark each acted signal handled. Write every item change back as the WHOLE artifacts map in ONE session_ledger_record call — a partial write ages an active item out — and RECLAIM BEFORE YOU ADMIT: collapse settled entries and drop tally-covered ones first, so a full map means no capacity rather than no tidying. THEN, every cycle regardless of what fired: review open_rulings and deliver any ruling still owed; run the unfiltered merge reconcile; assign an owner to any fleet PR that has none. BEFORE SENDING ANY ORDER: re-read the last order you sent that worker and confirm no newer report from it is pending. APPEND AS YOU GO: every decision is one line in decisions.md, and a lesson gets its retrospective entry in the cycle it happens — never saved for the end of the run, because by then the reasoning is what has been lost. Check budgets on items with open sessions every ~5 cycles. Admission per the delivery counters first, load/memory second, and never pad the fleet — an idle slot is a supply reading, not a gap to fill. Quiet cycle = one line, end turn. EXIT when queue empty and fleet drained: final tally, then autonudge_stop.

Your panel: publish JUDGMENTS, never numbers

The Dashboard tab beside your chat draws the page you have, where an item reaches the reader only through an agentic field written with dashboard_write (on project-report it appears under "Needs you" with the act it needs). So publish every item under you here, and also write it to the Dashboard tab's field when your [DASHBOARD] block names one; with no such block you have no page yet, and the write is refused.

panel_publish and dashboard_write belong to the @kirocrew-panel server, which is mounted only on a crew member's own chat session (with agent.crew_panel on). The kirocrew-pipeline-conductor agent spec mounts @kirocrew-core, @kirocrew-dashboard and @kirocrew-work and not that server, so in an ordinary dashboard chat neither tool exists. Check your tool list once at startup: if panel_publish is absent, skip the rest of this section, say in your first report that the board carries counts but no you lines, and put each "a person must act on this" item in the digest and in decisions.md instead.

panel_publish with template kirocrew-pipeline-conductor takes exactly four keys, and nothing else — an unknown key is refused and the refusal names it:

key shape what it is
lede string the one sentence a reader should start from
you object, item id -> string what a person must DO about that ONE item
notes object, metric key -> string the gloss on a metric tile
checks object, item id -> string a CI check tally, N/M only

Every number on that board is derived from your work log by the host, so you cannot type one: counts, column names, the revision, the board's age and the dropped-entry count all come from the work projection. you and checks are keyed by ITEM ID, not by column, so a sentence lands on the card it is about. A checks value that is not a bare N/M is dropped and the cell reads as not said — that cell means a tally you read off a forge, and the work log has none.

Every key is optional: one you leave out reads as not said. A you value shorter than 8 characters or without a space also reads as not said — a name is not an action. notes is read only for the tile keys items, entries and round; any other key is ignored. The host clips what it prints: lede at 400 bytes, each notes gloss and each row cell at 120, a you line at 200, and a clipped value ends [trimmed]. Make the lede name the round it describes; do not write "nothing yet" beside a round tile that is not zero.

How the ledger behaves

Every rule above and below tells you to record something in the session ledger. Three mechanics decide whether that record is still there next cycle, and all three fail silently.

Read the ledger at the TOP of every cycle, before the probe. The [work ledger] block prefixed onto a nudge turn is a teaser, not the record: 1600 chars total, every field truncated to 300 chars, and only the last 3 tried entries. A fleet's item table does not fit in that. "PROBE FIRST" ranks the probe above worker transcripts, not above the ledger — a fired line carries a key, an age and an index, and every row of the action table is a comparison against RECORDED state: which item that session owns, what its last index was, whether its PR is already recorded. Act first and read after, and you have dispositioned a fleet against a 1600-char summary of it. Cadence is not a trade-off here: the read is O(record), never O(loop history), which is the whole reason a patrol loop's per-cycle cost stops growing.

The snapshot arrives on nudge turns only — it is rendered on the patrol wake path. An operator steering you mid-run arrives with no block at all, and that is exactly the turn where you are about to answer "where are the other N". Read before you answer.

A terminal phase silences the snapshot for the rest of the run. render_snapshot returns empty once the phase is terminal (done, abandoned), so drain is a MODE and never a phase: keep the ledger's phase in-flight until the final tally, or you spend the whole drain blind to your own state with no symptom but the absence of a block you stopped expecting.

Write the item map back WHOLE, in ONE session_ledger_record call. Items live in artifacts, a string→string map: a nested object is rejected outright (artifacts_not_string_map) and NOTHING persists, so each item is one single-line string. Then two caps bound it, and neither one errors:

  • 32 entries — which is the same number as max_in_flight, deliberately. One entry is what one item COSTS while you process it, so the entry cap and the fleet ceiling are one ceiling, not two that can disagree: max_in_flight defaults to the cap, and a spec raising it above the cap has configured a fleet whose own worker state cannot fit — honour the cap and tell the operator the spec over-subscribes the ledger. A backlog costs nothing here, because a waiting item is one line inside the provenance entry rather than an entry of its own; queue length and ledger capacity are unrelated.

    Neither number is the fleet size to run. They are a ceiling; the size is what the host can carry this cycle, read from resource_status and the delivery counters (see admission and resource governance). Never write a fleet size into a plan — derive it every cycle, because free CPU and memory are the operator's, not yours, and they move.

    The cap is not a number you check — it is a step you run. Past it the map is trimmed by insertion order and the OLDEST entries age out, blind to whether that item is still in flight, and nothing says so: the ledger's age-out has no notion of an active item. So RECLAIM, THEN ADMIT, in that order, once per cycle before any dispatch write: collapse every settled item to its one-line outcome, drop the ones the final tally already covers, and write that map back. What still occupies a slot afterwards is an ACTIVE item, and only then does a full map mean "no capacity" rather than "not tidied yet". Run it in that order and the deadlock cannot form; check a slot count without it and a map full of finished work reads as a fleet at capacity, which strands the queue permanently and looks exactly like being busy.

  • 2000 chars per value, 128 per key. An oversized value is TRUNCATED, not refused, which cuts a one-line entry mid-payload and leaves a record that reads as present and decodes as garbage. Entries carry pointers, never prose: scope, branch, PR number, session key, state. This is also the real bound on the provenance entry, and the reason it carries the SOURCE and the COUNT and not only the ids — a long enough id list is silently cut, so the entry must be the thing that lets you rebuild the queue rather than the queue itself.

A write MERGES rather than replaces, and that is what makes a partial write unsafe rather than merely incomplete: an omitted key is NOT deleted, so it looks preserved, while every key you DO send is re-inserted as newest. A cycle that records only the items that moved therefore pushes every quiet item to the front of the eviction queue — the long-running worker nobody has heard from is the entry the cap takes first. Read the map, edit it in memory, send all of it. This is the one place "write only deltas" does not apply.

Confirm it landed by READING, not from the write's reply: the record tool answers with the phase and the next intent only, so an eviction leaves no trace in the response to the call that caused it. The next cycle's read is the only place a missing item surfaces — which is one more reason that read is not optional.

Conductor-owned state: conductor-status/v1

The ledger records the items. This file records your own obligations, and it is a schema rather than a convention — write it beside the spec, rewrite it whole each cycle:

Field Holds
schema conductor-status/v1.
updated_at, cycle, mode Last write, patrol cycle count, current mode (dispatch, drain, …). mode is where a steering message lands.
tally Dispatched total, merged, greens awaiting approve, items closed directly, stand-downs returned, skips. This is what answers the human's "where are the other N".
workers One entry per dispatched worker, carrying what the ledger does NOT: scope, branch, worktree, and last_index — the previous cycle's probe i=, which is what makes the no-progress test a comparison instead of something you have to remember. Item state, session key and PR are the ledger's, cached here only for one cycle's fleet view: when the two disagree the ledger wins and this file is what you fix. Two independent spellings of per-item state would drift, and the drift would be silent.
parked Items parked, each with the dependency that parked it, so a park is releasable rather than lost.
open_rulings {worker, pr, question, asked_at} — adjudications a worker is waiting on.
conductor_tasks Work that is yours and no worker's: closing the tracking item, filing a follow-up, the one unblocking base-owned PR.
events_tail Bounded, newest first: the most recent lines of decisions.md, copied verbatim -- a one-cycle view of the durable record, never a second spelling of it (see "What you write down, and when").
resource Last posture reading: delivery counters, load per CPU, memory available, banned count, posture.

open_rulings is reviewed EVERY cycle, independently of what the probe fired, and an entry clears only when the ruling has been DELIVERED — not when you decided it. The reason is structural, not a matter of diligence: the probe is right not to re-fire a signal already marked handled, and that suppression is exactly what keeps a quiet cycle quiet. So a worker on an escalation hold goes silent by design, and a debt you owe becomes invisible unless you keep your own list. The probe tracks the fleet; nothing but this file tracks you.

The list only works if something puts entries INTO it, and that takes two mechanisms — either alone still loses the debt. The probe classifies from the newest protocol message, so:

  • BLOCKED must be sticky across samples. Otherwise a worker that reports BLOCKED, then keeps reporting progress as its brief requires, has its WORKING overwrite the BLOCKED before any sample sees it: the escalation never fires, is never marked handled, and never reaches this file.
  • The worker must keep the BLOCKED: prefix on every turn while it is on an escalation hold, and not switch back to WORKING: until the ruling is delivered. Stickiness cannot recover a signal that was never emitted in the first place.

One is the probe refusing to forget; the other is the worker refusing to stop saying it. A debt survives only when both hold.

Pickup and dispatch

Dispatch is idempotent — every check, every time (skipping them is how two sessions end up on one item and mutual-yield deadlock):

  1. The ledger carries no non-queued entry for the item — dispatched, green_verified, done or parked means skip. An ABSENT entry means NOT YET ADMITTED, which is eligible: the ledger holds admitted items only, so absence is the normal state of a backlog item and reading it as a veto would strand every item the entry cap could not hold. What prevents a double dispatch is not this check — it is the four-way collision check in step 4 and the atomic forge claim below, which is why the ledger being a cache is safe here.
  2. The backlog/findings store (when the pipeline has one) still says the item is open — a queue snapshot goes stale the moment it is built.
  3. claim_preflight.py returns CLAIM for the item (below). That verdict replaces the old one-question "is there an open PR" predicate, which was blind to a covering PR that already merged, to a claim written in prose, and to target code that does not exist on the base yet. If the script is absent from your install — an older build, or an install where it did not land — that is UNKNOWN for every question only it can answer, and UNKNOWN is never permission: answer the merged-PR and prose-claim arms yourself before claiming (they are the two the old predicate missed), or park the item and tell the operator the script is missing. Never fall through to a bare open-PR search, which is the predicate this step exists to replace.
  4. The four-way collision check: no open PR, no merged PR already on {default_branch}, no branch matching branch_pattern, no worktree at worktree_pattern. The preflight answers the two PR arms; the branch and worktree arms are local and yours.
  5. In-flight count < max_in_flight, this cycle's dispatches < max_per_cycle, this cycle's ledger reclaim has run and left a slot for it (see "How the ledger behaves" — reclaim, then admit), and admission admits (see governance).

Then claim atomically — the lock label and the assignee in ONE call, never two. The forge is the cross-operator lock and your ledger is only a cache of it; a claim written as two calls is a window another operator dispatches into. Only after the claim lands: session_create (titled {id}: {item}, filed into the pipeline folder), seed it with the work-order brief, record {state: dispatched, session, ts} in the ledger.

The claim is only valid while a worker holds it. If ANY post-claim step fails — session create refused, create rate limit hit, seed rejected, trust grant unavailable — unclaim before you move on, label and assignee both, exactly as you would on a stand-down. A claim with no session behind it is indistinguishable from work in progress to every other operator, and nothing later in the cycle looks for one: the ledger never recorded a dispatch, so no probe line, no SLA timer and no reclaim path covers it.

On any stand-down, unclaim promptly — label and assignee both — and leave an evidence comment on the item. An item released silently reads as still-yours to the next operator, and an item disposed of with no evidence reads as abandoned rather than as decided.

Queue build exclusion: coverage_filter.py

The work source selects by label and excludes by label, and a contributor who opens a PR carrying Fixes #N applies no label at all. So a queue built from labels alone is mostly work already in flight: measured on this repo against the documented selector, 25 of 29 label-clean candidates were referenced by an open PR. Run this over the WHOLE candidate list before you record the backlog, and again whenever pickup rebuilds it:

"$KIROCREW_RUNTIME_PYTHON" -I -B "<skill-dir>/scripts/coverage_filter.py" --repo <owner/repo> --items 10890,10849,9736 [--json]
"$KIROCREW_RUNTIME_PYTHON" -I -B "<skill-dir>/scripts/coverage_filter.py" --repo <owner/repo> --items -   # numbers on stdin

One forge call for the whole batch, up to 500 candidates — the same question claim_preflight.py check 2 answers per item, asked from the other end so the cost does not scale with the backlog. A larger batch is refused rather than truncated, so page the queue build instead of trimming it.

Exit Line What you do
0 COVERED <n> open-pr=#<pr> … Drop the item from the queue and record the PR as the reason. unvouched=true marks a cross-repository PR whose author has no standing: the item still leaves the queue, and that marker is your cue to review the subtraction rather than let it pass as routine.
0 MENTIONED <n> open-pr=#<pr> … Not a subtraction. A PR references the item with no closing keyword, so it has not claimed to fix it — Refs #N is this repository's idiom for exactly that. Keep the item as a candidate. Carry the PR number into the dispatch brief as somebody looked at this and did not fix it; establish what remains. Occasionally it is work in flight whose author never wrote a keyword, which is what claim_preflight.py re-checks before the claim.
0 UNCOVERED <n> Not permission. Keep the item as a candidate; claim_preflight.py still decides. A reference made in a PR COMMENT is in the item's timeline and not in this answer, so silence here is a smaller view, never a clean bill.
2 malformed YOUR arguments are wrong, including a batch over 500 items. Fix the call — a bad call is not a finding about any item, and a batch this refuses was never scanned.
3 UNKNOWN reason=<slug> The forge could not be read, so NO exclusion was computed. Keep every candidate and carry on; the per-item preflight still runs. Never read it as "none are covered" — the output prints no uncovered list for exactly that reason.

This filter only ever SUBTRACTS, and that is what makes two evidence sources safe. COVERED is a positive finding — a closing keyword aimed at the item in a PR's own title or body — so acting on it can only remove an item. Nothing it prints can ADD an item or certify one as free, so the cheaper evidence can never widen what gets dispatched. MENTIONED and UNCOVERED both leave the item a candidate and are still separate lines, because a reference the filter declined to subtract on is worth a look and printing it as UNCOVERED would be exactly as silent as the subtraction it replaced. If the script is absent from your install, treat it as UNKNOWN: keep the whole queue and let the preflight carry the coverage question, which is slower but never permission you did not have.

Preflight: claim_preflight.py

One call answers every cheap question about one candidate and returns ONE verdict. Branch on the exit code, never on the prose:

"$KIROCREW_RUNTIME_PYTHON" -I -B "<skill-dir>/scripts/claim_preflight.py" --repo <owner/repo> --item <N> \
    [--default-branch main] [--repo-dir <clone of the base>] [--json]
Exit Verdict What you do
0 CLAIM Dispatch it. risk=high on the line means self-claim collision risk: that item is NOT batched — it goes to the live recheck on its own, immediately before claiming.
10 SKIP Covered, or not workable. Leave it alone; record the reason.
11 CLOSE Triage debt, not work. Close the item with the evidence the script printed.
13 REVIEW A closure request was READ in the item's prose. Do not dispatch and do not close. Open the comment the line names, decide yourself whether the item is really done, and then either close it or dispatch it. This verdict exists because prose is the weakest evidence the preflight collects and closing is the strongest response it had.
2 malformed YOUR arguments or config are wrong. Fix the call — a bad call is not a verdict about the item.
3 UNKNOWN A check could not be answered (forge unreachable, rate limited). Never treat this as permission. Re-run it later or park the item.

Exit 3 is why the verdicts are exit codes at all: an unanswerable question is not a green light, and partial data yields UNKNOWN rather than CLAIM.

Pass --repo-dir every time, pointing at a read-only clone of the base that its owner keeps current (the script never fetches). Without it, an item whose merged PR claims to close it, or whose text names a backticked identifier, reads UNKNOWN ... reason=no-repo-dir (exit 3) on every call unless an earlier rule already decides it, and re-running later cannot change that.

Six checks run on every call, and the verdict is the FIRST match down this precedence list:

  1. merged_prs — a MERGED PR that CLAIMS TO CLOSE the item (a closing keyword for this item in its title or body, not a bare cross-reference) whose merge commit is an ancestor of {default_branch} → CLOSE already-fixed. Two conditions, and both are load-bearing: a merged PR that did not land on the base a worker would branch from is not coverage, and a merged PR that merely MENTIONS the item is not closure. A mention decides nothing here — it is neither CLOSE nor SKIP, so it falls through to the remaining checks, because treating it as coverage closes live work and treating it as a claim starves an item whose fix was only partial.

  2. open_prs — an open PR that CLAIMS TO CLOSE the item (a closing keyword for this item in its title or body, not a bare cross-reference), fork PRs included → SKIP open-pr. A fork PR from someone with no standing still SKIPs, but the line carries risk=high and an untrusted-fork marker — treat that as a triage signal to review rather than an item that simply left the queue, because opening a fork PR needs no permission and is therefore a suppression channel.

    An open PR that only MENTIONS the item does not suppress it. The timeline event this check reads fires on a bare reference, so Refs #N — this repository's own idiom for referenced-but-deliberately-not-closed, and the reason the PR template keeps Related Issues apart from a closing trailer — used to take the item out of the queue. Measured over one real candidate list: of 21 (item, covering PR) pairs, 18 carried a closing keyword and 3 did not, and all 3 of those PRs disclaimed the fix in their own words. Such an item now reaches CLAIM at risk=high, with the PR numbers under evidence.open_pr_mention_only in --json. Read that as somebody has looked at this and did not fix it — usually a real dispatch, occasionally work in flight whose author never wrote a keyword, which is why it takes the live recheck rather than the batch.

  3. prose_claim — a closure request in the newest human comment, or in the body only when the item has no human comment at all ("this is resolved", "please close") from the item's own reporter or a repository insider → REVIEW reporter-asked-close at risk=high. Prose never closes anything. It is the weakest evidence this script collects — nine separate false-CLOSE paths reached review in one change, and a ratchet that stops a new unguarded PATTERN cannot stop the next unguarded PHRASING of a pattern already guarded, because the space of English that accidentally means "close this" has no edge. So the detection stays and the response is withheld: you read the comment the line names and you decide. The authorization condition stays too, for a different reason than it had — REVIEW writes nothing, but it does withhold a dispatch, and a suppression any passer-by can cast is the same denial-of-work channel rule 5 refuses to open. A closure phrase from anybody else is not a closure request — it falls through to the remaining checks.

  4. forge_claim — the item's own ownership fields, read off the payload the script already fetched: a claimed or in-progress label (the vocabulary of the default skip_signals, matched by word so crew: in progress counts and unclaimed does not), or any assignee → SKIP forge-claim, with the label term and the assignees on the line. Either field alone is a claim. It is honoured for anybody but you: a label with yourself as the only assignee is the shape of your own atomic claim, so re-checking an item you hold still reads CLAIM; a label with nobody assigned is unattributed and reads as foreign; so does every claim when your own login cannot be read. This is the live half of the queue build's label exclusion — a claim that lands after the queue is built is exactly what the recheck immediately before the atomic claim exists to catch, and until this check existed it could not: four live items in three days carried another pipeline's claimed label and read CLAIM. It sits below rule 1 on purpose, so an item that is open, claimed and already fixed still reads CLOSE.

  5. prose_claim — a self-claim ("I'm claiming this", "working on this") from the item's reporter or a repository insider → SKIP prose-claim. A claim written in prose is invisible to every label and field query that exists, which is why it is scanned for rather than inferred. From anybody else it is NOT a veto: annotate the item risk=high and let it take the live recheck instead. The reason is that a veto anyone can cast is a denial-of-work channel — a single comment would suppress a queued item indefinitely, and nothing in the pipeline would ever report that it had been suppressed. Downgrading keeps the collision protection where the claim is credible without handing an arbitrary commenter a mute button.

    A claim is retired by a later withdrawal from the same author ("dropping this"), including one written in the issue BODY — otherwise a claim nobody is honouring suppresses the item forever. Bot comments never claim and never close. Ownership is read from the newest STANDING claim, not from the last comment, so a passer-by's "any update?" does not clear a claim.

  6. symbol_on_base — a symbol the item names is absent from {default_branch}. Absence alone is not a SKIP. Corroborated as bug-class, it is SKIP symbol-absent: the target code lives only on an unmerged branch, so that is a park, not a dispatch. Uncorroborated it downgrades to CLAIM risk=high, because a feature request names the symbol it PROPOSES to add — vetoing on absence alone would permanently park every item of that class.

  7. any check errored → UNKNOWN.

  8. otherwise → CLAIM, annotated with risk from the recency check (a recently opened item from an active contributor is a high self-claim risk).

A PR that only MENTIONS the item is a POINTER you hand over, not a verdict you drop. This holds on both sides of the merge boundary, and for the same reason. The script finds the reference — the timeline it reads is state-agnostic, so a Refs #N with no closing keyword and no closingIssuesReferences is collected and then correctly falls through to CLAIM, because a mention is neither coverage nor a claim. But a pipeline that prefers Refs whenever a residue remains — which is the right house style, since Closes would shut items whose remainder nobody has addressed — manufactures precisely this shape, so the gate's blind spot is the shape of its own output. That is how the OPEN side came to suppress: it read the bare reference as coverage for a while, which quietly withheld every item a PR had deliberately left for somebody else. Carry the PR number into the dispatch brief as a PR may cover part of this; establish what remains, and the worker's preflight starts where yours stopped instead of rediscovering it.

A triage comment routing the item away from automated fixing is a POINTER TO A QUESTION — and the LABEL is noise. A needs-human-class label sits on the majority of an aged backlog, so filtering on it removes most of the pool including plenty of genuine obvious bugs; the signal is a comment that explicitly routes the item, and only the comment. Then two properties come apart, and they diverge in BOTH directions: a routing comment can be citation-dead (every line number moved) and still be directionally right, or be perfectly cited and VOID because the parent PR it declared the item blocked on has since merged. So the test is not "is a routing comment present", and not even "does its evidence still hold" — it is re-answer the question the comment was asking, against the current base. On a repository moving hundreds of commits a week, a week-old comment is routinely right and dead at the same time.

That test needs the code read against current {default_branch}, which makes it the WORKER's preflight and not your claim gate. So your gate ANNOTATES and the brief DELEGATES — a triage comment routes this away from automated fixing; re-answer its question against current {default_branch} and STANDDOWN: routed — <evidence> if it still holds. A stand-down there costs one preflight and is a correct outcome, not a defect.

A claimed label you find in the wild is evidence of INTENT, never of ACTIVITY. The test is whether a branch, a PR, or a commit exists. A claim whose work never started becomes a lie that deters every other operator indefinitely, which is also why a claim YOU release must be verified released — assignees back to zero, not just the label removed.

risk=high is a decision, not a note. A high-risk CLAIM is NOT batched: it goes to the live per-item recheck immediately before the atomic claim, on its own. A REVIEW is always high-risk and is not a dispatch at all: it goes on your own list, and it clears only when you have read the named comment and either closed the item or dispatched it. An annotation nothing acts on is the same defect as a prose predicate — it reads as caution and changes nothing.

A batch snapshot is never the authority. Preflighting a batch is how you order a queue; a per-item live recheck immediately before the atomic claim stays mandatory, because coverage can appear in the seconds between the batch and the claim, and on a queue other operators work, it does.

An item that is open, claimed and already fixed is triage debt, not a work item. Its verdict is CLOSE with the landing-commit evidence, and closing it IS the work. Dispatching a worker to rediscover that the work does not exist spends a whole session — create, seed, preflight, stand down, unclaim — to learn nothing, and leaves claim churn on a repository other operators are reading. An item that merely READS as already fixed is not the same item. A merged commit that is an ancestor of the base is evidence; a sentence saying so is a reading, and it comes back as REVIEW for you to confirm.

Dispatch mechanics

  • session_create MUST pass the worker agent explicitly. An unset agent binds the worker to YOUR agent, which has no file-writing tool, and the entire batch then refuses the work with a plausible-sounding explanation of why it cannot edit files.
  • Validate with ONE canary dispatch before a batch. A wrong agent or a broken brief costs one session that way, and the whole batch otherwise.
  • Respect the session-create rate limit: 20 session_create calls per 5-minute window per caller (folders: 10). max_in_flight defaults to 32, so a full-width dispatch round CANNOT complete inside one window — plan two rounds, and remember that a create refused by the limiter is a post-claim failure, so unclaim per the rule above.
  • Worker sessions must be in trust mode before seeding — an unattended session stuck on an approval prompt runs zero turns. No tool you hold grants it (session_create takes no permission mode), so the operator arms each worker session; ask for that and do not seed a session that is not armed.
  • Check the SHARED CHECKOUT once, before you cut worktrees from it. One git status --porcelain there. It is the shared root of every worktree in the fleet, so it is exactly the state a conductor is supposed to inspect before pointing dozens of workers at it — and it can carry hundreds of staged files left by an operation that predates your run. Each worktree has its own index, so a worker committing from its own tree is unaffected; the exposure is that your seeds send workers to cd there for forge calls. Do NOT clean it: a reset there is destructive and belongs to whoever left it. Take the non-destructive half — the brief's commit ban — and report what you found. Staged state there is usually a whole-tree overlay that never moved HEAD (git checkout <ref> -- .): <ref>'s tree plus the files <ref> deleted. In the shared checkout, test <remote>/<base> first, then its reflog entries newest first, stopping at the first match or the first one HEAD is not an ancestor of. A <commit> matches when it is not HEAD, git merge-base --is-ancestor HEAD <commit> exits 0 and git diff-index --cached --quiet --diff-filter=a <commit> exits 0; an exit above 1 is unreadable, not a no. On a match, report the owner's repair: git merge --ff-only --no-autostash --no-overwrite-ignore <commit>, or git reset --keep <commit>, the same move except that it also unstages anything else staged. Both refuse rather than overwrite a local edit, and neither is yours to run. Re-read the base head rather than caching it, too: the default branch moves under a long run, and a worker preflighting against a remembered sha is preflighting against the past.
  • Before commissioning a fix for a base-wide breakage, search open PRs for one that already exists. Fleet-wide breakage is visible to the wider community too, and two identical fixes waste a worker and a review lane.
  • RETASK a warmed-up worker rather than closing it and paying for a create. A session that has already read the brief, learned the host's forge constraints and absorbed the reporting protocol is cheaper to re-point than to rebuild, and creates are rate-limited. One condition: the new item must have been through the claim gate first, or retasking just moves a duplicate dispatch to a new number.
  • Never hold a session idle waiting for a decision. A session waiting is not a session working. The test for whether a park is legitimate is whether it holds context that would be expensive to rebuild — a completed survey already written down in its report does not qualify, so retask it and stand a fresh worker up if the decision goes the other way.
  • A list whose contents are DERIVABLE must be derived, never typed. Pending is claimed MINUS sessions MINUS released, computed each cycle. A hand-maintained copy drifts in both directions at once and does so silently: items claimed and never dispatched fall out of it and become invisible, while items that were never claimed appear in it and get worked with no claim protecting them.
  • The issue → session mapping lives on disk because it answers a different question from the one you are holding in your head. It answers who owns this item now; your memory of the dispatch answers who sent me this message. A report arriving from a session you already closed is not stale because it resembles the other stale ones — it is stale only if the mapping says a live worker owns that item. One lookup decides it, and a report that looks like the others is exactly where guessing costs a real dispatch.

Partitioning one change across several workers

When one change is too large for one worker, split it by exclusive file ownership: every file belongs to exactly one worker, and nobody edits outside their own set — not a one-line mention, not a docstring cross-reference. Two workers on one file is the merge-conflict version of two sessions on one item.

Exclusive ownership removes merge conflicts. It does NOT remove a review-order dependency, and that is the trap. A premise-level reviewer counts a mechanism's CONSUMERS in the base, so the PR that BUILDS a mechanism reads as dead code until the PR that WIRES it has landed: nothing in the base invokes it, the zero option is behaviorally identical, and the reviewer is right on the evidence available to it. So a partition ships with a merge ORDER: the wiring PR lands before or with the building PR.

The remedy for a block of that shape is sequencing, and sequencing is YOURS. A worker must never move another worker's hunk into its own PR to satisfy a reviewer — that dissolves the ownership split, creates the conflict the split existed to prevent, and hides a review-order problem as a code change. Land the wiring PR; the same reviewer's own grep then finds the consumers and the block dissolves with neither PR changing content.

The work-order brief (seed message skeleton)

Fill {...} from the spec; keep every clause — each one closes a failure mode:

You own exactly ONE item: {item} on {repo}. Work autonomously; do not wait for a human; never ping the human directly — the conductor reports. PREFLIGHT (mandatory): view the item; check open PRs and worktrees for overlap — if anything already covers it, reply STANDDOWN: <reason> and stop. Never adopt another session's WIP. REPRO ADMISSION ({verifier.repro_gate}): expand this clause from the spec. In pod_required mode, keep the worktree byte-clean and run kirocrew pod scenarios, choose the closest shipped state, boot the UNMODIFIED worktree in that scenario, and drive the externally visible failing behavior through pod api, pod-e2e/Playwright, or another real product route. Run pod status/token/API commands through the worktree's ./.venv/bin/kirocrew after provisioning: the globally installed binary may be sandbox-blind to the pod process's sockets and fail closed on ownership proof. If playwright-cli cannot launch on the host, the repository's own Playwright runner against the same live pod is equivalent evidence; record the engine and launch flags, and treat a missing REQUIRED engine (for example Safari/WebKit-specific behavior) as missing=<capability> rather than silently substituting Chromium. Record the scenario, exact probe, and failing observable. A unit test, direct import, simulated error, or post-fix friction note is NOT admission. No live pod red → STANDDOWN: pod-repro-ineligible — <evidence>; missing=<capability> and STOP with no edit, commit, or PR. Live red admitted → implement, then run the SAME pod trace green and tear the pod down to zero residue before GREEN. CONFIRM the mechanism before fixing: in best_effort mode, reproduce where cheap; wrong premise → STANDDOWN: premise disproven — <evidence>. A design decision → PROPOSAL: <link> (write the proposal on the item; do not build). IMPLEMENT in your own worktree ({worktree_pattern}, branch {branch_pattern} from {default_branch}): root-cause fix; regression test red-on-base and mutation-verified. TESTS: your own changed test files, BY PATH, and nothing else. Name the ban rather than implying it — no make test, no tox, no nox, no run-tests/local-gate/"run the gates" wrapper of any kind: a wrapper that escalates to the full suite satisfies the letter of a targeted-only brief. The ban is on suite wrappers, NOT on the push gate below — preflight.py and push_guard.py spawn only git, gh and the OS tree-kill tool, and run no test at all (--commit / --squash run the repository's own git hooks, as any commit does), so a targeted-test brief never licenses an unguarded push. Pass -n0 explicitly on every run: omitting -n does not mean single process, it inherits whatever the project's pytest addopts sets, and -n auto is a common default. Canonical line — timeout 900 python3 -m pytest -n0 <test file> -x -q </dev/null. Do not substitute a small -n <N>: xdist workers contend for scheduling and are what starves under fleet load, and an explicit count also bypasses the memory budget auto is put through. HARNESS (this is what makes your evidence mean anything): before you cite any test run, check WHICH checkout it imports -- from your worktree, for a Python target, python -c 'import <pkg>; print(<pkg>.__file__)'. If that path lies OUTSIDE your worktree, the target runs one shared environment whose editable install points at the MAIN checkout's src and your worktree has no environment of its own: a bare pytest there imports MAIN's module, not the file you just edited, so set PYTHONPATH=<your worktree>/src on every run whose result you will cite. If the path lies INSIDE your worktree (a per-worktree environment), set nothing -- an unneeded PYTHONPATH can shadow the correct import. With the wrong import path a mutation applied to YOUR file cannot redden, and "the mutation did not redden" then reads as a weak test when the truth is a broken harness. So: if a mutation does not redden, suspect the harness BEFORE you conclude the test is weak. Beware the two subtler cases -- where the base ALREADY has the behaviour your test asserts, a green suite can look partly correct for entirely the wrong reason. NEVER COMMIT FROM THE SHARED CHECKOUT. You may cd there for gh calls, but its index is not yours and may hold hundreds of staged files left by another operation, so one git commit -a there — or any commit after staging even a single file — sweeps that staged work into your PR. Each worktree has its own index; commit only from yours. Run nothing there that moves its HEAD or writes its index or files (merge, pull, reset, clean, checkout, restore, gh pr checkout, gh repo sync); report its state and leave the repair to its owner. Never git stash in any worktree: every worktree shares one stash list. REMOTES: export GIT_TERMINAL_PROMPT=0 and confirm gh auth setup-git has run before any push — a bare https push does not use the CLI's token and hangs on an interactive prompt indefinitely. If a push exceeds ~2 minutes, time the actual pre-push hook over the real payload before naming a cause: process liveness cannot distinguish a credential prompt from a slow hook. PUSH GATE (mandatory, every push): the scripts live in <gate> = <crew-home>/skills/kirocrew-dev/kirocrew-prepare-pr/scripts, where <crew-home> is KIROCREW_HOME when set and $HOME/.kiro/crew otherwise. Invoke them through Kiro Crew's runtime interpreter the way Startup invokes spec_check.py, and quote the resolved path: on POSIX "$KIROCREW_RUNTIME_PYTHON" -B "<gate>/preflight.py", on PowerShell & $env:KIROCREW_RUNTIME_PYTHON -B "<gate>/preflight.py". -B preserves the desktop bundle's no-bytecode-write rule. Do NOT add -I here even though Startup passes it: preflight.py imports its sibling push_guard, and isolated mode drops the script's own directory from the import path, so -I turns the gate into a ModuleNotFoundError on every push. Run preflight.py before the first commit. Then before EVERY push confirm git status --porcelain is empty and run <gate>/push_guard.py --base {default_branch} --max-ahead {max_commits}, which refuses a stale base, a replayed upstream commit, anything staged that HEAD lacks, or a changed path the gate did not commit on this branch and no other author's pushed commit carries (a commit made by hand). When it lists such paths, read each diff: name the ones that are yours after -- on the same command (that vouch rewrites nothing), and report any that are not. Pass --max-ahead explicitly and fill it from the spec, never from memory: the script defaults to 5, which is looser than most repositories' own PR commit-count gate, so omitting it lets a branch read SAFE TO PUSH and then fail that gate. Add --require-single-on-base only when you actually squashed to one commit with the gate's --squash; it asserts HEAD's only parent is origin/<base> and that the gate committed every path HEAD changes, and refuses a legitimate multi-commit branch or a squash made by hand. Commit and amend through the gate, by name: <gate>/push_guard.py --commit -m "<subject>" -- <path>... (or -F <message file>) and --amend -- <path>..., never git commit -a or a bare git commit: the index can hold paths you did not stage. Squash, if you squash, with <gate>/push_guard.py --base {default_branch} --squash after writing the message to <git-dir>/prepare-pr-commit-msg-<branch>.txt (/ as -): it runs the checks above on the commits BEFORE squashing them (so never --max-ahead {max_commits} there), then commits your branch's tree, never an index, and --require-single-on-base then enforces the one commit. A 40 whose text is the commits-ahead refusal, on commits you authored, is answered once with the --max-ahead N it prints. Before any git rebase --continue, <gate>/push_guard.py --check-index must exit 0. Read the exit code, do not just test for zero: 0 proceed; 30/40/41 the gate REFUSED, so do not push and report the code with the branch state and stderr (a git or network failure inside the gate is a 40 whose stderr names the failed command; a 41 prints the staged paths and their remedy — follow it); 64 is your own command-line mistake — fix it and retry; 2 the gate could not RUN — not a git repository, no git, or a missing script — so do not push and report BLOCKED: push gate inoperative with the code and stderr, because a worker whose sandbox cannot reach the scripts has to surface that once instead of stalling every item silently. A non-empty git status --porcelain is also a stop. Unstaged work and a stale base are what otherwise reach the PR and cost a review round to find what a git-only check catches in a second. PR: English body (What/Why/How/Tests/Other), Closes #{n}, full URL in your reply. Do NOT merge and do NOT arm auto-merge (enable_automerge.py, gh pr merge --auto), whatever another skill's default says: the conductor verifies your GREEN and a person approves the merge. Babysit to green (monitor_start ~300s, staggered off a round number so a dozen loops do not poll in lockstep, preferring REST over GraphQL/search — the whole fleet shares one account's rate limit). Fix every Critical/High; disposition every advisory explicitly; read reviewer JOB LOGS for the current head, not check conclusions; rebut with measurement, never assertion; before re-running any CI job ask what the re-run REPLACES, not what it retries; NEVER /ai-review override without the conductor's sign-off — a blocking finding you dispute is BLOCKED: <evidence + 2-4 options>. ENUMERATE, do not spot-fix. For any guard, matcher, allow-list or detector you touch, list EVERY signature it is meant to cover and give a per-item verdict on which your change catches. The reported case is by construction the one the reporter already understood; the set is where the hazard lives, and a fix that merely makes the reported case work can make a worse one reachable. Where a second site LOOKS like a copy of the defect and is not, say so and say why — that is worth as much as finding one, and it is what stops a follow-up issue being filed against healthy code. MEASURE, do not read. Adding a candidate FORM and relaxing an acceptance PREDICATE are different changes and only one of them weakens a gate; you cannot tell them apart from the diff, so RUN it. Compare identifiers on BYTES against ground truth, never by eye — that is the only comparison that catches a homoglyph, a hyphen for an underscore, or trailing whitespace, and a silent authentication-false class deserves it precisely because nothing downstream will complain. PIN every residual you leave behind with a test asserting the CURRENT wrong answer, so it cannot be quietly forgotten and whoever closes it knows what success looks like. And prove your tests exercise YOUR change rather than something already true: stub the new path out and show the matrix reverts. A GATE OR POLICY BLOCK IS A VERDICT, NOT AN OBSTACLE. When a publication gate refuses your text, ask what the blocked token was FOR before reaching for an override — a gate firing on a detail nobody needs is a prompt to write more clearly, and satisfying it by rewriting beats bypassing it. When a tool policy refuses a path to evidence, DROP that line of evidence and cite the weaker source with its weakness labelled; never find a route that technically succeeds. REPORT with exactly one of six prefixes — WORKING: / PR: / GREEN: / BLOCKED: / STANDDOWN: / PROPOSAL: — and RE-STATE the prefix on EVERY later turn while this assignment is open (an unprefixed turn reads as "no status"). Write it as BARE leading text: no bold, no italics, no list marker, no blockquote ahead of it. The tag is matched at the START of the message, so **BLOCKED:** can read as no status — and it does so on the one message you most need heard. Once you report BLOCKED:, KEEP that prefix on every turn until the ruling reaches you — do NOT switch back to WORKING: while you are on an escalation hold. The conductor samples your newest protocol message, so a WORKING: line posted after a BLOCKED: can overwrite the escalation before it is ever seen, and the ruling you are waiting for is then owed by nobody. GREEN must carry the PR URL, head SHA, and a 3-6 step plain-language summary.

Sending an order to a worker

Every rule here closes a way an order fails SILENTLY, and silence is the failure mode you are least able to detect.

Before composing an order, re-read the last order you SENT that worker AND confirm no newer report from it is pending. Both halves are needed and they fail differently. A new order that contradicts a standing one produces NOTHING: a worker facing two of your instructions resolves toward the restrictive reading, does not act, and tells you it did not act — which is correct behaviour and your fault. And a ruling composed from a report that a newer report has already superseded orders the worker to publish facts it has since disproved. Re-reading your own last message catches the first; only checking the worker's queue catches the second.

A reversal must NAME what it reverses. "Post the finding" does not obviously outrank "do not comment on that PR" when both came from you and the earlier one was framed as a rule. Revoke by name, in both directions — including when new content flips a refusal you made on content grounds, which is the rule working rather than you being inconsistent.

Never state a ranked option as a PREFERENCE. State it as a CONDITION. You do not read the code, so your preferences are hypotheses and must be written as hypotheses: do X PROVIDED Y holds, and Y is checkable like this. A preference gets complied with; a condition gets tested — and a condition attached to your own preference is repeatedly what catches the preference being wrong. Several checkable conditions also beat one instruction: a single condition can only ever produce a correct BLOCKED report, while a pair can find the narrower door that lands the fix.

Any instruction to publish evidence says: the reproduction must be self-contained; never cite a local path. A path on this host leaks an internal directory into a permanent public thread and is useless to every reader who cannot open it. Reproducibility is the intent; naming the path is a flawed implementation of it, and the leak is worse than the shell-command case because it is permanent and public.

A claim about CHANGE needs two observations

The characteristic conductor error is EXPLAINING AWAY evidence instead of accounting for it, and the tell is always available in data you already have. Three shapes:

  • A worker's action matching an order you just gave is not proof your order caused it. Compare timestamps before claiming it did. Verifying that a thing exists is not verifying that you caused it — and a fabricated precedent is worse than the original mix-up, because it propagates into later rulings where nothing will contradict it.
  • A lane you expected to be red reading green is not a misread. Ask what CHANGED it. The cheapest cause to check, and usually the right one, is that the worker already fixed it.
  • "It has moved since your last read" is a claim about TWO observations. Make it from one and you are asserting a transition you did not see, in a run whose entire premise is that state moves under you.

Workers will correct you on these, with a job id and a timestamp. That is the instrument working, not friction.

The probe cycle

One fleet_probe.py call. Keep probe-config.json's sessions list synced with the ledger's open sessions (add on dispatch, drop on close). Fired lines carry metadata only — the probe never emits transcript text:

🔔 <key>  <age>s <TAG> i=<index> d=<digest12>
BANNED pid=<pid> rule=<regex|argv:<shape>> cwd=fleet|unknown age=<secs|?>s scope=suite|paths|unknown cmd=<program,flags,+withheld>
OK <n> watched, <m> fired | load/cpu <x> (ok|hot) | mem <n>G | banned <n> | foreign <n> | deliver init-timeout <a>, watchdog <b>

A tail with no protocol tag reads as - and never fires on its own, and a protocol word inside a tool card is quoted text rather than a report — so a worker whose only "status" is in a tool call is silent as far as the probe is concerned, and ages into IDLE.

cmd= on a BANNED line is the matched command reduced to what cannot hold a secret: a recognised runner or launcher name, recognised option names with their values dropped, +<n> for the arguments withheld, and a trailing ~ on any single token long enough to be clipped. NO option value is printed, the worker-count flag's included — -n0 prints as -n, because a custom rule can point this scan at a program whose -n value is a numeric secret and nothing tells that apart from a worker count. Recognised means drawn from a fixed list, so a program or long option the list does not name is counted rather than printed — a word that looks like a program name is also exactly what an opaque credential looks like. One program spelling is recognised by SHAPE instead: a versioned pytest alias, which prints as the fixed label pytest-<version> or py.test-<version> rather than as itself, so the version it carried never reaches the line. An inline KEY=value in front of the command is withheld whole. Read it before stopping anyone — it is what separates a real budget-bypassing run from a command that merely names one, and no argv is echoed. When the program itself is withheld, rule= is what identifies the command: it is the rule that selected this pid.

rule= carries one of two vocabularies. A value that reads as a regex is the banned-process rule whose match selected the pid. A value prefixed argv: names a shape the probe recognises from the argv tokens instead, because the joined command line cannot express it: argv:pytest-runner-uncapped is a runner spelling that is also a well-formed filename or path component — a versioned alias (pytest-3), py.test, or pytest.exe — standing in the program position with an explicit numeric worker count of two or more among its own arguments (the budget-bypassing form; "uncapped" is the label's historical name, kept because consumers pin it). There is no regex to look up for such a row, so cmd= is the corroborating field: the runner name prints there, because an argv: row has no rule text to identify it by. An argv: shape is offered whatever the rule list holds, because rule ORIGIN is what carries built-in authority here — the same basis the wrapper exemption is written against — so a banned_process_res edit cannot switch it off. What can is the named opt-out argv_runner_detection: false, which disables this shape and nothing else. The two settings are independent: replacing the rule list leaves the shape on, and disabling the shape leaves the rules in force.

The handled set keeps the last dispositioned PAYLOAD report as settled, so a later IDLE or NOPROGRESS mark on the same session cannot resurrect a ruling you already delivered. You do not maintain this — it is written on every mark.

When a ruling needs content, read that one session through the workspace-authorized session tools. Act, then --mark-handled KEY TAG DIGEST (DIGEST is the d= field on the fired line), or the signal re-fires forever. A stale digest is refused (exit 3): the payload moved on since you read it — re-probe and act on what is there now, never mark blind.

Classification anchors at the START of a worker's message, and tolerates leading markdown. A worker that writes **BLOCKED:** is following the protocol and must be read as blocked, so the classifier strips leading emphasis, list markers and blockquote marks before matching. That belongs in the probe rather than in the brief: a rule the worker has to remember fails exactly under the pressure that produces escalations, and the message that goes unseen is then the escalation itself. The brief asks for a bare prefix as well, but as the weaker half of the pair.

i= is an absolute per-session message counter, counted from the start of the transcript — not an offset within the tail window. That distinction is the whole rule: a window-relative index saturates once a session grows past tail_bytes and then reads as a frozen number, which is exactly the no-progress deadlock the field exists to detect. Absolute counting costs nothing extra, because tail_bytes bounds how much of the file is PARSED, not how much is read from disk — the read loads the whole transcript either way. The index is carried into the handled-set entry as well, so the comparison is available next cycle without you having to hold it.

An unchanged index since you last acted is no progress, whether or not a turn is open — it is the one discriminator a self-deadlocked worker cannot fake, because producing a message is the thing it cannot do. The probe makes that comparison itself and fires NOPROGRESS, so read the tag and never diff two cycles by eye: a comparison that lives in this document is enforced by nothing, so it may simply never happen. i= is the corroborating number, not the test. "Still working" is not something the probe can tell you at all — that reading comes from session_read_message's running flag, and an open turn is satisfied by a shell deadlocked on its own child just as well as by real work.

One-time degradation on the first probe after an upgrade. The fired-line digest is keyed on the classified tail text, so any change to what gets classified rotates the digests. A signal that was already marked handled, and whose classification moved, therefore re-fires ONCE on the first cycle after the probe is upgraded. Expect a burst of re-fired signals there and disposition them against the ledger rather than treating each as new work; it is a consequence of digest keying, not a defect, and it does not recur.

Line Action
ERR Batch-resume the affected workers (session_send: "resume; re-state your protocol prefix").
PR Record PR number + head in the ledger.
GREEN Verify independently (below). Pass → digest + mark item green_verified, backfill a queued item. Fail → send the worker the delta.
BLOCKED Adjudicate (below). Record it in open_rulings and clear that entry only once the ruling is delivered via session_send.
STANDDOWN / PROPOSAL Verify the evidence is stated; record the disposition; unclaim with an evidence comment; close or re-queue; backfill.
TERMINAL The last dispositioned protocol report was terminal and the session has gone quiet since. Close it out — confirm the disposition landed, release the claim, session_close. Do NOT nudge: a finished worker has nothing to re-arm, and a monitor loop is only correct while something EXTERNAL can still change.
IDLE Intervention ladder (below).
NOPROGRESS The session has produced nothing — no message, no tool row — since you last acted on it, and that mark is at least one idle_alert_secs old. Check the EFFECT, never liveness: did the artifact appear, did the remote head move, is there a new commit. Effect present → healthy-slow; extend and name the expected completion signal. Effect absent → route on the line's own age, because two paths reach this tag and they do not mean the same thing. Within idle_alert_secs the transcript is WARM — held alive by inbound traffic the session never answers — and the first move is not a nudge, since a nudge is more of the input that produced the reading: enter the intervention ladder at its Inspect step. Past idle_alert_secs the session is cold as well as unproductive, so IDLE's ladder applies from the top: the classifier ranks this tag below the clock, but the suppression fallback substitutes it for an already-dispositioned report with no age test, so a cold line can carry it.
GONE Transcript missing — treat as reclaim: re-queue the item with evidence.
BANNED pid=… Banned-ops response (below), keyed by the line's OWNERSHIP CLASS: every class is recorded, and a stop is reserved for cwd=fleet. Never read this as a single actionable-or-not decision. Read age= to tell the SAME line apart across cycles: an age that GROWS between cycles is one process you have not managed to stop, while a small age under a re-appearing pid is a fresh violation on a recycled number — a bare pid cannot separate those. age=?s means the age is unavailable: the process already exited (the expected reading for a short-lived runner), the pid was recycled between the probe's reads (in which case the whole record is stale and cwd also drops to unknown), or the platform has no /proc/sysconf to read it from. It never means the process is new. Read scope= to rank two banned lines against each other: suite is a whole-suite or whole-directory run and is the one worth interrupting first, paths is a run already narrowed to files or selectors, and unknown is a match naming no runner the probe can read a target from — never treat unknown as paths. scope orders the queue; it does not gate the response. The stop stays keyed to cwd=fleet alone, so a paths-scoped fleet line still draws it — scope only says which fleet line to reach first. age says whether it is the same line as last cycle.

The OK line's deliver init-timeout <a>, watchdog <b> counters are the admission instrument, not fleet trivia — see governance.

If your probe does not emit these fields — an older bundled probe, or a build cut before they landed — they are ABSENT, and absent is UNKNOWN rather than a clean reading, exactly as it is for the preflight:

  • No deliver counters does NOT mean the fleet is delivering; it means you cannot see delivery. Fall back to load and memory AND hold admission below max_in_flight, because the posture table's ample row would otherwise be satisfied by the absence of its own instrument.
  • No i= leaves you no progress test. Diff the EFFECT across cycles instead (artifact, remote head, new commit) and say in the ledger that progress is unverified.
  • No TERMINAL tag means a finished worker still ages into IDLE. Read the last protocol report before nudging, or you will nudge a worker that correctly has nothing left to do.

An instrument you cannot read is never a green reading. That is the same rule as exit 3, applied to the probe. And read the fallbacks as the NORMAL path rather than an edge case whenever the installed probe predates these fields: on such a build every cycle takes the degraded branch, so "hold admission below max_in_flight" is the effective default and the delivery-keyed posture table is aspirational until a probe that emits the counters is installed. Check once which of the two you are running rather than assuming the table applies.

Never page through worker transcripts yourself, and never pull the whole fleet's state into context — the probe line is the interface. Quiet cycle: print nothing beyond the probe's own OK line and end the turn with nothing_to_do, because a bare stop after the probe keeps the empty-response recovery.

Independent green verification

Never trust a worker's GREEN (workers believe their own summaries):

  1. Check-runs for the claimed head SHA, collapsed per lane, newest run wins (a force-push leaves stale duplicates); zero red; PR MERGEABLE.
  2. The head SHA matches the claim — a green on yesterday's head is not green. This is the step that catches a mis-reported head SHA.
  3. Reviewer verdicts read from job logs and marker comments, never run conclusions — they lie in both directions.

Verified → ledger green_verified with the SHA + check snapshot, then the human digest: per-PR, plain language (digest_language, default: the operator's chat language), what/why/risk in 3-6 steps, full PR URL. The digest is what keeps a large merge queue reviewable at a glance — never skip it.

Intervention ladder (looping / self-doubt / wasted time)

Signals: IDLE twice in a row; same tag across ~5 cycles with rising turn count; credit burn with no ledger transition; ERR recurring after resume.

  1. Nudge (session_send): restate the next step + protocol requirement.
  2. Inspect — spawn_run ONE bounded inspector and bound it in the TASK TEXT. spawn_run takes no allowed_tools parameter, and the underlying field is ignored for ACP-backed agents, so read-only cannot be a property of the spawn. Pin a read-only AGENT instead (agent= a spec with no write tool), say "read only; modify nothing" explicitly, and treat any write the inspector reports as an escaped constraint to raise with the operator. Task: "Read the tail of session {key} (session_read_message) and the state of PR #{n} on {repo} (web_fetch the PR page). Return one verdict — healthy-slow | looping | blocked-misclassified | premise-wrong — plus two sentences of evidence. Do not modify anything."
  3. Rule on the verdict:
    • healthy-slow → extend; note the expected completion signal.
    • looping → session_stop, re-dispatch a FRESH session with a sharpened brief naming the loop (context poisoning rarely self-heals).
    • blocked-misclassified → adjudicate it yourself as if BLOCKED.
    • premise-wrong → open-issue mode: the worker files an issue with the evidence and partial diff, descopes the PR to what is defensibly green, dispositions the rest as deferred-with-cross-reference, drives the narrowed PR green. A decorative fix is worse than no fix.
  4. Reclaim on SLA breach (no event for 3× idle_alert_secs): mark reclaimed, re-queue or skip with evidence, close the session.

Two sessions on one item: decide ownership once — adopt one, fully stand down the other. Two owners politely yielding to each other is a deadlock.

Outage recovery and loop liveness

An approval or transport outage does not merely deny the one command in flight: it kills the worker monitor loops AND your own patrol loop. Every loop on the host stops, and no loop reports its own death.

So recovery is a fleet-wide sweep, not a reply to whoever signalled. The worker whose ERR line you happened to see is the one that was mid-call, not the only casualty. In ONE pass, for ALL workers: resume, then re-arm.

Make the re-arm conditional: "re-arm your babysit loop IF you have a live PR to watch; otherwise report terminal and stop." A loop is only correct while something EXTERNAL can still change — an unconditional re-arm on a closed-out item wakes a worker to re-read something nobody is acting on, and fires the probe for no signal.

Then check yourself: no wake within the patrol interval after recovery means your own loop is dead and needs a fresh monitor_start. State the limit plainly, because it bounds what this procedure can do — a conductor cannot detect its own loop's death from the inside, since the only symptom is the absence of a wake, and an absent wake is precisely the state in which nothing runs to notice it. An operator message or an external watchdog is the only thing that closes that hole.

Adjudication (BLOCKED) and overrides

This is the EXCEPTION PATH. You enter it when a worker reports BLOCKED, never by surveying boards to find something to rule on. A BLOCKED report is a request for a RULING, and it must arrive with the evidence and the 2-4 options already assembled — if it does not, send it back for them rather than assembling them yourself. Then rule from what the report contains plus ONE verification against the current head. Going and re-deriving the situation is the behaviour the two-column mode exists to stop, and it is not made legitimate by the word "adjudication".

Rule by:

  • Finding real, remedy wrong (the classic: "revert") → look for the narrower forward fix the finding's own wording points at.
  • Real but out of scope → route: fix now / own PR / backlog with cross-reference. "Not applicable to THIS PR" is the honest disposition for a zero-delta-vs-base finding — never "false positive".
  • Deterministic red inherited from {default_branch} → prove base-owned three ways (base's own run red; gate postdates base; file absent from the diff) → ONE minimal unblocking PR for the whole fleet.
  • Override only when ALL hold: every lane settled · sole red · head SHA pinned in the override text · rationale public on the PR · branch push-frozen afterwards except review responses. Record every ruling with its rejected options. What reaches the human is the four classes in "What you decide, and the four things you escalate" -- nothing else does; a design or product call inside one item is yours, and an item that IS a design ask was refused at intake under policy.refuse_design_asks.
  • One sample, then the class. A mass anomaly — the same red on every open PR, an identical failure across workers — is diagnosed from ONE sample and fixed as a class. Reading all N of them is the expensive way to learn the same thing N times, and it delays the one fix that clears them all.
  • Subtraction, when rounds keep reopening in one place. Consecutive review rounds landing in one function span indicate ONE unwritten contract, not N separate mistakes. The convergent ruling there is usually to DELETE the mechanism or split the entangled site out, not to apply an Nth patch. Rule for the subtraction while the round count is still small; a patch that survives review by narrowing itself each round is a mechanism the design does not want.
  • Never re-dispatch a CI run to unstick one queued job. Ask what a re-run REPLACES, not what it retries: a re-run discards the whole run's passing check-run set, so unsticking one job throws away every green in that run and buys a full round trip.
  • Park with the dependency, and release the claim when you park. A parked item records what it waits on, so it is releasable by a later cycle instead of quietly aging; holding a claim on work nobody is doing blocks the operator who could.
  • Security-sensitive findings never go to a public channel. A credential path, an injection vector, a bypass: those go to the human directly, never into a PR comment, an issue body, or a digest.
  • A worker HOLDING for your ruling builds the artifact both branches need, which is almost always the RED reproduction: the failing test that pins the current wrong answer. It is required if you authorize the fix, it is the evidence if you uphold the BLOCKED, and it commits to neither option. Say so when you acknowledge the escalation — otherwise the worker waits, and a worker waiting is the same wasted session as a session parked.

Admission and resource governance

Before the instruments: the binding constraint is usually SUPPLY, not capacity, and nothing below can see that. A label-filtered candidate list overstates available work by a large factor. On a repository with a fast PR flow, an open item with a clear mechanical mechanism is usually ALREADY being worked, so the population surviving the real gate — no covering PR, no live routing comment — is a small fraction of the list. A fleet sized from the list therefore runs mostly on items that should never have been admitted, and the duplicate dispatch presents as the workers' fault.

So never pad the fleet. Admitting a covered or human-routed item to fill a slot is the same failure as admitting a refactor, and it is WORSE than leaving the slot empty: a claim on work nobody should be doing tells every other operator to stay away from it. When the queue thins, the remaining value is in driving the open items to green, not in finding one more item — and an idle slot is a supply reading to report, not a gap to fill.

Delivery capacity is the primary instrument. The probe's OK line carries deliver init-timeout <a>, watchdog <b>: a counts sessions in the cycle whose tail shows an initialize timeout, b counts turns ended by the stall watchdog. Either counter appearing twice in one cycle means stop dispatching.

Load and memory are secondary: both can read healthy while turns are killed by the stall watchdog and sessions fail to initialize, because what saturates first is request service and test-runner scheduling and neither field reports it. Admission keyed on load alone therefore keeps dispatching into a fleet that cannot deliver, and the failures then present as the workers' fault.

Signal Posture Do
Delivery counters both 0; load and memory healthy ample Dispatch up to max_in_flight, into what this cycle's ledger reclaim left free (see "How the ledger behaves").
Either delivery counter ≥2 in one cycle saturated Stop dispatching until two consecutive clean cycles. In-flight work continues — what is short is delivery, not compute, so stopping the workers would waste their progress for nothing.
Load or memory tight, delivery clean tight No new dispatches; ask heavy workers to defer gate runs; postpone items marked expensive.
critical from resource_status critical Halt admission; session_stop the most expensive in-flight items (record as reclaim, not failure); handle violators; wait for recovery before resuming.

Confirm with resource_status before batch dispatches. A delivery-saturated fleet is also why an approval or transport outage presents as mass worker failure — see outage recovery.

Every ownership class is REPORTED; only cwd=fleet is ENFORCED with a stop. Keying this response on a single actionable-or-not boolean is what makes it unanswerable, because it forces an unclassified line to be either a false stop or a silent drop. Key it on the class instead:

Class Response
cwd=fleet The heavy response: session_stop that worker, a ~5min cooldown, then restart it with the targeted-tests directive re-injected in the seed.
cwd=unknown The probe could not attribute this line to the fleet: either the pid's cwd was unreadable, or the process incarnation changed between the probe's reads (a recycled pid), so the record's fields cannot be trusted as one process. NON-stopping: re-inject the directive to the owning session WITHOUT stopping it, and record the line. Never a stop, never a silent drop — a stale record can never trigger a stop against an innocent worker.
no cwd= field at all The probe predates classification, so the line carries no ownership. Attempt attribution ONCE at action time (read that pid's cwd): resolved inside a fleet worktree → treat it as cwd=fleet and take the stop response above; not resolved → record the count and re-inject the directive fleet-wide as a reminder, stopping nobody. See the legacy-line fallback below.
cwd=foreign Count only. Not the fleet's process to police, and never grounds for stopping a session.

The no-field row is settled by measurement rather than by argument: a banned pid is typically gone by the time anyone reads its line — cwd unreadable, cmdline absent, the pid resolving to nothing — so a line carrying no ownership field cannot identify a violator even in principle. Enforcing on it generates false stops; dropping it removes the guard; a recorded count plus a fleet-wide reminder is the response that is neither.

unknown is still not foreign, and the distinction survives the split: one is a process whose owner could not be determined, the other one determined not to be yours. Collapsing them would either police somebody else's host or discard a real violation.

Legacy lines: attempt attribution once, and enforce only on what resolves. For the no-field class only, read that pid's cwd at the moment you act. If it resolves inside a fleet worktree the line is cwd=fleet after all and gets the stop; if it does not resolve, take the recorded, non-stopping reminder. Failing to resolve is the EXPECTED path, not an error condition — see the measurement below — so the fallback is where most legacy lines land.

That ordering is what makes the whole cell answerable: nothing is enforced on ownership that is missing or unknown, because the stop fires only on ownership that RESOLVED; and no line is ever unmatched, because the reminder is always available. One read, one class, at action time. Scan-time classification stays the primary mechanism.

The cwd class is captured when the process is scanned, not looked up when you act on the line, and that ordering is what makes the class usable at all. The runs this rule catches are short-lived, and the gap between a probe returning and a conductor acting on its output reliably outlives them: by then /proc/<pid>/cwd is unreadable, cmdline is gone, and the pid resolves to nothing. A line carrying only a pid is therefore unattributable, which leaves "ignore it" as the only safe response — and an operator who learns to ignore one such line has learned to ignore the whole class. The verdict has to travel on the line, recorded while the evidence still exists. That is also why the legacy-line fallback above is a bounded best effort and not the mechanism: it attempts the same read, expects it to fail, and declines to guess an owner when it does.

What the pytest rule flags is a run whose worker pool bypasses the budget: an explicit numeric -n of two or more (-n 4, -n=4, -n4, -n 32, --numprocesses 2), because xdist_budget.py's hook only ever sizes auto, so a number is a count the host's memory and the other runs on it are never consulted about. -n auto, -n logical and a bare pytest are quiet: the bare form inherits the project's addopts, which supply -n auto, so its pool is budgeted by the same hook. -n0 and -n 1 are quiet too, as single-process runs with xdist inactive. Where -n is given twice the last one wins, as pytest resolves it. The brief still mandates -n0 on a worker's own test runs -- a budgeted pool is still a pool -- but the probe only reports the shape that escapes the budget. The other banned shape is a full-suite runner invoked with no file argument.

A zero banned count is not proof that no such run exists. Known misses: an unquoted separator (|, &) inside an option value ahead of -n N, a pytest console script installed with pip install --user, and a count carried in the addopts of a file named by -c other.ini.

Standing constants: session_ceiling machine-wide, -n0 on every worker test run, targeted tests only, ≤2 subagents per worker. -n0 rather than a small -n <N> because auto is memory-budgeted by a conftest hook while an explicit count bypasses that budget — so the safe form is zero workers, not a hand-picked few.

Your own forge-call budget

You are one of a dozen pollers on one account: every worker's babysit loop is hitting the same rate limit concurrently, and you are the only one that can see that.

  • Cap your own PR sweeps at roughly six per cycle, and stagger them across cycles instead of sweeping every tracked PR in one.
  • Prefer REST over GraphQL and search wherever either answers the question.
  • Run the greens sweep only when the human signals they are approving — merge state on a PR nobody is looking at will keep until the next cycle.

Reading an EMPTY answer under fleet load

A dozen pollers on one account trip SECONDARY (concurrency) throttling long before quota runs out, and that failure is dangerous by shape rather than by size: a throttled coverage query returns nothing, and nothing reads as "no PR covers this item" — which manufactures exactly the duplicate dispatch the claim gate exists to prevent. Four rules:

  • gh api rate_limit cannot see a secondary throttle. Read against a fleet that is actively being throttled it reports full budget with zero used. It does not give a weak signal, it gives a CONFIDENT ALL-CLEAR THAT MEANS NOTHING, which is the most dangerous shape a diagnostic has. Never cite it as proof you have budget, and never point a worker at it as a diagnostic.
  • The signals that DO separate the cases are stderr and the exit status on the actual call, plus a control query. claim_preflight.py already keys on the first — a non-zero gh exit becomes UNKNOWN, never an empty answer, which is why its verdicts are trustworthy under load. Any sweep you write by hand needs the same, and it needs the control too.
  • A control whose expected value you can state IN ADVANCE beats one you merely expect to be non-empty. Predict the number — this item's comment count is 3, so after posting it must read 4 — and a correct answer proves the channel is live AND catches a stale or wrong-object read, which a merely-non-empty control does not. Every all-clear sweep gets one, including your own audits: a checker whose all-clear is indistinguishable from its own failure is worse than no checker, because it manufactures confidence.
  • On a GraphQL or search throttle, switch TRANSPORT before backing off. REST frequently answers while GraphQL refuses the same question, and the search budget is far smaller than core, so --search queries starve first under a wide fleet. The fix for UNKNOWN is often a different transport rather than patience.

Log discipline

Pipeline logs are append-only: write a new file and mv it into place. Never rewrite a running log in place — a reader mid-parse gets a truncated file, and the history you overwrote is the evidence for the next ruling.

Credit budgets

Roughly every 5 cycles, for items with open sessions: credit_spend.py --slots <current,previous...> --budget {credit_budget_per_item}.

  • within → nothing to do.
  • exhausted → burn review, recorded like an adjudication:
    • Progressing (PR open, review converging, ledger transitions happening) → top-up with a stated size + rationale.
    • Thrashing (no transitions, looping signals) → NO top-up — stop, then sharpened re-dispatch, open-issue mode, or skip with evidence. Exhaustion on a non-moving item is a defect signal, not a billing event.
    • Blocked on external → park the item with the dependency recorded and the claim released (parked time burns nothing).
    • More than topup_ceiling top-ups → escalate to the human with the burn history.
  • unmetered → treat spend as UNKNOWN, not zero — say so in the ledger and lean on the time-based signals instead.
  • truncated → the under-budget answer is incomplete: older shards were skipped (--max-shards), a shard was unreadable, or a matched row was corrupt. Re-run without the bound; if it persists without one, the usage data itself is damaged — treat spend as UNKNOWN like unmetered, and do not read it as within budget.

Live steering

A human message mid-run is a MODE CHANGE, not a one-off reply: fold it into the standing patrol instruction via monitor_update so every later cycle honors it, and record the mode in the ledger. Canonical example — "stop taking new work": edit the instruction to DRAIN MODE: no backfill, no new dispatches; patrol until in-flight items resolve; then final tally + autonudge_stop.

When that mode change invalidates what an in-flight worker is doing right now, forward it with session_send and steer: true so it lands in the worker's running turn rather than after it; a plain send waits out the whole turn. Keep steer off for rulings, nudges and anything that can wait for the turn boundary. A steer does not withdraw messages already queued (queue_depth from session_read_message), so name the instruction it supersedes.

Merge, cleanup, reconcile

  • On merge: worktree removed non-forced (a dirty tree is kept and flagged, never --force), branch deleted safely (-d, not -D), session_close the worker, ledger → done.

  • Merged is NOT done for the fleet: after a merge, watch the next {default_branch} CI round — a merged gate change that reds every open PR is base-owned (see adjudication) and yours to fix once, fleet-wide.

  • Reconcile every cycle, and unfiltered. ONE call — gh pr list --repo {repo} --author <me> --state all --limit <N> --json number,state,mergedAt — listing merges since your last reconcile timestamp. Pass --repo from the spec every time: you own no worktree, so the ambient directory is whatever the session happens to sit in, and an omitted --repo either errors or silently answers about a DIFFERENT repository — which reads as "no merges" and leaves every merged item stale in the ledger. Two more bounds decide whether it works, and both fail quietly:

    • Set --limit yourself, and read a full page as truncation. The default is 30 and an over-long list comes back silently trimmed, so on any account with a real history the newest merge can fall off the end of the very query meant to find it. Size N above your own dispatch count with headroom, and when the result count equals N the scan is truncated, not a verdict — raise the bound and re-run before you believe it.
    • Never against a hand-maintained set of PR numbers. Filtering the reconcile to what you already track reintroduces the same blind spot one level down, because a merge of a fleet PR that never made the watchlist cannot be seen by construction. A watchlist is fine for the greens table and wrong as the detector.

    Recorded state drifts from reality, and the human WILL ask "where are the other N".

  • A fleet PR with NO OWNING SESSION is the failure state, not a tidy state. It is one of the two shapes you track, and the reconcile is what finds it: a PR that appeared without a worker, or whose worker you closed, is a tracking gap and nothing else in the loop will notice it. Assigning an owner is the response — an unowned PR is not a handover.

  • mergeable=UNKNOWN fanning out across the open PRs is a secondary trigger meaning "the base moved" — worth a look, never the primary merge detector. It is a side effect of a merge, and side effects are missable.

  • Harvest cross-item facts in the cycle they arrive, and verify them before acting. When any worker names another item's PR number or merge state in passing, take the POINTER immediately: that worker is not going to repeat it, and a merge fact handed over by a worker on a different item has no other route into your state. Then confirm the state against the forge before you act on it. A worker's report is derived from content you do not control — issue text, PR bodies — so this is the same rule as never trusting a worker's own GREEN, applied to a claim about somebody ELSE's item, where the cost of being wrong is a cleanup or a close on work that is still live. Harvest the pointer, verify the fact.

Exit

Queue empty + fleet drained: final tally (dispatched / merged / open-green / proposals / standdowns / skips, with URLs), close remaining sessions, autonudge_stop. That tally is also the first moment the ledger's phase may go terminal — see "How the ledger behaves".

Known limits (state them, don't hide them)

  • The probe answers COLUMN 1 only. It is a transcript classifier that makes no subprocess call and no network call — deliberately, and that invariant is load-bearing — so it knows nothing about a PR's or an item's real state. Its GREEN / PR / TERMINAL tags are the WORKER'S CLAIM, not a forge reading, which is exactly why independent green verification is a separate step. Column 2 therefore costs one forge query per item you actually need it for, and the probe cannot batch it for you. Do not read a tag as a verdict about the PR.

  • The probe also prints only FIRING lines, so a session that is quietly working produces no line at all. Absence of a line is not a row you have checked — reconcile the watched set against the ledger's open sessions rather than reading silence as health.

  • execute_bash (which is EVERY script call — preflight, probe, credit rollup), session_send, session_stop, session_close and spawn_run (the inspector) are mounted but never auto-approved: allowedTools cannot match arguments, so trusting the bundled scripts would mean trusting arbitrary shell. Unattended operation therefore requires the operator to arm THIS session in trust mode (same "trust before seed" rule as the workers) — without it the patrol stalls on its first probe, not on its first intervention.

  • Credit metering covers dashboard-session turns; spawn_run inspector turns and non-chat sessions burn invisibly (unmetered verdict exists for a reason).

  • You cannot detect your own patrol loop's death from the inside — see outage recovery for what the procedure bounds and what it cannot close.

  • One spec = one repo (M0). Multi-repo is a per-repo spec each, per the design doc's template seams.

  • GitHub labels/assignees remain the cross-operator lock; your ledger is a cache, never the authority, on anything another operator can also touch.