| name | write-code |
| description | Pick the next actionable issue off the configured target repo and execute it end to end — claim it by self-assigning, implement on a branch, open a PR that closes it, log progress on the issue, and hand off to the parent epic; OR, given a PR number, enter repair mode and consume a gate's latest FAIL verdict to fix-and-resubmit on the same branch. Trigger on "work the next issue", "pick up an issue", "implement issue |
write-code
You are the executor. The backlog has already been triaged (triage) and any epic
has been planned into children with a dependency topology (plan-epic). Your job is
to take the next actionable issue, claim it, build it, and open a PR that closes
it — leaving a trail (progress comments, an epic handoff note) so the next agent picks
up cold without spelunking your head.
You operate autonomously. You don't propose-first or wait for sign-off on the pick
— triage already decided this work is worth doing and plan-epic already sequenced it.
Pick, claim, implement, hand off. The one gate downstream is review-code, which
verifies your PR against the issue's acceptance criteria before it merges; your job is
to make that verification pass, not to merge on your own authority.
You are the implementer, never the reviewer of your own diff (the split-role firewall).
The whole point of the pipeline is split-role review — implementer ≠ reviewer — so the
self-evaluation bias of grading your own work never enters the merge decision. That guard
lives in who runs the gate, and it is structural, not advisory: write-code never
invokes review-code/review-doc/review-skill on the PR it just opened or repaired, and
never posts a review-(code|doc|skill): PASS/FAIL marker on its own output. The gate is
run by a separate reviewer agent; you hard-stop at PR-open (initial mode) and after
resubmit (repair mode) and leave the verdict to them. Re-reading your own diff to self-check
before you push is fine — what's forbidden is stepping into the gate role: running a
review skill on your PR, or emitting a verdict marker. Repair mode's loop is sound for the same
reason — you fix, an independent re-review re-gates; you never write the PASS (see
Why the author may fix its own FAIL'd PR).
This invariant is the skill's own rule, enforced here — it does not rely on a per-spawn
hand-off instruction (which agents demonstrably ignored, walking themselves into the gate on
their own PR — #664).
All GitHub ops via gh api REST — never GraphQL
Every issue/PR/label read and write goes through gh api — the org's legacy
Projects-classic integration errors out GraphQL issue queries, so this is a hard
constraint, not a style call (branch/commit/PR-open use git/gh per repo conventions).
Resolve the target repo once, up front (this skill is repo-agnostic — every call targets
$REPO); the full resolution rule is the shared contract's Target repo resolution
(../gh-issue-intake-formats.md, ADR 0062 §1), defaulting
to kamp-us/phoenix with no config.
REPO="${CLAUDE_PIPELINE_REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
The formats contract
You read three and write two of the shared formats; read the contract before you
start: ../gh-issue-intake-formats.md.
## Dependencies grammar (format 1) — you read it off a parent epic to derive
whether a sub-issue is eligible (phase predecessors closed + every requires: #N
closed). Blockedness is derived from this section, never a label.
- Sub-issue body (format 2) — you read it as your spec:
### What to build is
what to do, ### Acceptance criteria is the contract review-code will verify, the
**TDD:** flag is advice on whether to go test-first.
- Progress comment (format 3) — you write these on the issue you're working:
Completed / Decisions / Gotchas / Next.
- Epic handoff note (format 4) — you write one on the parent epic when you
finish a sub-issue: Done / Affects siblings / Watch out.
Read tolerantly (the formats are conventions, not parser specs — a synonym or a
slightly different bullet style still means what it means), write canonically.
The glossary — read .glossary/, use the canonical terms
Before you draft a PR title/body, a progress comment, an epic handoff, or any identifier
you introduce, read the repo-owned vocabulary register and reach for its names rather than
inventing your own (the one-concept-named-four-ways drift, #851; ADR 0099):
.glossary/TERMS.md
(domain nouns) and .glossary/LANGUAGE.md
(architecture vocabulary) — the single source; never copy a definition into this skill.
Invocation — two modes, disambiguated by what you're given
write-code has two invocation shapes, and the argument tells them apart:
- A PR number → repair mode. "Repair PR #N" / "fix the failed review on #N" hands you
an existing PR. Go to Repair mode:
resolve the PR's latest gate verdict, and only if that latest verdict is FAIL, read
the findings, fix them on the existing branch, push so the stateless gate re-runs, and
stop. You do not pick new work, you do not branch, you do not merge.
- An issue number, or no argument → initial-build mode. "Implement #N" / "work the
next issue" runs the normal pick → claim → Steps 4–7 path below. This is unchanged.
The two are unambiguous: a PR number routes to repair, an issue number (or nothing) routes
to the pick-and-build path. If you're handed a bare number and genuinely can't tell which
it is, resolve it once — gh api repos/$REPO/pulls/<N> succeeds for a PR and
404s for a plain issue — and branch accordingly.
The ownership boundary, stated once and load-bearing throughout: write-code owns
fail → fix → re-request; ship-it owns PASS → merge. You own the branch and the PR, so
driving a FAIL'd PR back through the gate is your loop — but the merge is never yours, in
either mode (this mirrors the gh-issue-intake-formats.md §5/§6/§6.5 relationship table, which
names write-code the consumer of all three FAIL markers and ship-it the consumer of all
three PASS markers).
A rebase invalidates the PASS — rebase → re-review → ship is atomic
Whenever a PR head moves after it was reviewed — most often a rebase to catch up to
main, but any force-push — the prior review-code/review-doc/review-skill PASS is bound
to the old head and is staleness-invalidated (ADR
0058): the verdict attests the exact
tree it reviewed, and the rebased head is, in principle, un-reviewed. ship-it will then
correctly refuse with unverified (verdict not bound to current head) (its Step 2b). That
refusal is the system working, not a stall — never weaken the SHA-binding to route around
it, and never wait on a human for it.
So a rebase is never the last step before ship. Rebase → re-review → ship is one atomic
sequence: after you rebase (or force-push) a PR, the new head needs a fresh review against
that head before ship-it can act — re-run the matching gate (review-code for code,
review-doc for docs, review-skill for skills) against the new head, and only once its latest
verdict is a current-head PASS hand off to ship-it. Never ship on a pre-rebase PASS — the rebase invalidated it the
moment it landed, so "ship on the existing PASS after a rebase" is self-contradictory.
The pattern that never hits this: review the exact head you ship. A flow that reviews
and ships in one pass over a single head never orphans its verdict; the split
review-then-rebase-then-ship flow is the only one that does, and the fresh re-review is what
re-binds the verdict to the head being merged (#310).
Step 1 — Pick the next issue
The pick rule is deterministic. Among open issues that are status:triaged and
unassigned:
- Highest priority bucket first: all
p0 before any p1, all p1 before any
p2.
- Milestone tiebreaker within a bucket: among equal-priority candidates, prefer
one in the active milestone — never across buckets.
- Oldest first otherwise: lowest issue number / earliest
created_at.
The priority spine is sovereign — p0 outranks any milestone lean. Milestone only
reorders within a single priority bucket; it never reaches across one. A p0 outside the
active milestone is therefore always picked before a lower-priority issue inside it — the
campaign bias is a tiebreaker, not a new top-level sort key. See
Milestone-aware ordering for the full rule, including the
explicit work milestone N mode and why p0-sovereignty holds in both modes.
Assigned issues are someone else's claim — skip them. Skip on any non-null assignee,
not on an exact match: under the Step 3 claim race an issue may transiently show two
co-assignees for the window before the winner evicts the loser, and skipping any assigned
issue keeps that transient state safe — a half-resolved claim is never double-picked, it's
simply passed over until it settles to its single winner. status:needs-triage,
status:needs-info, and closed issues are not pickable (they haven't cleared triage).
Pre-pick exception — resume your own failed PR first
The "skip assigned issues" rule has exactly one exception: a PR you opened that came
back FAIL. Its Fixes #N issue is still assigned to you (review-code/review-doc/review-skill
leave it open and assigned on a FAIL), which would make it unpickable by the rule above — but
that arc is yours to drive forward, not skip. So before picking new status:triaged
work, scan your own open PRs for one whose latest gate verdict (in any of the three
namespaces) is an unaddressed FAIL:
ME=$(gh api user --jq '.login')
VERDICT="${CLAUDE_PLUGIN_ROOT:-claude-plugins/kampus-pipeline}/bin/pipeline-cli verdict"
gh api "repos/$REPO/pulls?state=open&per_page=100" \
--jq ".[] | select(.user.login==\"$ME\") | .number" | while read PR; do
comments_file=$(mktemp)
gh api "repos/$REPO/issues/$PR/comments?per_page=100" > "$comments_file"
markerAuthors=$(jq -r '[.[]
| select(.body | test("^\\s*\\**\\s*review-(code|doc|skill):\\s*(PASS|FAIL)"; "i"))
| .user.login] | unique | .[]' "$comments_file")
authorized='[]'
while IFS= read -r a; do
[ -z "$a" ] && continue
perm=$(gh api "repos/$REPO/collaborators/$a/permission" --jq .permission 2>/dev/null)
case "$perm" in
admin|maintain|write) authorized=$(jq -c --arg a "$a" '. + [$a]' <<<"$authorized") ;;
esac
done <<<"$markerAuthors"
ROUNDS=$(jq --argjson authorized "$authorized" \
'[.[] | select(.user.login | IN($authorized[]))
| select(.body | test("^\\s*\\**\\s*review-(code|doc|skill):\\s*FAIL"; "i"))
| .created_at | sub("\\..*Z$";"Z") | fromdateiso8601]
| sort
| reduce .[] as $t ({n:0, prev:null};
if (.prev == null) or ($t - .prev) > 120
then {n:(.n+1), prev:$t} else {n:.n, prev:$t} end)
| .n' "$comments_file")
[ "$ROUNDS" -ge 3 ] && continue
$VERDICT read --pr "$PR" --gate code --expect FAIL >/dev/null 2>&1 && echo "#$PR review-code FAIL"
$VERDICT read --pr "$PR" --gate doc --expect FAIL >/dev/null 2>&1 && echo "#$PR review-doc FAIL"
$VERDICT read --pr "$PR" --gate skill --expect FAIL >/dev/null 2>&1 && echo "#$PR review-skill FAIL"
done
If such a PR exists, repair it instead of picking new work — go to
Repair mode with that PR
number. Only once you have no PR with an unaddressed latest FAIL do you fall through to
the normal pick below. (This scan resolves each (PR, gate) verdict through the same
authoritative SHA-bound pipeline-cli verdict read repair mode Step R1 uses; R1 still
re-resolves at repair time because the head may move between this scan and the repair — a PR
that flipped to PASS, or whose FAIL went stale in that window, is then a clean no-op.)
Two properties make this scan terminate rather than starve:
- Author-gated verdicts (ADR 0055).
Markers count as a verdict only from a
write+ repo collaborator — the same GitHub-ACL
gate ship-it Step 2 applies before the marker regex. A self-authored or
forged review-(code|doc|skill): FAIL is invisible here, so write-code can't pull itself into
spurious repair (and a forged PASS can't mask a real FAIL).
- Cap exclusion. A PR already at the N=3 cap is skipped (
ROUNDS >= 3 → continue):
escalation hands it to a human but leaves its latest verdict at FAIL, so without this skip
the scan would re-match it forever — re-enter repair, recount 3 FAILs, re-escalate — and
never advance to fresh work. Excluding capped PRs lets the picker step over the escalated
PR and pick new status:triaged work.
List the candidate pool, priority bucket by priority bucket, stopping at the first
bucket that has any unassigned candidate:
for P in p0 p1 p2; do
gh api "repos/$REPO/issues?state=open&labels=status:triaged,$P&sort=created&direction=asc&per_page=100" \
--jq '.[] | select(.assignee == null and (.pull_request | not)) | "#\(.number)\t\(.created_at)\t\(.title)"'
done
(.pull_request | not) filters out PRs (the issues endpoint returns both). Take the
first unassigned issue in the highest non-empty bucket — applying the
milestone tiebreaker below when several issues share that
bucket. That's your pick — unless it's a sub-issue, in which case run the eligibility
check in Step 2 first.
Milestone-aware ordering
Milestone is the optional fourth intake dimension — strategic sequencing / campaign
grouping, not feature breakdown — defined once in the formats contract's
## Milestone section (the single source of truth;
read it for what a milestone is and its REST surface — ADR
0072).
write-code is the consumer named there: milestone influences pick-order only, and
only as a tiebreaker that respects the priority spine — it never gates, never blocks a
merge, and never changes which issues are pickable.
The precedence rule (p0 stays sovereign — state this, never weaken it). A milestone
preference orders candidates strictly within a single priority bucket and never
across buckets. The priority spine — all p0 before any p1, all p1 before any p2 — is the
top-level sort and is never overridden by a campaign lean. Concretely: a p0 outside the
active milestone is always picked before any lower-priority issue inside it. A milestone
bias that could starve an out-of-milestone p0 is a bug, not a feature; the within-bucket
confinement is what makes the campaign lean safe. This precedence holds in both modes
below — never reintroduce a milestone-over-priority sort.
Milestone shapes the pick in two modes:
-
Default mode — within-bucket tiebreaker. With no milestone named, run the normal
priority-then-age pick, but when a single priority bucket holds several unassigned
candidates, break the tie toward the active milestone: prefer the in-milestone
candidate over an equal-priority out-of-milestone one; fall back to oldest-first when
the milestone dimension doesn't separate them (both in, both out, or no active
milestone). The "active milestone" is resolved from a single durable surface, not a
per-run guess: it is the arc pinned by the one active row of
ROADMAP.md's ## Arcs table
(the founder-voice roadmap projects each arc onto a GitHub milestone; exactly one arc is
active at a time — ADR
0078).
An operator's explicit work milestone N still overrides it (the mode below). When no
## Arcs row is marked active, there is no active milestone and this degrades cleanly
to plain oldest-first. Because the tiebreaker lives inside a bucket, it can only reorder
equal-priority issues — it can never pull a lower-priority in-milestone issue ahead of a
higher-priority one.
-
Explicit work milestone N mode — drain that milestone. When invoked as "work
milestone N" (or "drain milestone N"), scope the pool to that milestone via the REST
filter and pick from it by the same priority-then-age order:
for P in p0 p1 p2; do
gh api "repos/$REPO/issues?state=open&milestone=$N&labels=status:triaged,$P&sort=created&direction=asc&per_page=100" \
--jq '.[] | select(.assignee == null and (.pull_request | not)) | "#\(.number)\t\(.created_at)\t\(.title)"'
done
Even here the priority spine wins inside the milestone (p0s in the milestone before
its p1s), and the explicit scope is the operator's choice — naming milestone N is a
deliberate decision to work that campaign, so confining the pool to it is intentional,
not starvation. If you must guarantee no global p0 is left behind while draining a
campaign, run the default unscoped pick first; the explicit mode is for when the
operator has chosen to focus N.
In both modes the pickability predicate is unchanged — milestone only orders among
issues that are already pickable (status:triaged + unassigned, sub-issue eligibility per
Step 2). Read an issue's milestone with
gh api repos/$REPO/issues/<N> --jq '.milestone.number // "none"' (none ⇒ the well-formed
default — most issues carry no milestone) per the contract's REST surface.
Is it a sub-issue?
An issue may be a child of an epic. Check before claiming — a sub-issue carries
dependency constraints the bare issue doesn't show.
Resolve the parent through GET /repos/{owner}/{repo}/issues/<N>/parent, the dedicated
sub-endpoint ADR
0131
§3 already names the authoritative linkage — and read three outcomes off it, never two:
PARENT_RESP="$(gh api "repos/$REPO/issues/<N>/parent" -i 2>/dev/null)"
PARENT_STATUS="$(printf '%s\n' "$PARENT_RESP" | head -n1 | awk '{print $2}')"
case "$PARENT_STATUS" in
200)
EPIC="$(printf '%s\n' "$PARENT_RESP" | awk 'body{print} /^\r?$/{body=1}' | jq -r '.number // empty')"
: "${EPIC:?parent read returned 200 with no .number — UNKNOWN, not standalone; refusing to claim <N>}"
echo "#<N> is a sub-issue of epic #$EPIC → Step 2 (derive eligibility BEFORE claiming)" ;;
404)
echo "#<N> is standalone (404 'No parent issue found') → Step 3 (claim it)" ;;
*)
echo "write-code FAILED (fail-closed): parent read on #<N> returned HTTP '${PARENT_STATUS:-none}' — UNKNOWN, which is NOT evidence of 'no parent'. Refusing to claim: retry, or re-pick per Step 1." >&2
exit 1 ;;
esac
- Parent resolved (200) → go to Step 2 and derive eligibility before claiming.
- Standalone (404) → skip to Step 3.
- Anything else → stop, loudly. An unreadable parent read is unknown, not absent;
proceeding would claim a child whose dependencies were never checked.
Never read .parent off the plain issue payload. The single-issue REST response carries
no parent key at all (the linkage surfaces there as parent_issue_url), so the read this
step used to carry — a --jq on .parent with a "no parent (standalone)" jq-alternative
default — fired its default unconditionally and answered "standalone" for every issue: a
well-formed, plausible, always-wrong answer that skipped the whole Step 2 derivation on every
epic child and left no trace (#4171). ADR 0131 §3 states the same thing from the other side:
the child's own .parent field is unreliable, the sub-endpoint is the source of truth.
Step 2 — Sub-issue eligibility (derive blockedness, never read a label)
For a sub-issue, read the parent epic first — its plan, its ## Dependencies
topology, and its progress (the handoff-note comment stream). A child is only pickable
when its dependencies are all closed. There is no status:blocked label;
eligibility is computed fresh on every pick from the epic's ## Dependencies section.
EPIC=<the parent number Step 1's sub-endpoint read resolved — never a guessed or remembered one>
# the epic body carries the plan + the ## Dependencies topology
gh api repos/$REPO/issues/$EPIC --jq '.body'
# the real child set + each child's state (the list endpoint is source of truth;
gh api "repos/$REPO/issues/$EPIC/sub_issues?per_page=100" \
--jq '.[] | "#\(.number) [\(.state)] \(.title)"'
gh api "repos/$REPO/issues/$EPIC/comments?per_page=100" --jq '.[].body'
The derivation rule (from the formats ## Dependencies grammar):
A child #C is unblocked iff:
- Phase predecessors closed: every issue in every phase before
#C's phase is
closed. (Phases are the sequential spine — Phase 2 can't start until all of Phase 1
is closed.) A child within a phase has no ordering against its phase-siblings.
requires: closed: every issue named in #C's requires: #N, #M … annotation
is closed. This is the cross-boundary gate for a dependency that doesn't fall on a
phase line.
Both conditions must hold. If either fails — a phase predecessor is open, or a
requires: target is open — the child is blocked: skip it and fall back to the
next pickable issue (the next unassigned status:triaged issue in priority/age
order, re-running Step 1 with this child excluded). Do not apply a label, do
not comment "blocked" — blockedness is a derived, transient fact, not a stored
state. The child becomes pickable the moment its blocker closes; on the next pick the
recomputation will let it through.
Worked: epic with ### Phase 1: #210, #211 and ### Phase 2: #212 (requires: #210).
If you're eyeing #212: it needs #210 closed (its requires:). It does not
need #211 — #211 is a phase-1 sibling but #212 only gated on #210. Note the
subtlety: #212 is in Phase 2, so the phase-boundary rule would also gate it on
all of Phase 1 — but a requires: that names a strict subset is the planner saying
"this specific edge is what matters." When a requires: is present, honor it as the
precise gate; when it's absent, fall back to the phase-boundary default. If a child
in Phase 2 has no requires:, it waits on all of Phase 1.
Step 3 — Claim by writing your session-id claim marker (the agent-distinguishable claim, ADR 0115)
Claiming backs Step 1's "skip assigned issues" rule so other write-code agents step over a
claimed issue. But the bare GitHub assignee cannot be the claim: it is last-write-wins,
additive, not compare-and-swap (two agents that both saw #N unassigned co-assign [A, B],
#260) — and worse, every draining agent here pushes as the single git identity usirin,
so the old min(login) tiebreak degenerated to a no-op (both co-racers compute
min == usirin == me and both implement #N; the #1431 double-implement). The fix is the
agent-distinguishable claim marker (ADR
0115, #1452):
a claim comment stamped with your CLAUDE_CODE_SESSION_ID, the per-agent UUID the
runtime exposes that the shared login cannot provide.
Single-source the primitive — do not re-derive it. The canonical claim-comment grammar,
the CLAIM_RE matcher, the write+ ACL trust root (ADR 0055), and the earliest-authorized-claim
tiebreak are defined once in ../gh-issue-intake-formats.md §7
(the agent-distinguishable claim marker). This step is the issue-claim consumer of that
primitive — it writes the claim and resolves ownership by §7's rules; it never invents a second
grammar or a second CLAIM_RE. The marker is the one line:
claim: <CLAUDE_CODE_SESSION_ID> · <ISO-8601-UTC>
The claim is two layers (§7): the assignee stays as the coarse, login-blind
availability gate the Step-1 picker reads (skip on any non-null assignee), and the
session-id claim comment is the fine, agent-distinguishable resolver. You self-assign
and post the claim comment; the comment, not the assignee, decides who owns the work.
Delegated claim — the orchestrated path (recognize, don't re-race)
If the orchestrator (.claude/workflows/drive-issue.js) claimed pre-spawn and threaded a
claim token into your prompt ("your delegated claim token is <token>"), the claim is
already yours — the orchestrator posted it on your behalf (ADR 0115 §3, Delegated
ownership). Do not post a second, redundant claim and do not re-race. Confirm the
delegation by resolving §7's tiebreak once — the earliest authorized claim's embedded
session id must equal the threaded token — then proceed straight to implementing:
[ "$WINSID" = "$DELEGATED_TOKEN" ] || { echo "delegated token is not the earliest authorized claim — abort, do not implement." >&2; exit 1; }
Direct path — claim it yourself (no orchestrator)
When write-code is invoked directly (no threaded token), make the claim here, before you
branch or build, using your own CLAUDE_CODE_SESSION_ID as the token:
The whole claim-comment write — Rule-0 defer, the POST, the checkpoint-GET tiebreak, and
retract-on-loss — is owned by one verb, pipeline-cli tracker claim (§7's write surface).
Run it; never hand-roll a claim: body, which silently skips the ADR-0191 presence stamp and
leaves this lane's claim permanently unprobeable (#3987):
PCLI="${CLAUDE_PLUGIN_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)/claude-plugins/kampus-pipeline}/bin/pipeline-cli"
if [ -z "$CLAUDE_CODE_SESSION_ID" ]; then
echo "no CLAUDE_CODE_SESSION_ID in env — cannot post an agent-distinguishable claim. BACK OFF, re-pick." >&2
exit 0
fi
if ! "$PCLI" tracker claim <N>; then
echo "did not win the claim on #$N (held by another agent, or lost the tiebreak) — back off, re-pick."
exit 0
fi
ME=$(gh api user --jq '.login')
gh api -X POST repos/$REPO/issues/<N>/assignees -f "assignees[]=$ME" >/dev/null
The operating rule. Posting the claim comment detects a race; the checkpoint GET
(re-reading the issue's comments and resolving the earliest authorized claim per §7) resolves
it — the verb does both, and neither leg is prunable as "redundant" (without the checkpoint both
staggered co-racers proceed, a double-pick). The winner is the earliest authorized claim (min
created_at, then min comment id) whose claimant is not provably dead, recognized because its
embedded session id equals your CLAUDE_CODE_SESSION_ID; because earliest-claim-wins, Rule 0
(defer to a pre-existing owner) and the tiebreak are the same fact. Every loser retracts its
own claim comment (the verb's own last act) and, having claimed before assigning, has no
assignment to undo — it just re-picks. Never co-occupy, and never delete another agent's claim,
never unassign a slot you did not fill, and never fall back to the login-keyed assignee as
ownership. This is detect-and-tiebreak, not a lock; the residual transient window — a claim
already posted while the assignee is not yet set — is tolerated, because an agent that picks the
still-unassigned issue in that window runs the verb and defers to the earlier claim.
See ../gh-issue-intake-formats.md §7 for the canonical
CLAIM_RE, the full write+-ACL resolution snippet, the staggered-co-racer / straggler / transient
derivation, and the pre-spawn / delegated-ownership protocol — this step implements that contract,
it does not re-derive it.
Now route by type before implementing — a type:decision or type:investigation
issue is not a "write code and open a PR" issue. See Type routing
and branch there if the issue carries one of those types. Everything else
(type:feature, type:chore, type:bug) is the implement-and-PR path below.
Step 3.5 — The mis-attribution guard (verify the target is mine before mutating it)
The claim (Step 3) guarantees you won a unit of work; this guard guarantees you only ever
operate on a number you won. They are complements: without it, a mis-attributed issue/PR
number — a wrong <N> substituted into a gh api … /comments, a git push to the wrong
branch, a close on the wrong issue — silently mutates another agent's live work. That is
the #1404 near-miss this step closes (a coder nearly killed another agent's live PR via a
mis-attributed number); it is the write-code adoption of ADR
0115
§4 (surface #1456).
Before any mutating action that targets an issue/PR number — open a PR against it (Step 5),
comment on it (Step 6 progress, Step 7 handoff), git switch/git push its branch (repair
R2/R3), or close it (type routing) — verify the target carries your own session-stamped
claim, and refuse — loudly, fail-closed — if that claim is absent or another agent's. The
guard reuses the §1 claim-marker read (ADR 0115 §1/§2); it introduces no second claim
mechanism.
Your claim token — MY_CLAIM (own session id, or the orchestrator's delegated token)
The token this run owns its work under is MY_CLAIM, resolved once at the top of the run:
- Direct path —
write-code invoked directly: MY_CLAIM is the coder's own
CLAUDE_CODE_SESSION_ID (the same token the Step-3 claim writes — ADR 0115 §3 direct path).
- Orchestrated path — dispatched by the orchestrator, which pre-claims before spawn
(
.claude/workflows/drive-issue.js, ADR 0115 §3) and threads its winning claim token into
the spawn prompt: MY_CLAIM is that threaded delegated token. write-code recognizes
the threaded claim as its delegated own and does not re-race (it posts no second claim) — the
guard treats a target whose earliest authorized claim equals the threaded token as mine.
MY_CLAIM="${THREADED_CLAIM_TOKEN:-$CLAUDE_CODE_SESSION_ID}"
: "${MY_CLAIM:?mis-attribution guard: no claim token — CLAUDE_CODE_SESSION_ID absent and none threaded; refusing to claim or mutate (ADR 0115 Consequences — never a login fallback)}"
claim_is_mine <N> — MANDATED before every issue/PR-number mutation
Define the guard once and gate every number-targeting mutation on it, exactly as
wt_preflight gates every git mutation — a green claim_is_mine
is the only sanctioned path to a mutation that names <N>, no bypass:
PCLI="${CLAUDE_PLUGIN_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null)/claude-plugins/kampus-pipeline}/bin/pipeline-cli"
claim_is_mine() {
"$PCLI" claim is-mine --issue "$1" --session "$MY_CLAIM"
}
claim_is_mine "<N>" && gh api repos/$REPO/issues/<N>/comments -f body="…"
The guard is fail-closed by construction — the verb proceeds (exit 0) only on positive
evidence of an authorized claim whose session is MY_CLAIM; an absent claim, an unauthorized-only
claim, and a foreign claim all resolve not-mine and refuse (exit non-zero). It is observable
(the verb prints the resolved owner vs MY_CLAIM, the outcome reason, and any superseded
dead-claimant claims) and idempotent (a read-only GET). Never route around it by widening
the comparison or treating the bare assignee as the owner — the assignee is a coarse availability
gate only (ADR 0115 §1), and a login-keyed ownership check is the degeneracy this guard exists to
remove. A refusal is also never overridable by reasoning: an explicit-looking dispatch brief,
or independently re-deriving that the PR/branch/issue/verdict all line up, establishes that the
dispatch was coherent — not who owns the lane. Proceeding on that corroboration is what made
the guard advisory in the field (#3751); the only sanctioned response to a refusal is to stop and
surface it to whoever dispatched you.
Ownership resolves against the earliest live authorized claim (ADR 0191 supersession). The
verb drops a claim whose claimant is provably dead — its marker's presence stamp names a
session process this host can probe, and the process is gone — so a dead session's older marker no
longer shadows a legitimate later claim on an abandoned lane (the orchestrated-repair case, #3751).
Supersession needs positive evidence of death: an unstamped/legacy marker, a claim stamped on
another host, and an unprobeable pid all stay indeterminate, still count as owners, and still
refuse. So the guard's fail-closed direction is unchanged — what changed is that the legitimate
case can now resolve, instead of being permanently unprovable.
Which <N> to guard at each site. The guarded number is the work-target whose mutation
could clobber another agent: Step 5 guards the issue you open the PR against; Step 6 the
issue you comment on; Step 7 guards the child you own (the handoff to the parent epic is
predicated on owning the child — gate on claim_is_mine <child>, not the epic, which you never
claim); repair R2/R3 guard the PR's linked issue #N (the claim lives on the issue, so
resolving Fixes #N and confirming its claim is MY_CLAIM is what proves the PR is yours to
push); the repair escalation sites — both the N=3 cap block and the freeze-after-round-K
block — guard the PR's linked issue #N too (the escalation comment + status:needs-triage
relabel are number-targeting mutations reachable as a fresh stateless repair's first mutation,
escalating instead of running R2/R3, so the R2/R3 guards never fire — gate the escalation itself);
type-routing closes guard the issue you close.
Composition + ship-ordering (the one honest caveat). This guard is the read-side
complement of the claim write: it verifies the marker that the claim surface posts — the
Step-3 issue self-claim and the §7 contract (surface #1453), or the orchestrator's pre-spawn
claim threaded as MY_CLAIM (surface #1454). It is live-correct only once a claim marker is
posted ahead of the mutation — which the integrated pipeline always does (orchestrator
pre-claims, or write-code self-claims at its claim step). Landing this guard ahead of a
claim-marker-posting surface would, by its own fail-closed contract, refuse write-code's own
mutations (no marker yet to verify) — so its ship is sequenced with or after #1453/#1454, a
control-plane ship decision, not a fail-open hedge to weaken here.
Rehearsal — a mis-attributed number is refused (the #1404 reproduction)
The guard handed a number it did not claim refuses to mutate it. Walk the three resolutions:
- Foreign claim — REFUSE. Agent B (its own session
B-sid) holds the earliest authorized
claim on issue #900. Agent A (MY_CLAIM = A-sid) is handed #900 by a mis-attributed number
and reaches a mutation. claim_is_mine 900 resolves winner = B-sid; B-sid != A-sid ⇒
FAILED (fail-closed): #900 is claimed by ANOTHER agent — A pushes/closes nothing. This is
the #1404 near-miss, now structurally unreachable.
- Absent claim — REFUSE. The number names an issue with no authorized claim marker (or
only a forged claim from a non-collaborator, which the ADR 0055 author-gate drops).
winner is
empty ⇒ FAILED (fail-closed): no authorized claim marker — A cannot prove ownership, so it
refuses rather than mutate an unclaimed number.
- My own claim — PROCEED. The earliest authorized claim on
#N carries A-sid (A's own
Step-3 self-claim) or the token threaded to A by the orchestrator (delegated own). winner == MY_CLAIM ⇒ the guard prints earliest authorized claim == mine and the single guarded
mutation runs.
Exactly one of three outcomes, decided by a re-read of canonical issue state against MY_CLAIM —
the same detect-and-tiebreak shape (ADR 0115 §2), here read-only and on the acting side.
Step 4 — Implement on a branch
write-code MUST run in an isolated git worktree — when spawned as a subagent, via
the Agent tool's isolation: worktree. The operator loop requires it so concurrent
runs can't race or dirty the primary checkout.
Step 4 preflight — assert you're in a worktree, fail closed if not (ADR 0092)
Run this before you branch or touch a single file. "write-code runs in a worktree"
was a documented invariant nobody asserted — so a misconfigured spawn (no
isolation: worktree, or a harness cwd-reset that drops you back in the primary checkout
between calls) would sail past it and branch the owner's primary checkout, the exact
mis-branch the MEMORY notes burn on. This is the silent-no-op failure mode at the agent
layer, so it gets the same fix the gates get: emit what you scanned, then FAIL CLOSED on
the unsafe state (ADR
0092).
The check is a one-liner of plumbing, portable across git ≥ 2.5: a linked worktree's
per-tree git dir (.git/worktrees/<id>) is not the shared common dir (<primary>/.git),
whereas in the primary checkout they are the same path. Equal ⇒ you're in the primary
checkout (or a bare/no-repo edge) ⇒ stop; differ ⇒ you're in a linked worktree ⇒ proceed.
GITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || {
echo "write-code preflight FAILED: not inside a git repository — refusing to mutate." >&2; exit 1; }
COMMON="$(git rev-parse --git-common-dir 2>/dev/null)"
case "$COMMON" in /*) ;; *) COMMON="$(pwd)/$COMMON" ;; esac
COMMON="$(cd "$COMMON" && pwd)"
ISOLATION_EXPECTED=0
case "$CLAUDE_CODE_AGENT" in *coder*|*reviewer*|*shipper*) ISOLATION_EXPECTED=1 ;; esac
[ -n "$WORKTREE_ROOT" ] && ISOLATION_EXPECTED=1
[ -n "$CLAUDE_CODE_AGENT" ] && [ "$GITDIR" = "$COMMON" ] && ISOLATION_EXPECTED=1
echo "write-code preflight: git-dir=$GITDIR common-dir=$COMMON cwd=$(pwd) isolation-expected=$ISOLATION_EXPECTED (agent=${CLAUDE_CODE_AGENT:-unset} worktree-root=${WORKTREE_ROOT:+set})"
if [ "$GITDIR" = "$COMMON" ]; then
if [ "$ISOLATION_EXPECTED" = 1 ]; then
echo "write-code preflight FAILED (fail-closed, LOUD): worktree isolation was EXPECTED (agent=${CLAUDE_CODE_AGENT:-?}, worktree-root=${WORKTREE_ROOT:+set}) but this run is on the PRIMARY checkout (git-dir == common-dir) and \$WORKTREE_ROOT is unset." >&2
echo " ROOT CAUSE: the harness's worktree provisioning (git worktree add + \$WORKTREE_ROOT injection) did NOT run for this coder spawn — the #2440 harness no-op. The repo-side worktree-guard also keys on \$WORKTREE_ROOT, so it is disarmed too; only this preflight is left." >&2
echo " REFUSING to self-provision — that would hide the harness failure and leave the two-layer defense collapsed to one, invisibly (the primary-checkout-corruption class, #2270)." >&2
echo " ROUTED BLOCKER — surface UP to the operator/EM: 'harness worktree provisioning no-op'd for a coder spawn (isolation expected, \$WORKTREE_ROOT unset); the out-of-repo harness half (#2440) needs attention. Do NOT blindly retry the same spawn.'" >&2
exit 1
fi
echo "write-code preflight FAILED (fail-closed): git-dir == common-dir ⇒ this is the PRIMARY checkout, not an isolated worktree." >&2
echo " Refusing to branch/commit here — a spawn without isolation:worktree (or a cwd reset to the primary tree) would mis-branch the owner's checkout." >&2
echo " This run did NOT expect isolation (standalone) — take the Non-isolated fallback below to create a worktree before mutating." >&2
exit 1
else
WT="$(git rev-parse --show-toplevel)"
echo "write-code preflight CONFIRMED (LOUD): in a LINKED worktree at $WT (git-dir != common-dir) — worktree identity established from git plumbing, INDEPENDENT of \$WORKTREE_ROOT (${WORKTREE_ROOT:+set}${WORKTREE_ROOT:-unset}) and \$CLAUDE_CODE_AGENT (${CLAUDE_CODE_AGENT:-unset}), both of which may misreport. Anchor EVERY Edit/Write and git op to this \$WT (absolute) — never a primary-checkout path."
fi
The preflight is fail-closed by construction: it refuses on the primary checkout, on a
not-a-repo cwd, and on the ambiguous default — only positive evidence of a linked worktree
(git-dir ≠ common-dir) lets it through. It is observable (it prints the two dirs + cwd +
isolation-expected it compared, so "what did the preflight look at" is answerable from the run
log) and idempotent (read-only git rev-parse, safe to re-run). Never route around the
preflight by deleting it or relaxing the comparison; a green preflight is the precondition every
mutation in Steps 4–7 relies on.
The primary-checkout refusal forks on whether isolation was expected (ADR 0172, #2443). The
git-dir == common-dir refusal is a hard stop in both directions, but how you're meant to
recover differs, and conflating the two is what let a harness failure hide:
- Isolation was EXPECTED (
isolation-expected=1 — the run is under the coder agent-type, or
$WORKTREE_ROOT was set) yet you're on the primary checkout ⇒ fail closed LOUD and STOP.
This is the #2440 harness no-op: the harness
was supposed to provision a linked worktree and inject $WORKTREE_ROOT but silently didn't, which
also disarms the whole $WORKTREE_ROOT-keyed repo-side worktree-guard — so this preflight is
the only surviving layer. Do NOT self-provision to route around it. Self-provisioning here
would paper over the harness failure and leave the two-layer primary-corruption defense collapsed
to one, invisibly (the #2270
primary-checkout-corruption class). Instead surface the printed ROUTED BLOCKER up to the
operator/EM so the out-of-repo harness half gets fixed rather than silently absorbed.
- Isolation was NOT expected (
isolation-expected=0 — a genuine standalone write-code, e.g.
a human running /write-code directly) ⇒ take the Non-isolated fallback
below, which creates a real linked worktree and cds into it, after which this same check passes.
This is the only path where self-provisioning is sanctioned; the loud branch above never fires
for it, so the standalone flow is unchanged.
The isolation-expected signal is machine-checkable and grounded in the coder agent-type's
unconditional-isolation assertion (../../agents/coder.md), read from the
harness-set $CLAUDE_CODE_AGENT env (stable across an agent's separate Bash calls) plus a set
$WORKTREE_ROOT — never a per-run guess. It fails safe: if the agent-type value ever differs from
what's matched, the run degrades to isolation-expected=0 (today's silent self-provision), never to a
dangerous primary-checkout mutation — the loud branch only adds a stop, it never removes the
existing refusal.
The success path asserts worktree identity LOUDLY, on env-independent evidence (#3458). When
git-dir != common-dir the preflight no longer proceeds silently: it captures $WT from that
positive git-plumbing evidence and prints a CONFIRMED line naming the worktree root. This is the
producer/preflight half of the worktree-misID class — a worktree-isolated spawn is handed a
misleading process env ($WORKTREE_ROOT unset because the #2938 provisioning hook was reverted;
$CLAUDE_CODE_AGENT carrying the parent's agent-type via inheritance, #2462), so neither env var
can anchor "am I in my worktree?" The git-dir != common-dir plumbing can, and the assertion keys on
only that — never on an agent-type string, so no fix relocates the fragile coupling (the same
principle as #3406's ADR 0189 note). This is deliberately distinct from #3406. #3406 is the
consumer/failure-side change: it re-keys the ISOLATION_EXPECTED detector (the
case "$CLAUDE_CODE_AGENT" on the git-dir == common-dir branch above) onto the same env-independent
primary-checkout corroboration, to parity with bash-pin.ts. This issue is the positive/success-side
assertion. The two live on opposite sides of the same if and do not overlap: #3406 hardens when
to fail closed, #3458 hardens what a pass positively establishes. The ISOLATION_EXPECTED detector
is intentionally left untouched here so the two changes can't double-implement or contradict.
The preflight runs once at Step-4 start AND re-asserts before EVERY git-mutating op.
The harness resets an isolated subagent's shell cwd back to the primary checkout
between Bash calls (edits still land in the worktree, but a fresh git invocation runs
where the cwd points). A git commit/git push/branch op issued after such a reset runs
against the shared primary tree even though the opening preflight was green — so two
parallel runs serialize their commits onto whatever branch the primary tree is on,
cross-contaminating each other's PRs (#832). One pass at Step-4 start does not hold for
the whole run.
Capture your worktree root once, then run this mandatory per-mutation preflight —
wt_preflight — immediately before every git commit, git push, and branch
create/switch (Steps 4, 5, R2, R3). It re-cds to your own worktree root first (correcting
a between-calls reset), then re-runs the same fail-closed check and asserts the toplevel
is your worktree. A green wt_preflight is the only sanctioned path to a mutation — no
bypass, same construction as the opening preflight:
WT="$(git rev-parse --show-toplevel)"
wt_preflight() {
cd "$WT" || { echo "wt_preflight FAILED: cannot cd to worktree root $WT" >&2; return 1; }
GITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || {
echo "wt_preflight FAILED: not in a git repo at $WT" >&2; return 1; }
COMMON="$(git rev-parse --git-common-dir 2>/dev/null)"
case "$COMMON" in /*) ;; *) COMMON="$(pwd)/$COMMON" ;; esac
COMMON="$(cd "$COMMON" && pwd)"
TOP="$(git rev-parse --show-toplevel)"
echo "wt_preflight: git-dir=$GITDIR common-dir=$COMMON toplevel=$TOP wt=$WT"
[ "$GITDIR" != "$COMMON" ] || { echo "wt_preflight FAILED (fail-closed): on the PRIMARY checkout, not the worktree — refusing to mutate." >&2; return 1; }
[ "$TOP" = "$WT" ] || { echo "wt_preflight FAILED (fail-closed): toplevel ($TOP) != my worktree ($WT) — cwd reset landed me in a sibling/primary tree." >&2; return 1; }
}
wt_preflight && git <commit|push|switch …>
Anchor EVERY Edit/Write to $WT — the raw-write path no git guard covers (#3458).
wt_preflight and the repo-side worktree-guard both gate head-moving git ops. A raw
Edit/Write to a file is not a git op, so neither guard fires on it — an edit whose
target is a primary-checkout absolute path writes straight into the shared primary tree,
silently, and is caught (if at all) only by a coder noticing the primary went dirty before commit.
This is not hypothetical: with $WORKTREE_ROOT unset and the cwd reset to the primary checkout
between calls, a coder's first Edit/Write took primary-checkout absolute paths from session
gitStatus/Read context and landed edits on primary main (#3458 sharpening) — the guards never
saw it because it wasn't a git op.
So the opening preflight's $WT capture is load-bearing for file edits, not just git ops:
every Edit/Write target path MUST be under $WT (the absolute worktree root the preflight
CONFIRMED), never a bare primary-checkout absolute path — even one the harness handed you in
gitStatus or a Read result. The Edit/Write tools do not auto-target the worktree; an
absolute path outside $WT edit-bleeds into whatever tree it names. Before the first edit, know
$WT; for every edit, confirm the path begins with $WT. Treat a primary-checkout absolute path
in your context as stale (cwd-reset + Read-cache), not as your worktree.
Pushing: the verdict is the ref, not the exit code — always pipeline-cli verified-push (#4213).
A push is the one mutation whose failure is invisible by default, and not because of git.
Two observation-layer defects destroy the signal independently: piping a push through tail
reports tail's status (pipefail is off on this platform, measured — so the pipeline
returns 0 however git died), and a detached run's output file carries no exit status at
all, so success gets inferred from the absence of an error: line — which is exactly what a
SIGPIPE'd git, printing nothing, looks like. Both fired on one lane (#4042 / PR #4142): the
branch simply was not there, and every downstream stage — reviewer checkout, SHA-bound verdict,
shipper merge — assumed a branch that did not exist.
So never invoke git's push porcelain directly from this skill. Use pipeline-cli verified-push, which pushes and then reads the remote ref back independently, and emits its
verdict as a single grep-able line last on stdout — PUSH-VERDICT: MOVED, NOT-MOVED, or
UNKNOWN — in addition to the exit code (0 / 1 / 3). stdout is the one channel that survives
both masking layers, so … | tail -1 now yields exactly the verdict instead of erasing
it, and a detached run's output file ends with it.
Three outcomes, and the third is not a success. MOVED means the remote ref was confirmed
at your local head. NOT-MOVED means it was confirmed not to be. UNKNOWN means the probe
could not determine it — a dead transport, an unresolvable head, a ref that matches while the
push itself misbehaved. Treat UNKNOWN exactly like a failure: stop and surface it. Do not
re-run the push hoping it takes (the verb deliberately does not retry — a blind retry hides the
contention it exists to expose, #4136), and do not proceed to open a PR, request a re-review, or
report the work landed.
This is enforced mechanically, not by attention (ADR 0202): pipeline-cli gh-phoenix lint-skills
reds on a git push invocation in any runnable block of the skill corpus and fails closed on zero
scope, so a bare push cannot re-enter this file.
This constrains how you branch: main
is already checked out in the primary tree, so git checkout main fails inside an
isolated worktree (fatal: 'main' is already checked out at <primary>). Branch from
latest origin main without checking it out:
git fetch origin main || { echo "write-code: 'git fetch origin main' failed — refusing to branch off a stale FETCH_HEAD (would cut a head missing a just-merged base file; #1920)." >&2; exit 1; }
PREFIX="$(git config user.name | tr '[:upper:] ' '[:lower:]-')"
: "${PREFIX:?set git user.name to derive a branch prefix}"
BRANCH="$PREFIX/<slug-for-issue-N>-$(uuidgen | head -c 8)"
wt_preflight && git switch -c "$BRANCH" FETCH_HEAD
It's git switch -c "$BRANCH" FETCH_HEAD (not git checkout main) on purpose: in an
isolated worktree main is checked out elsewhere, so branching directly off the
freshly-fetched FETCH_HEAD is the only flow that works — don't "fix" it back to a
main checkout.
The branch name is created once here, then RE-DERIVED LIVE from the worktree at every
later git op — NEVER carried across Bash calls in a shared file. $BRANCH is a shell
variable, and shell state does not survive between an agent's separate Bash
invocations (env vars are gone by the next call, the same way the cwd is reset back to the
primary tree — which the per-mutation preflight corrects). So by push time (Step 5)
$BRANCH is empty, and an agent that "carries" it by writing the name to a scratchpad
file improvises a cross-lane hazard: a plain /tmp (or shared-scratchpad) path like
branch.txt is unkeyed, so a concurrent lane clobbers it mid-run and the reader
pushes to the sibling lane's ref (#2038 — the #2018 lane pushed toward the #2021
lane's branch). The uuidgen nonce makes each lane's value unique, but a shared
filename is what collides.
The rule (mandatory, at every git op after this create): re-derive the branch live from
your own worktree — never read it from a file. Right after wt_preflight re-cds you to
$WT, the checked-out branch is your work branch, so read it straight from the
worktree HEAD:
BRANCH="$(git -C "$WT" branch --show-current)"
: "${BRANCH:?could not re-derive branch from worktree $WT — refusing to push to a guessed/cached ref}"
This is exactly the recovery the reported incident used to self-heal, promoted to the
standing rule. Do NOT persist the branch name to a scratchpad//tmp file to carry it
across calls. This is the local instance of the per-run scratchpad namespace, §SP of
../gh-issue-intake-formats.md — and it is §SP's first
rule, "prefer no file at all," which the live re-derivation above satisfies outright. If — and
only if — a per-run cache is genuinely unavoidable, allocate it under $RUN_SCRATCH per §SP
(never a bare branch.txt, and never a path keyed only on the issue number). The reason this
matters beyond a mis-push: a clobbered scratchpad file reads back successfully with the
sibling lane's content, so nothing errors and the wrong ref looks right (#2038, and the #3718
reviewer that diffed the wrong PR).
The prefix is derived from this checkout's git config user.name, not a copied literal —
so the branch lands under your handle (<your-handle>/…), whoever runs the skill, instead
of inheriting someone else's namespace. Append a short kebab-case slug naming the work and the
per-run nonce so two concurrent runs on the same issue never push the same origin/ ref. Read the issue's ### What to build for scope
and honor the **TDD:** flag — yes means write the failing test first, then make it
pass; no means config/docs/scaffolding where test-first doesn't apply.
As you write, apply CLAUDE.md's "Comments earn their place or die" collapse convention to
the code you generate: state a why once at its most load-bearing site and point elsewhere
with // See ADR NNNN / #NNNN — never re-derive the same ADR rationale across multiple
docblocks in a file (the coder.md standing invariant is the source; #2186).
Non-isolated fallback. For the rare invocation that isn't already in a worktree,
spin one up rather than checking out main. Fetch first — the local origin/main
remote-tracking ref can predate a just-merged base commit (e.g. a run-evidence-referenced
tooling file added seconds before branch-cut), so git worktree add … origin/main off a
stale ref cuts a head that misses that file and false-fails the SHA-bound CI (#1920; the same
stale-base class #1837 fixed at the repair step). Create the worktree on the per-run
$BRANCH (nonce and all) at a per-run worktree path off the freshly-fetched tip, so two
concurrent fallback runs collide on neither the branch nor the dir: git fetch origin main; WT="../wt-issue-<N>-$(uuidgen | head -c 8)"; git worktree add -b "$BRANCH" "$WT" FETCH_HEAD,
then cd "$WT". Branch off FETCH_HEAD (the just-fetched origin tip), never the possibly-stale
origin/main ref. When you're done, remove it with git worktree remove "$WT" (never
--force — a dirty tree then errors out and is KEPT). A build worktree that made commits and
is not removed here (an aborted run, a fallback that never reached its done-step) does not
leak unbounded: it is backstopped by pipeline-cli worktree-sweep --execute (#1243/#2785),
which reclaims a .claude/worktrees/ build tree only once it is clean + merged + idle +
unlocked with no open PR — the #2240 liveness guard, non---force. After the cd "$WT", re-run the Step 4 preflight — the
fresh worktree's git-dir now differs from the common dir, so the check passes and you may
mutate. This fallback is only for the standalone (isolation-expected=0) path — a run where
isolation was expected must NOT reach it: the preflight's loud branch (ADR 0172, #2443) hard-stops
that case before here, because self-provisioning would silently absorb a harness provisioning
failure (#2440) and collapse the two-layer defense to one. For a standalone run this is the only
sanctioned route from a primary-checkout start; the preflight stays fail-closed until a real
worktree exists. Here too the branch name is created once
and thereafter re-derived live from the worktree (git -C "$WT" branch --show-current) at each later git op — never carried across Bash calls in a shared file
(see the live-derivation rule above; the same
cross-lane clobber applies to a fallback run).
Ground the implementation in the codebase the way the repo expects: the ADRs in
.decisions/ are the why and the binding decisions, the patterns in .patterns/
are how the current code is shaped — read the relevant ones before writing, and
follow them over intuition (per CLAUDE.md). Implement the issue's acceptance
criteria; they are the literal checklist review-code will verify, so build to make
every box checkable from the outside. Run the pre-push typecheck (below) and the
test suite as the repo conventions require before you open the PR.
Pre-push typecheck — run the EXACT CI command (pnpm typecheck), never a hand-rolled tsc
Run pnpm typecheck — the exact command CI's lint / format / typecheck job runs
(.github/workflows/ci.yml) — and refuse to push on any non-zero exit. This is the
only typecheck that predicts the gate, because it is byte-for-byte the gate's own
command:
pnpm typecheck
pnpm typecheck fans out (turbo run typecheck) to each app's project-aware checker — for
apps/web that is pnpm fate:generate && tsgo -b tsconfig.worker.json tsconfig.node.json && tsgo -p tsconfig.app.json … — so it covers the full workspace under the real project graph,
including every newly-added test file, with the repo's strict flags
(noUncheckedIndexedAccess, exactOptionalPropertyTypes) in force. Do NOT substitute a
hand-rolled tsc -p tsconfig.json (or any bare tsc): the repo's checker is tsgo
(@effect/tsgo / @typescript/native-preview), a different engine than stock tsc, and a
root-tsconfig.json tsc run uses a different, partial project scope that omits the
app's test files — so a type error in a new test file passes your self-check and only
surfaces when CI (or review-code) re-runs the real pnpm typecheck at head, costing a full
FAIL → repair round-trip the pre-push gate exists to prevent (#1479; the #1406 / #1407
incidents — new test files that failed TS18048 / TS2412 / TS2345 only under the gate's
tsgo). New test files are full TS under the same flags and the same project as production
code — a test-file type error is a real gate failure, not noise. So run pnpm typecheck
after your final test edits, over the whole workspace, and treat a non-zero exit as a
hard "do not push" — never report "typecheck clean" off a narrower or different-engine run.
For lint, run pnpm lint:worktree, not pnpm lint. Bare pnpm lint
(biome check .) self-no-ops from inside the worktree: . resolves to the
worktree's own CWD, which physically sits under .claude/worktrees/<id> and so
matches the retained !**/.claude/worktrees exclusion — biome reports "0 files /
paths ignored" without linting anything (a false-clean that sailed past local checks
and only failed in CI; #236, #553,
ADR 0060).
pnpm lint:worktree lints the explicit changed files instead (committed and
working-tree, vs origin/main), filtered to biome-handled extensions so a
docs/markdown-only diff is a clean skip (exit 0), never bare .. It catches the
same violations CI's lint / format / typecheck job would — including in root and
.claude/** files, which a bare biome check apps packages would miss — so a clean
pnpm lint:worktree reliably predicts a green CI lint.
pnpm lint:worktree
Editing a skill — use the real skills/** path, never the .claude/skills/** symlink
When the issue has you editing a skill (a SKILL.md or its supporting files), edit the
real path under claude-plugins/<plugin>/skills/<name>/… (e.g.
claude-plugins/kampus-pipeline/skills/write-code/SKILL.md), never the .claude/skills/<name>/…
path. .claude/skills is a symlink to the real plugin skills dir — both resolve to the same
file on disk — but the harness's auto-mode self-modification classifier keys on the path
string: any Edit/Write whose target contains .claude/ is flagged "Self-Modification
(config file controlling agent behavior)" and hard-blocked when the authorization comes from an
issue/tool rather than the user's own message. The identical file edited via the real
claude-plugins/**/skills/** path is not flagged. So the .claude/ path is a coin-flip into an
opaque failure (it surfaces as a generic build-failed with no PR, not "blocked by the self-mod
guard"), costing a wasted retry + manual diagnosis (#599, #637). Always resolve the real path first.
PR bodies and progress comments must describe the changed path as the real claude-plugins/**/skills/**
path too, so the diff a reviewer reads matches what you wrote.
Editing .claude/ content (not a symlinked skill) needs a Bash scripted-replace. The same
guard blocks the Edit/Write tools on any genuine .claude/ path that has no real-path alias —
e.g. .claude/settings.json. There's no symlink to route around for those, so apply the change with a
Bash-scripted in-place replace (a node/sed splice) rather than the Edit tool. (Most write-code
tasks don't touch .claude/ content at all — this is the escape hatch for the ones that must.)
Commit per repo conventions, gating each git commit on wt_preflight (the
per-mutation preflight above) so a between-calls cwd reset can't
land the commit on the primary tree. Don't push to or PR from main.
Step 4a — Read the four-pillars design law before you generate any UI (UI diffs only)
When this diff will generate or edit a user-facing UI surface, read
design-system-manifest.md
before you author the UI, and generate to it — role-token annotations, component-selection
rules, and the per-pillar prohibitions. That manifest is the agent-readable transcription of the
four-pillars design law (ADR
0162) —
the CLAUDE.md-for-design a UI-generating agent applies from the first draft. This is the read-before
counterpart to Step 4d's look-after self-check and Step 4e's prose-before read: consult the law on
the way in so the generated UI conforms from the start, rather than leaving review-design to catch
a pillar violation after the fact. Making this an explicit step closes the build-vs-review asymmetry —
review-design binds the manifest explicitly at gate time, so the build side must too, not rely on
CLAUDE.md happening to name the file.
Fires only for a UI diff — a no-op otherwise (graceful absence). Scope it to a diff that changes a
rendered frontend surface (apps/web/src/**, the same probe as Step 4d); a worker/tooling/docs/skill
diff authors no UI and skips this step entirely — the same first-class-absence shape as Steps 4b/4d/4e.
Step 4b — Ship dark behind a default-off flag on a containment-marked child
When the child you picked carries **Containment:** flag (default-off), the implementation
above isn't done until the new user-facing path ships dark: behind a boolean flag that is
off by default, so the feature reaches main and production deployed-but-not-live until a
human deliberately flips it. This is the product-development cycle's agents-deploy / humans-release
contract (ADR
0083):
your autonomous merge is the deploy, the flip is the human release, and a default-off flag is
what makes the no-eyeball auto-ship safe — a bad merge sits dark, contained, never seen by a user.
plan-epic stamps the marker, you ship dark on it, and review-code Step 3b verifies the gating.
The marker contract — its values, its tolerant-read rule, who writes vs reads it — is defined once
in ../gh-issue-intake-formats.md
(§The product-development cycle hook); read it there, this step is the reader's behavior on the
ship side.
Read the marker off the child you're implementing, tolerantly per the formats §Reading stance —
a **Containment:** line, with a leading bold-marker, anywhere in the body; a missing line reads
as none:
CONTAINMENT=$(gh api repos/$REPO/issues/<N> --jq '.body' \
| grep -ioE '\**\s*Containment:\**\s*(flag|exempt|none)' | head -n1 \
| grep -ioE '(flag|exempt|none)' || echo none)
Graceful absence — the dark-ship behavior applies only when there's a cycle. It fires only
when the marker resolves to flag and the repo has a product-development-cycle.md (the one
canonical probe, formats §1). On exempt, none, a missing line, or an absent cycle doc (a
foreign install with no cycle and no flag substrate — ADR
0062), this
step is a no-op: you implement and ship the change exactly as Steps 4/5 already describe, with
no flag introduced. Absence is a first-class, correct state, not a defect — the same
graceful-absence contract plan-epic (stamp) and review-code (verify) honor:
gh api "repos/$REPO/contents/product-development-cycle.md" --jq '.path' >/dev/null 2>&1 \
&& CYCLE_DOC=present || CYCLE_DOC=absent
When it does fire, ship dark per the dark-ship procedure — don't re-derive the mechanics.
The change gates behind a default-off flag, which is one of two shapes that this step treats
identically from here on:
- Newly-declared in this diff — no suitable flag exists yet, so you mint one: declare a
default-off flag and gate the new path following
.patterns/feature-flags-agent-workflow.md
(the ship-behind-flag workflow, #514), naming the flag by the grammar in
.patterns/feature-flags-schema-lifecycle.md
(<product>-<feature>-<purpose>, kebab-case, #513).
- Gated behind a flag a PRIOR PR already declared — the flag resource is not in this diff
(an earlier PR minted it; you only add the gated path under the existing key). The #1277/#1205
shape: a feature gated behind the prior-PR #1204 authorship flag.
Whichever shape applies, capture the exact kebab-case flag key as FLAG_KEY — it is the single
fact that flows out of this step. The load-bearing invariant the patterns own is default =
safe-state, the three facets review-code Step 3b will verify, so build to make each checkable
from the outside:
- Declare it default-off (newly-declared shape only) — a