---
name: work
description: "This skill should be used when executing work plans efficiently while maintaining quality and finishing features."
---

<!-- work-anti-bypass-protocol:start -->
## Anti-bypass protocol (load-bearing — especially Grok Build)

You are the **implementation orchestrator** for standalone `/work` and one-shot Step 3:

- **FORBIDDEN:** Pushing a branch and reporting "done" without `/review` → `/compound` → `/ship`.
- **FORBIDDEN:** Treating `## Work Phase Complete` as a turn boundary when you own the pipeline (see Invocation Mode below).
- **REQUIRED (Grok Build):** Invoke `/review`, `/compound`, `/ship` via slash commands — never hand-roll ship steps.
- **Deliverable:** merged PR (standalone) or return to parent one-shot (pipeline mode).

See `plugins/soleur/lib/workflow-fidelity.ts` (`IMPLEMENTATION_TAIL`) and Phase 4 Invocation Mode below.
<!-- work-anti-bypass-protocol:end -->

**"PROPAGATED" IS A MEASUREMENT ACROSS EVERY SITE ASSERTING THE CLAIM — NOT AN OBSERVATION ABOUT
THE ONE YOU EDITED.** Grep for the OLD claim after correcting it, never for the new one: a residual
count over the new text is structurally blind to the sites still carrying the old. A superseded
position survives precisely where prose review does not look — machine-readable frontmatter, an
operative conclusion far from the edited paragraph, and a DIFFERENT document citing the old
position as its own contrast case — so a corrected document containing an uncorrected field clears
both the gates and the review. **A literal grep is the WEAKEST of the three sweeps, and
PARAPHRASE is the mode with no coverage** — wrap-insensitivity handles a blockquote splitting
a phrase, and neither handles a site that restates the claim in different words. Write the
claim as a SENTENCE and grep its paraphrases; the highest-risk position is a `**Prevention:**`
line, because that is the part a future session executes. **Why:** #7957 — a sweep that was
wrap-insensitive across all 9,684 tracked `*.md` returned 0 for a file it had just corrected,
while that file's `## Session Errors` still prescribed "wait for the completion notification"
as the remedy for an error it names "Inferred a commit VERDICT". **Why:** #7349 — a legal record's controllership was corrected in
prose while its YAML `controller:` field FIFTEEN LINES ABOVE still asserted the superseded
position, along with the operative balancing conclusion and a sibling document; the signed-off
audit had already recorded that propagation as DISCHARGED. Same shape as the measurement rule
below, one level up: that rule protects a number's freshness, this one protects a correction's
reach. See `knowledge-base/project/learnings/2026-08-12-every-blocking-finding-was-the-defect-class-the-pr-existed-to-close.md`.

**VERIFY A MEASUREMENT ONCE, BEFORE IT PROPAGATES.** When a plan's sweep will write the same
measured fact into more than one file, verify it at the granularity you are about to CLAIM it
— before the first write, not after the last. A false measurement does not stay where you put
it: downstream files, issue comments, and review agents all consume it as fact and build on
it, so the cost of correcting it scales with how far it spread, and the correction itself has
to be swept. **Why:** #7325 — "cx33 is available in hel1-dc2" was read off a FLEET-WIDE probe
and written into 4 files and 2 issue comments. It is false per-datacenter. Six review agents
read it and one built its top finding on it (recommending an amendment to a hard-rule
exception), so the error had already propagated one level past the diff before a 15-second
re-probe falsified it. Project every measurement onto the exact scope you will assert — per
datacenter, per host, per environment — and re-probe rather than adjudicating between two
readings of the record.

**SINGLE-SOURCE THE RATIONALE BEFORE WRITING IT, NOT AFTER REVIEW SAYS SO.** Prose is review
surface: every restated paragraph is re-read by every agent and can drift independently. Write
the reasoning ONCE at the artifact that owns the decision (the ADR for the element, the
resource for the resource) and make every other site a one-line pointer anchored on content,
not a line number. **Why:** #7325 — ~300 lines of justification prose across 8 files became
the bulk of the review surface for a one-literal change, and two copies of the same probe
table DIVERGED INSIDE THE SAME PR (one said "flipped twice", the other "three times") before
any reviewer saw them. The review's own conclusion was to delete one copy. A short rationale
plus a pointer is both cheaper to review and structurally unable to drift.

**A ROLE TEST BUILT FROM AN ABSENCE DOES NOT DISCRIMINATE, AND ITS FIXTURE WILL AGREE WITH IT.** When one artifact is rendered for two roles (a shared bootstrap, a shared workflow, a shared config), scope role-specific behaviour on a POSITIVE identity the role owns — and emit that identity as a field so the consumer can require it rather than infer it. Never scope on "the other role does not have X": you must then prove the negative, and the CI sandbox lacking X will pass the arm you labelled "the other role" while proving nothing about it. Fixture the role that HAS X, with every input present and only the identity differing. **Why:** #7695 gated a destructive-recut input on "is `/mnt/data` a mountpoint", commenting that the co-located web host has no `/mnt/data`; `cloud-init.yml` mounts the workspaces volume there, so the probe would have walked every user's repo tree hourly and shipped a store measurement from the wrong host into a gate whose clearance condition is `0`. See `knowledge-base/project/learnings/2026-09-02-i-built-a-host-discriminator-out-of-an-absence-and-fixtured-the-absence.md`.

**ASSERT THE VALUE THAT MUST NEVER APPEAR, AND PUT THE INSTRUMENT UPSTREAM OF EVERY ASSERTION.** When a change exists to guarantee a value never occurs, assert that negative directly over every arm and every field — a positive "is it the token I expect" is satisfied by the correct answer AND by any arm that never reaches the field. Give each suite an instrument self-test that drives its pass/fail helpers once each and refuses to continue unless both counters moved, and report assertion-count floors with `printf` + `exit 1`, never through the helper they backstop (ADR-193). **Why:** #7695 — 16 of 24 mutants survived a 250-assertion battery; three flipped `__UNREADABLE__` to the literal `0` that authorizes a destroy and stayed green, `if eval "$condition"` -> `if true` produced a byte-identical summary line, deleting the entire new block reported `232/232 passed, 0 failed` exit 0, and the sibling suite's floor called the very `fail()` one edit disarms. Same learning file.

**THE WRAPPER THAT REPORTS A GATE IS ITSELF A GUARD, AND IT IS THE ONE NOBODY REVIEWS.** The suite under test gets mutation rows, assertion floors and an instrument self-test; the five-line bash that RUNS it and reports the verdict gets none, so the could-not-measure/measured-bad collapse survives there. Three rules: never take a verdict from a PARTIAL artifact, and never from the completion NOTIFICATION either — the notification is authoritative for LIVENESS (has it exited?), never for VERDICT (did it succeed?), and TWO INDEPENDENT mechanisms defeat the verdict reading. (i) Its exit code is that of the LAST command the shell EXECUTED, so a trailing `git log` for convenience becomes the verdict — measured in #7912 (filed as #7957): three commits notified `exit code 0` while `git commit` had returned 1. (ii) A run killed mid-`tsc` by the memory reaper ALSO notified `completed`, with no trailing command involved at all — so ending the string with the command that matters does NOT make the notification trustworthy; a missing verdict line is UNRESOLVED, never a pass. Write the real code yourself — `echo "RC=$?" >> log` IMMEDIATELY after the command whose status matters, before any convenience line, because `$?` binds to the immediately preceding command — and read that; a per-step line is not the runner's verdict either. Never pipe a command whose exit code IS the result (`cmd | tail` takes the pipe's status and destroys the evidence in the same stroke — redirect and read `$?`); and make "the loop ran" observable, because `grep -c` exits 1 on zero matches, so `0 failures` and `0 executions` render identically. Put long-lived logs in the worktree — `/tmp` is swept between turns and leaves the writer holding a deleted inode. **Why:** #7828 — a partial lefthook tail read as "rejected" started a SECOND concurrent `git commit` in one worktree (two writers, one index); `git commit | tail -25` reported success on a failed commit and destroyed the only copy of a 52-minute battery failure, which then had to be re-run; and a fleet loop reported `0 reds` without proving it executed. Seven process errors, one shape — the same shape the PR's own 38 findings existed to remove. #7912 hit the two-writers case AGAIN by a different route: the harness reaped the parent `git commit` and its lefthook kept running, so a retry made two writers on one index. Before any git-write retry, check this worktree for a surviving hook process — a reaped task does not imply a reaped process tree. See `knowledge-base/project/learnings/2026-09-07-every-instrument-i-built-to-check-my-own-work-could-not-tell-clean-from-never-ran.md`.

**A DATED record is APPEND-ONLY.** When a plan's sweep touches a probe table, ledger row, dated addendum or post-mortem, correct it by APPENDING a new reading/section that cites the old one — never by editing the old one's body or substituting a cell. Editing in place is what turns a correction into destroyed evidence, and the destroyed datum is disproportionately the one that supported your own claim. Use `## Addendum — YYYY-MM-DD (#N)` or `> **Superseded YYYY-MM-DD (#N):**`, and put the record in the ADR that OWNS the element rather than the one you happened to be reading. **Why:** #7309 — a repin overwrote ADR-143's dated `cx23 = YES — in stock` cell, then asserted "Both read NO here" four lines above it; that deleted `YES` was the only in-repo evidence for the PR's own headline claim, and restoring it made the argument stronger than the one that replaced it. See `knowledge-base/project/learnings/2026-08-06-i-deleted-the-measurement-that-was-my-own-evidence.md`.

**BEFORE FIXING A DEFECT CLASS, GREP THE LEARNINGS FOR IT — AND IF IT RECURRED, THE FINDING IS THE PROPAGATION FAILURE, NOT THE DEFECT.** When the fix is a helper, the durable unit is the IMPORT, not the helper and not the write-up: a helper that must be re-derived per file will eventually be re-derived wrongly, and the wrong copy is disproportionately the one that does damage. Strip by PREFIX, never by a hand-listed set of names — a name list is a claim about which values the tool honours, and it is wrong the moment the tool adds one. **Why:** #7822 — a fixture spreading a raw `process.env` into `git commit` under lefthook committed onto the caller's live branch and CONSUMED an in-flight commit (`GIT_DIR` beats `cwd`, and beats `git -C` too). The identical mechanism was already documented five months earlier in the same directory (#1454), WITH two correct working copies in the tree; neither was importable, so neither propagated. The first fix hardcoded six `GIT_*` names — exactly what the prior learning's Prevention section said not to do, unread until after it was committed. Second harm: a case asserting "this directory is NOT a repository" inverts under the inherited variable and proves nothing while staying green. See `knowledge-base/project/learnings/2026-09-04-a-learning-two-working-copies-and-it-still-got-re-derived-wrong.md`.

**A YAML `run: |` BLOCK APPLIES NO ESCAPE PROCESSING — VERIFY THE LINE AS THE RUNTIME WILL RECEIVE
IT, NOT AS YOU WROTE IT.** A literal block scalar hands the command its bytes verbatim, so a
doubled backslash reaches `sed` as a LITERAL backslash: `\\(` is not a capture group in BRE, and the
expression silently matches nothing. Nothing rejects it — the step exits 0, the variable is empty,
and every condition downstream of it goes false. The same block has two more traps: no line inside
it may begin at column 0 (a heredoc terminator or a wrapped quote there ends the block), and a
`templatefile()`-rendered one needs `$${…}` for braced shell expansions. So never grade one of these
edits by reading it: parse the YAML, extract the command, and run it against a fixture whose correct
answer you already know. **Why:** #7500 — a fix hardening `NIC_ALARM_VERDICT` against injection
doubled the backslashes in its own anchored `sed`, so BOTH verdicts extracted empty on every run and
all four filing conditions went false: the alarm would have filed nothing on any verdict. That is
the #7242 "dark on exactly the verdicts it exists to raise" defect, reintroduced by the commit
fixing a different defect in the same file, and it was caught by a reviewer re-reading the
uncommitted tree rather than by any suite. See
`knowledge-base/project/learnings/2026-09-08-every-field-my-alarm-trusted-came-from-the-region-it-did-not-trust.md`.

# Work Plan Execution Command

Execute a work plan efficiently while maintaining quality and finishing features.

## Introduction

This command takes a work document (plan, specification, or todo file) and executes it systematically. The focus is on **shipping complete features** by understanding requirements quickly, following existing patterns, and maintaining quality throughout.

## Headless Mode Detection

If `$ARGUMENTS` contains `--headless`, set `HEADLESS_MODE=true`. Strip `--headless` from `$ARGUMENTS` before processing the remainder as a plan path. Pipeline mode (file path detection) already covers all prompt bypasses for work's own prompts — `--headless` is only needed for forwarding to child skills in Phase 4.

## Input Document

<input_document> #$ARGUMENTS </input_document>

<decision_gate>
**API budget.** This skill executes a work plan iteratively across many phases. Tier A (Agent Teams) carries ~7x per-task token cost; Tier B (Subagent Fan-Out) is moderate; Tier C is single-agent. Total cost scales with plan length, chosen tier, and per-task RED/GREEN/REFACTOR cycles. Soleur does not bill or proxy these calls — Anthropic does, against the key in your session. The Soleur LICENSE (BSL 1.1) disclaims warranty for runtime cost; you operate this loop against your own budget.

The tier offer fires inline at the right phase. Decline if running an unfamiliar plan against a tight budget.
</decision_gate>

## Execution Workflow

### Phase 0: Load Knowledge Base Context (if exists)

**Load project conventions:**

```bash
# Load project conventions
if [[ -f "CLAUDE.md" ]]; then
  cat CLAUDE.md
fi
```

**Clean up merged worktrees (silent, runs in background):**

Navigate to the repository root, then run `bash ${CLAUDE_PLUGIN_ROOT:-./plugins/soleur}/skills/git-worktree/scripts/worktree-manager.sh cleanup-merged`. Report cleanup results: how many worktrees were cleaned up, which branches remain active.

**Check for knowledge-base directory and load context:**

Check if `knowledge-base/` directory exists. If it does:

1. Run `git branch --show-current` to get the current branch name
2. If the branch starts with `feat-`, read `knowledge-base/project/specs/<branch-name>/tasks.md` if it exists

**If knowledge-base/ exists:**

1. Read `CLAUDE.md` if it exists - apply project conventions during implementation
2. If `# Project Constitution` heading is NOT already in context, read `knowledge-base/project/constitution.md` - apply principles during implementation. Skip if already loaded (e.g., from a preceding `/soleur:plan`).
3. Detect feature from current branch (`feat-<name>` pattern)
4. Read `knowledge-base/project/specs/feat-<name>/tasks.md` if it exists - use as work checklist alongside TodoWrite
4.5. Read `lane:` from spec.md if present. Guard file existence first:

   ```bash
   spec_path="knowledge-base/project/specs/feat-${branch_name}/spec.md"
   if [[ -f "$spec_path" ]]; then
     LANE=$(awk '/^lane:/ { gsub(/^lane:[[:space:]]*"?|"?$/, ""); print; exit }' "$spec_path")
     case "$LANE" in
       single-domain|cross-domain|procedural) ;;
       "") LANE="" ;;  # legacy spec; silent skip in announce
       *) echo "work: invalid lane value '$LANE' in spec; ignoring."; LANE="" ;;
     esac
   fi
   ```

   Lane is **non-binding in skill logic** — `work` code does not branch on `LANE`. Operators MAY use the announced lane as a heuristic when picking work Tier 0/A/B/C in Phase 2; binding behavior is deferred per Non-Goal #2.
5. Announce: ``"Loaded constitution and tasks for `feat-<name>`"`` — append `" (lane=<value>)"` when `LANE` is non-empty.

**If knowledge-base/ does NOT exist:**

- Continue with standard work flow (use input document only)

### Phase 0.5: Pre-Flight Checks

Run these checks before proceeding to Phase 1. A FAIL blocks execution with a remediation message. A WARN displays and continues. If all checks pass, proceed silently.

**Environment checks:**

1. Run `git branch --show-current`. If the result is empty (detached HEAD), FAIL: "Detached HEAD state -- checkout a feature branch or create a worktree." If the result is the default branch (main or master), FAIL: "On default branch -- create a worktree before starting work. Run: `bash ${CLAUDE_PLUGIN_ROOT:-./plugins/soleur}/skills/git-worktree/scripts/worktree-manager.sh feature <name>`"
2. Run `pwd`. If the path does NOT contain `.worktrees/`, WARN: "Not in a worktree directory. You can create one via `git-worktree` skill in Phase 1."
3. Run `git status --short`. If output is non-empty, WARN: "Uncommitted changes detected. Consider committing or stashing before starting new work."
4. Probe for stashed changes WITHOUT invoking `git stash` (the `hr-never-git-stash-in-worktrees` hook denies even the read-only `git stash list`). Use `git rev-parse --verify --quiet refs/stash` — a zero exit means a stash exists; WARN: "Stashed changes found. Review stash list to avoid forgotten work." A non-zero exit means no stash; continue silently.

**Scope checks:**

5. If a plan file path was provided as input (ends in `.md` or starts with a path-like pattern), verify it exists and is readable. If not, FAIL: "Plan file not found at the specified path." If the input appears to be a text description rather than a file path, WARN: "Input appears to be a description, not a file path. Scope validation limited."
6. Run `git diff --name-only HEAD...origin/main` to identify files that diverged between this branch and main. If output is non-empty, WARN: "Branch has diverged from main in [N] files: [file list]. Consider merging main before starting." If the git command fails (e.g., offline, no remote), skip this check silently. **For plans that edit AGENTS.\* (high-collision file class), `plugins/soleur/skills/ship/SKILL.md` (Phase 5.5 gates), OR any path under `docs/legal/**` / `knowledge-base/legal/**` (legal-doc cross-document gate; weekly compliance PRs collide on the same 4-file set), FAIL HARD instead of WARN — fetch + rebase BEFORE Phase 1 (`git fetch origin main && git rebase origin/main`); sibling PRs landing mid-session reliably obsolete plan-quoted budget baselines and trim-target line numbers. Applying-then-rebasing duplicates sibling work and requires full reassessment.** See `knowledge-base/project/learnings/best-practices/2026-05-20-rebase-before-applying-agents-md-plan-edits.md` and `knowledge-base/project/learnings/2026-05-25-closed-field-list-must-classify-at-value-shape-not-column-name.md` §Session Errors #5 (PR #4351 — 10 commits behind including #4353 legal-doc lockstep; caught at review time, not Phase 0.5).
7. If a plan file was provided (check 5 passed), scan for a `## Domain Review` or `## UX Review` heading (both are accepted for backward compatibility). If NEITHER heading found: scan the plan content for UI file patterns (page.tsx, layout.tsx, template.tsx, .jsx, .vue, .svelte, .astro, +page.svelte, app/, pages/, components/, layouts/, routes/). If UI patterns found, WARN: "Plan references UI files but has no Domain Review section. Consider running /soleur:plan to add domain review before implementing." If either heading IS present: pass silently.

**Design artifact checks:**

8. Check if prior phases produced design artifacts. Search the repo for design files matching the feature name: `git ls-files '*.pen' '*.fig' '*.sketch' | grep -i "<feature-name>"` and check `knowledge-base/product/design/` for related files. If design artifacts exist AND the current tasks include UI/page implementation (patterns: `.njk`, `.html`, `.tsx`, `.jsx`, `.vue`, `.svelte`, `pages/`, `components/`, `layouts/`): store the artifact paths as `DESIGN_ARTIFACTS` for use in Phase 2.

**Specialist review checks:**

9. If a plan file was provided (check 5 passed) and a `## Domain Review` section exists with a `### Product/UX Gate` subsection: check whether domain leader assessments recommended specialists (copywriter, ux-design-lead, conversion-optimizer) that are NEITHER listed in `**Agents invoked:**` NOR in `**Skipped specialists:**`. If the `**Decision:**` field says `reviewed (partial)`, WARN: "Domain review was partial — some specialist agents failed. Review the Domain Review section before proceeding." If any recommended specialist is missing from both fields: **Interactive mode:** FAIL with message listing the missing specialists and options: (a) "Run \<specialist\> now" — invoke the specialist agent directly, update the plan file's `**Agents invoked:**` field, then continue; (b) "Skip with justification" — prompt for reason, add to the plan file's `**Skipped specialists:**` field, then continue. **Pipeline mode (headless/one-shot):** auto-invoke each missing specialist agent. If the agent succeeds, add to `**Agents invoked:**`. If it fails, add to `**Skipped specialists:**` with note `(auto-skipped — agent unavailable in pipeline)` and WARN. Do not FAIL in pipeline mode. If all recommended specialists are accounted for (in `**Agents invoked:**` or `**Skipped specialists:**`): pass silently.

   **UX-skip-on-UI-plan hard gate (within check 9):** Determine "UI plan" by matching the plan's `## Files to Create` AND `## Files to Edit` against the shared UI-surface term list + glob superset (`plugins/soleur/skills/brainstorm/references/ui-surface-terms.md`) — NOT just `*.tsx/*.jsx`. The superset includes `*.njk`, `*.html`, `*.vue`, `*.svelte`, `*.astro`, and email templates, so an Eleventy/Svelte/email UI surface does not slip the gate (`wg-ui-feature-requires-pen-wireframe`). On a UI plan, FAIL when EITHER: (a) `ux-design-lead` appears in `**Skipped specialists:**`, OR (b) the plan has **no `### Product/UX Gate` subsection at all** (the gate was never run). Message: "Plan touches a UI surface but has no committed `.pen` (ux-design-lead skipped or gate never ran). Invoke the specialist or provide an explicit override naming the specific UI surfaces being shipped without review." This overrides the "all accounted for → pass silently" branch. A documented skip — or absent gate — on a UI plan is a process gap, not process compliance. See `knowledge-base/project/learnings/workflow-patterns/2026-05-26-ux-design-review-skip-must-fail-hard-on-ui-plans.md`.

   **UX artifact commit checkpoint (after each specialist in check 9):** After each specialist agent completes successfully (interactive "Run specialist now" or pipeline auto-invoke), commit the output:

   1. Run `git status --short` to discover new/modified files from the specialist
   2. Stage specialist output files: `git add <discovered files>`
   3. Commit: `git commit -m "wip: <specialist-name> artifacts for <feature-name>"`

   Each specialist gets its own commit so partial progress is preserved if a later specialist fails. Do not commit on specialist failure.

**On FAIL:** Display the failure message with remediation steps and stop. Do not proceed to Phase 1.

**On WARN only:** Display all warnings together and proceed to Phase 1.

**On all pass:** Proceed silently to Phase 1.

### Phase 1: Quick Start

**Pipeline detection:** If `$ARGUMENTS` contains a file path (ends in `.md` or matches a path-like pattern), this skill is running in **pipeline mode** (invoked by one-shot or another orchestrator). In pipeline mode, skip all interactive approval gates and proceed directly. If `$ARGUMENTS` is empty or a plain text description, this is **interactive mode** — keep the approval gates below.

1. **Read Plan and Clarify**

   - Read the work document completely
   - Review any references or links provided in the plan
   - Before proceeding, verify the plan does not contradict conventions in AGENTS.md and constitution.md: file format (markdown tables not YAML), kebab-case naming, directory structure (agents recurse, skills flat), required frontmatter fields, shell script conventions
   - **A budget you write as a LITERAL is stale the moment a sibling PR moves its base — write it as a delta, and measure the shell rather than assuming it.** Two shapes from one session. (a) An assertion floor, byte cap or count copied into the diff as `47` encodes *today's* base; when a sibling raises the base mid-flight the number is silently wrong and the rebase conflicts exactly where both PRs land. Derive it (`main's floor + 1`), state the increment in the comment, and re-read the base from `git show origin/main:<file>` at ship rather than at plan time — measured, one suite's floor moved 44 -> 46 -> 48 -> 49 over four days across three PRs, and the branch's own in-flight 47 was never a value that shipped, so a literal carried from it would have been wrong in a direction no local test could see. (b) `set -e` does **not** fire on a failing NON-FINAL member of an AND-OR list (`bash -c 'set -e; sh -c "exit 100" && true; echo reached'` prints `reached`), so `setup_a && setup_b` lets a failing `setup_a` fall through into the code under test with its preconditions unmet — which then surfaces as whichever *later* guard trips, asserting a cause that never occurred. Split such pairs into separate statements so each failure aborts with its own rc. **Why:** #7291 — a floor literal and an `apt-get update && apt-get install` both shipped, the second producing a hard FAIL naming a deterministic fixture defect that had not happened. See `knowledge-base/project/learnings/2026-08-16-every-number-i-inherited-was-stale-and-the-panel-found-the-defect-class-inside-my-fix.md`.
   - **A format captured from the LOCAL toolchain is a claim about a version that may not be the one that ships — diff the pin before depending on it.** When a change makes parsing, matching, or a guard newly dependent on another tool's OUTPUT FORMAT (a runner's summary line, a CLI's table, a compiler's diagnostic), the captures are taken with whatever is installed here, and CI installs whatever the repo PINS. Check them against each other (`.bun-version`/`.nvmrc`/`.tool-versions` vs `<tool> --version`) and, if they differ, re-capture under the pinned version in a scratch install before merging — a format difference turns every CI run into the fail-closed branch, on a version you never observed. State in the artifact which versions the format was verified on, so the next reader knows the claim's scope. **Why:** #7466 — every byte capture, fixture and terminator claim was measured on bun 1.3.11 while CI installs 1.3.14 from `.bun-version`; verified identical only because the shard runner's preamble happened to print the mismatch. Do not let the discovery depend on that.
   - **Plan-quoted numbers are preconditions to verify, not facts.** When the plan quotes a current measurement (`bun test … reports X`, `wc -c < AGENTS.md = N`, "cumulative ~Y words; ~Z headroom", `git ls-files | wc -l`), re-run the measurement at /work start before depending on it. Plans authored hours-or-days earlier observe a moving target; parallel branches landing in `main` invalidate the measurement. PR #3501 plan claimed `~186 word headroom` against an actual `15` and required inline trim of the gate description. See `knowledge-base/project/learnings/2026-05-10-handshake-schema-drift-and-stale-precondition-budgets.md`.
   - **A remedy PROPOSED BY AN ISSUE is a claim to MEASURE, not a fix to apply — and a correct diagnosis is what makes the proposed fix go unchecked.** The rule above re-derives plan-quoted *numbers*; this is the same discipline applied to a *remedy*. An issue's diagnosis and its suggested repair are independent claims, and when the diagnosis is obviously right (a reproduced P1, a named file:line), the repair inherits its credibility and nobody runs it. Before implementing an issue's prescribed fix, expand/execute it in the failing condition and confirm it changes the outcome — for a path/anchor/env remedy that means printing what it actually expands to on the target surface, not on yours. **Why:** #7442 — the issue correctly diagnosed CWD-relative producers executing a customer's same-named file, then proposed `${CLAUDE_PLUGIN_ROOT:-./plugins/soleur}`; the variable is unset in bash, so the `:-` default expands to `./plugins/soleur` — the defect itself. Measuring the remedy (~30s) is what caught it, and the same PR then shipped an unmeasured safety claim about its *replacement* that review falsified. See `knowledge-base/project/learnings/2026-08-11-i-measured-the-issues-remedy-then-asserted-my-own-without-measuring.md`.
   - **A plan premise you REFUTED is only refuted against the SHA you checked — re-check refutations after the ship-time rebase, not just re-derive counts.** The stale-number rule above runs one direction (the plan claimed X, X has since changed); this is the inverse, and it is the one that reads as diligence: you verify a plan-asserted defect does not exist, correctly decline to file it, and a sibling PR merging mid-session then CREATES it. The rebase that brings siblings in is exactly the event that can manufacture the condition you ruled out, so a refutation recorded at Phase 0 is stale by Phase 5 on a fast-moving `main`. Cheapest gate: re-run the refuting command after the final rebase for any premise you declined to act on. **Why:** #7160 — a deferral claiming [scripts/test-all.sh](../../../../scripts/test-all.sh) carried a stale "21 suites" header was correctly refuted (no numeric suite claim existed anywhere in the file), and #7146 merged that exact line an hour later. See `knowledge-base/project/learnings/2026-08-03-my-guards-error-channel-was-published-before-its-writers-ran.md`.
   - **Before concluding from a measured delta, state the harness's NOISE FLOOR and check the SIGN — a delta smaller than the noise is UNRESOLVED, never zero.** Re-deriving a plan-quoted number (rule above) fixes staleness, not statistical power — and fixing the *known* flaw in a harness is exactly when the unexamined one ships: interleaving A/B arms removes drift **bias** but adds no **resolution**, so a "corrected" harness feels trustworthy while still noise-dominated. Two gates before any measurement-driven decision: (a) compute the run-to-run spread and compare it to the effect — if `|delta| < spread`, report "this harness cannot resolve it" and measure the PRIMITIVE that changed instead (25 reps of the one `cp`/`diff`/parse has negligible variance where a whole-suite run does not); (b) ask whether the SIGN is physically possible — a result showing strictly-less-work as *slower* is a broken instrument, not a finding. When the decision is right for a non-timing reason (coverage, correctness, blast radius), justify it that way and drop the number entirely. **Why:** #7001 — a plan-mandated fast path was removed on a 5-pair whole-suite A/B reading 6.86s WITH vs 6.60s WITHOUT; the real effect is ~89ms against a ~±590ms (1σ) floor needing ~160 pairs, and the sign was impossible. The removal was correct on coverage grounds alone. See `knowledge-base/project/learnings/2026-07-27-my-ab-could-not-resolve-the-effect-i-concluded-from-it.md`.
   - **Name both A/B arms literally and check one against `main` — every other rigor check protects the SUBJECT of a measurement, and nothing protects its REFERENT.** Interleaving, noise floors, sign checks and rep counts all interrogate how well you measured *the two things you chose*; none asks whether either is the status quo. When a change is built in stages the natural comparison is stage-N vs stage-N+1 ("do these excludes earn their place?"), which is a legitimate question whose number has the identical shape to the one a PR body actually claims ("is this better than today?"). Before reading any delta, write out `arm A = <literal command>`, `arm B = <literal command>`, and answer *which one runs on `main` right now*; if the answer is "neither", the result can support a claim about a choice INSIDE the change and none about the change. The status-quo arm usually has to invoke the real thing (`exec -a ugrep "$CLAUDE_BIN" …`), not a reconstruction of its flags. **Why:** #7165 — a carefully-run interleaved benchmark (noise floor stated, cold rep excluded, sign checked, and it even corrected the plan's figure downward) compared new-without-excludes to new-with-excludes and shipped a **2.25x regression as a win**; the three-arm re-run showed today 243 ms vs new 548 ms, with the dominant term the single-threaded-engine swap rather than the `.gitignore` loss the spec blamed. See `knowledge-base/project/learnings/2026-08-03-my-ab-had-the-wrong-baseline-arm-so-i-shipped-a-regression-as-a-win.md`.
   - **A plan-quoted tool-flag VALUE/UNIT is a claim to verify against the PINNED tool's source/`--help` — the unit is the highest-risk part.** When the plan names a CLI/config flag value (`--postgres-conn-max-idle-time 30`, a timeout, a size, a percent), resolve the pinned version and read that version's flag registration for type + **unit** + default + validation before writing it — a wrong unit (minutes/seconds/ms, bytes/KB, count/%) passes typecheck AND the binary's own validation, so only the source catches it. The plan is authoritative for *intent* (e.g. "drain idle conns fast"), never the literal value. **Why:** #6258 — the plan's `SECS=30` for `--postgres-conn-max-idle-time` was seconds-intent, but the inngest v1.19.4 flag is an IntFlag in MINUTES (default 5) → `30` = 30 min, defeating the drain; corrected to `1`. See `knowledge-base/project/learnings/best-practices/2026-07-09-plan-quoted-tool-flag-value-and-unit-are-claims-verify-against-pinned-source.md`.
   - **A vendor error that CLEARS ON RE-RUN is two hypotheses, and one follow-up probe cannot separate them — read the response HEADERS before writing down "transient".** Vendors retire APIs behind scheduled BROWNOUTS: the endpoint serves the error for a window on a recurring schedule and 200 the rest of the time, so a re-probe minutes later returns clean whether it was restored or merely outside the next window. Both readings fit and the reassuring one gets recorded. Capture headers on the failing call (`curl -D <file>`, never body-only) and treat a deprecation/sunset header on a **successful** response as the durable signal — it is served on 200s too, which is the half that is always observable. Never retry a `410`: a retry long enough to swallow a brownout necessarily also masks the permanent post-sunset case the check exists to catch. **Why:** #7590 — a 2026-07-17 session re-probed a `410`, got a clean plan, and wrote "the 410 was transient" into `versions.tf`; the endpoint had been deprecated since 2026-05-14 and the 410 carried `x-sentry-deprecation-date` + `x-sentry-replacement-endpoint` all along. Measured in BOTH states inside one session on 2026-08-19 — 410 at ~20:5x, 200/200/200 at 21:23, same token, same host, no change on our side. See `knowledge-base/project/learnings/integration-issues/2026-08-19-a-vendor-brownout-is-not-a-flake-and-the-header-said-so-all-along.md`.
   - **Plan hypotheses about a symptom's mechanism (and its route/file classifications) are starting points, not the work-list — trace the ACTUAL producer before coding.** For a symptom-named bug (a 404, an empty state), grep the literal producer (`notFound()`, the `404` response, the empty-state component) and walk the redirect/call chain to the exact determining condition, confirming or falsifying each plan hypothesis against code. Code-tracing is a valid substitute for a plan-prescribed live repro when the repro needs hard-to-synthesize state. Verify route classifications by reading the route's exported HTTP methods (a "read route" with no GET is a write route). **Why:** #4543 follow-up — the plan's two 404 hypotheses + its "redirect lands on /dashboard" reconciliation were all wrong; a 4-file trace (`invite-actions` → `settings/team/page.notFound()` → null-org resolver → `accept-invite` never set active workspace) found the real cause. See `knowledge-base/project/learnings/bug-fixes/2026-06-01-symptom-root-cause-trace-the-actual-redirect-not-the-plan-hypothesis.md`.
   - **A short-circuit guard that returns early to skip a code path must sit AFTER any recovery mechanism that lives inside that path.** Before adding a "detect bad state → skip the expensive path → show a fallback" branch where the plan prescribes a placement, grep for where the *recovery* for that bad state actually runs; if recovery is entangled inside the path you're skipping, the early-return amputates it and dead-ends the user. An honest "X is gone" fallback is a *post-recovery-failure* concept — placing it pre-recovery makes the message a lie in exactly the recoverable case. The placement-time companion to "trace the ACTUAL producer". **Why:** #5240 — the plan's pre-dispatch `.git` probe (FR2/FR3) would have skipped the in-dispatch `ensureWorkspaceRepoCloned` self-heal, dead-ending connected-repo resume; reverted + descoped. See `knowledge-base/project/learnings/best-practices/2026-06-14-short-circuit-guard-must-sit-after-the-recovery-it-gates.md`.
   - **A fail-loud/short-circuit guard for state-class X must not nest under a precondition that only holds for a DIFFERENT state class — and a self-noticed plan-vs-goal gap gets fixed at write-time, not deferred to review.** When the plan nests "flag bad-state X and refuse to proceed" inside an enclosing gate you inherited from an adjacent branch (a staleness/age check, an auth check, a size check) that belongs to a *different* state class, the guard silently narrows to "refuse only when X *also* satisfies the unrelated gate" — undercutting the plan's own stated goal. Ask: does the enclosing condition belong to the SAME class the guard protects? If you notice the gap while coding, fix it inline (a ≤10-line correctness fix is not an architecture fork — reserve CTO routing for genuine forks); implementing the plan's literal placement and waiting for review to catch it is the wasteful path. **Why:** #5907 — the non-regular-lock `UNREMOVABLE` emit was nested under the regular-lock staleness gate, so a *fresh* non-regular `config.lock` slipped straight into the doomed `git config` EEXIST write; 3 review agents converged on a gap noticed at write-time. See `knowledge-base/project/learnings/best-practices/2026-07-02-fail-loud-guard-must-not-nest-under-a-different-state-class-gate.md`.
   - **A RIDER recorded against a pending event is only as live as its VEHICLE — and a probe that measures delivery cannot tell "not yet" from "the vehicle already departed."** When a change ships inert and its delivery is booked onto a future event (a host replace, a migration window, a cutover), the rider has TWO operands and every gate reliably watches only the change. Before reading a follow-through's `not_delivered` as "still waiting", re-check the vehicle: `gh issue view <N> --json state` plus its closing comment, because a CLOSED ordered path is the signal the rider is spent, not the probe. Better, state the falsification condition when recording it (*"if #N closes without this delivering, the rider is spent"*) so it is checkable rather than implicit. Corollary for the correction sweep afterwards: **index by CLAIM, not by literal** — three engineering-corpus literals returned 0 across `knowledge-base/legal/` and that zero was read as "not relevant", while the claim as the legal corpus phrases it returned 4; the same bound missed the ORIGIN text on punctuation alone (backticks around the command; `INERT UNTIL PROVISIONED` ≠ `… A PROVISIONING`), and correcting every paraphrase while leaving the origin standing makes the repo self-contradictory where it was merely stale. **Why:** #7455 — the rider rode a step-6 replace that had fired ~45h BEFORE the shipper merged, so the change sat inert on a host born before it existed and the probe would have reported `not_delivered` accurately for the full 90-day horizon. See `knowledge-base/project/learnings/2026-08-13-a-rider-is-only-valid-while-its-vehicle-is-still-pending.md`.
   - **On resume, a `session-state.md` `### Decisions` entry is INTENT, not an accomplishment — verify each against the live artifact before treating it as done.** The section is written in the past tense by a session that was still mid-flight, so a decision reading "met the intent via a correcting comment on #N" / "filed the follow-up" / "re-titled the issue" records what the author RESOLVED to do, and the step is exactly what a mid-task death (API timeout, compaction, crash) leaves undone — the resume then inherits the *claim* without the *act*. Cheapest gate: for every Decisions line naming an outward-facing artifact, probe it (`gh issue view N --json comments --jq '[.comments[]|select(.body|test("<cite>"))]|length'`, `gh issue view N --json title`, `ls <path>`) and re-open it in the task list on a zero. This is the outward-facing sibling of the artifact rule below (which covers FILES the contaminated session wrote); a GitHub comment leaves no working-tree trace at all, so nothing else surfaces its absence. **Why:** #6497 — `session-state.md` recorded a #6416 correcting comment as met; `gh issue view 6416` showed ZERO comments citing #6497, and the resumed session nearly shipped the claim in its PR body. See `knowledge-base/project/learnings/2026-07-16-a-mutation-battery-only-covers-what-you-mutate.md`.
   - **On resume, prior-session artifacts are UNVERIFIED — not work-in-progress to extend.** When `session-state.md` documents that the originating session's tool layer was contaminated/degraded (batched output, warnings prepended to `Read` results), the contamination taints every FILE that session wrote, not just the status claims it retracted ("applied"/"GREEN"). Re-derive each artifact from its authoritative source: read the real migration/file it claims to mirror and rewrite it as a verbatim delta (diff to prove byte-identity), then re-establish status from scratch (apply + verify the live DB; re-run RED→GREEN). For SQL, a misread `RETURNS` type is a free tripwire — `CREATE OR REPLACE` cannot change a function's return type, so any "applied GREEN" against a return-type-changed body is self-evidently false. **Why:** #4709 — 089 was authored against a misread of 088 (`RETURNS void` vs `integer`, dropped NULL-auth + 22023 gates); caught only by reading the real 088 body. See `knowledge-base/project/learnings/2026-06-01-resumed-session-artifacts-from-contaminated-tool-layer-are-unverified.md`.
   - **Counts written into the artifact (workflow header, script comment, AC expected-N) must be derived from the as-written file, not from plan-prose estimates.** Plan §Phase X says "~40 resources after expansion" → /work runs the grep → the actual count goes into the workflow header comment AND into AC4's expected N. Cheapest gate: after writing the artifact, re-run the canonical count command against the as-written file (`grep -cE '^[[:space:]]+-target=' <workflow>`, `wc -l <list>`, etc.) and copy the integer into every comment/AC reference. Plan-prose mental tallies drift by ±1-2 during expansion; multi-agent review reliably catches it (P3 polish) but the inline grep at write-time is free. **Why:** PR #4122 — workflow header carried "68 explicit targets" from plan §Phase 0.3 mental tally while `grep` returned 67; caught by `code-quality-analyst` + `pattern-recognition-specialist` at review. See `knowledge-base/project/learnings/best-practices/2026-05-20-plan-time-pr-vs-issue-disambiguation-and-self-derived-counts.md`.
   - **Re-running the count is necessary but NOT sufficient — publish the COMMAND next to the number, because a prose predicate does not pin a count.** Stating the filter ("N sites, where site = `<predicate>`") reads like rigour and is not: the prose leaves per-line-vs-per-match, unique-vs-all, marker position, and language-correct-markers free, each worth 10–30%. A number pinned to prose rots exactly like a citation pinned to a line number — both name a referent without fixing it — so the fix is the same: anchor on content (the command), not on a description. If the figure is load-bearing, also show the conclusion survives the plausible range. **Why:** #6517/PR #6527 — a reviewer *measured* and adopted "**403**, where site = a line in a code file bearing a comment marker AND citing `<path>.<src-ext>:<N>`" as canonical; at /work, **18 faithful readings of that exact prose returned 319–583 and never 403** (two independent resolvers also disagreed on the sibling counts). Shipped as `~360` + a `git grep … | wc -l`; the conclusion was invariant across the whole range (0.17–0.31%), which is *why* the false precision survived a 5-agent panel — nothing depended on it. See `knowledge-base/project/learnings/2026-07-16-advisory-first-precedent-is-a-claim-to-measure-and-a-coordinate-citation-carries-no-claim.md` Session Errors #3.
   - **Verify an AC by running its LITERAL command — a normalized variant verifies a different, weaker claim.** When self-checking acceptance criteria, it is natural to "helpfully" widen the plan's grep (add `-i`, alternate a second spelling, swap a space for `.`), and the widened form is a DIFFERENT predicate: it can pass while the AC's own bytes fail. The AC is the contract and the plan-quoted command is part of it, so paste it verbatim; if it fails, fix the artifact or amend the AC explicitly — never quietly satisfy a looser one. Same family as `cq-assert-anchor-not-bare-token` (anchor on what is actually asserted) and the plan-quoted-number rule above (re-derive, don't restate). **Why:** #6178/PR #6933 — AC9's `grep -c 'function discovery'` was self-reported satisfied after running `grep -c 'function.discovery\|FUNCTION-DISCOVERY'`; the literal command returned **0**, and a checked `tasks.md` line already claimed every AC baseline had been re-confirmed. See `knowledge-base/project/learnings/2026-07-25-i-added-the-field-that-closes-the-gap-and-nothing-read-it.md`.
   - **Discrete-enumeration re-lockstep (when applicable).** When the plan asserts paraphrased lockstep across discretely-enumerated documents — e.g., "files in lockstep at sections (a)-(N)", "all 7 entries match in both files", "list is (i) through (v) on both sides" — run a fresh `grep -cE '^- \*\*\([a-z]\)\*\* '` (or equivalent, using `[a-z]` NOT `[a-N]`) across each file BEFORE the first letter-inserting Edit. Counts AND letter-sets must match; anything else means the plan's lockstep claim is paraphrase-from-stale-read. PR #3755 (#3708): plan asserted DPD §(a)-(k) lockstep; canonical actually had §(l) DSAR; AC1 caught the §(l) collision but the cheaper gate is re-lockstep at /work-start. See `knowledge-base/project/learnings/2026-05-14-discrete-enumeration-relockstep-and-pr-introduced-asymmetry.md`.
   - **Sequential-section insertion anchor.** When inserting a new `### N.M`-numbered section, the Edit anchor MUST target the LAST `### N.M` block before the desired slot (not the lexically-adjacent one). Confirm with `grep -nE '^### [0-9]+\.[0-9]+ ' <file> | tail -3`; the new `M` must be greater than the picked anchor's `M`. PR #3755 (#3708): §5.10 was anchored on §5.9-Resend's block start and landed BEFORE §5.9. One-grep prevention. Same learning file.
   - **Write-boundary sentinel sweep (when applicable).** If the plan introduces a sentinel/guard that asserts a property at write sites (e.g., `assertWriteScope` for cross-tenant integrity, GDPR write-boundary checks), enumerate ALL write sites where the property applies — not just diff sites. Run `git grep -nE '\.from\("<table>"\)\.insert\(' <scope>` (or the equivalent for the boundary type) at Phase 0 and verify every match is sentinel-gated, then file follow-up tasks for any uncovered sites BEFORE entering Phase 1. See `knowledge-base/project/learnings/2026-05-12-type-widening-cascades-and-write-boundary-sentinels.md`; hard rule `hr-write-boundary-sentinel-sweep-all-write-sites`. **Why:** PR-A2 #3603 — sentinel placed at assistant-row write but not user-row write at `cc-dispatcher.ts:1008`; same service-role-bypass surface.
   - **Type-widening cross-consumer grep (when applicable).** When the PR widens a producer-side shared type whose payload crosses an `unknown`/`any`/jsonb boundary (compiler cannot enforce optionality at the consumer), `git grep -nE '<field-name-pattern>' apps/` across every consumer and verify each respects the new optionality. For `Message`-class fields the canonical grep is `git grep -nE '\bmessage\.usage\.(input_tokens|output_tokens|completed_actions)\b' apps/` (adapt per field family). See learning `2026-05-12-type-widening-cascades-and-write-boundary-sentinels.md`; hard rule `hr-type-widening-cross-consumer-grep`. **Why:** PR-A2 #3603.
   - **Id-shape/format-guard fixture blast-radius (when applicable).** When the PR adds a shape/format guard (UUID allowlist, regex, length check) to an id that is DB-sourced AND flows through a shared resolver (`workspacePathForWorkspaceId`, an active-workspace resolver, any `join(root, id)` path builder), the guard THROWS on every test fixture that fabricated that id with a short non-UUID literal. Before sizing the change as "1 file", grep the fixture surface — `git grep -nE '"(user-1|ws-[A-Za-z0-9]|owner-[A-Z]|[a-z]+-workspace)"' apps/web-platform/test/` — and trace which hits reach the guarded function; size the fixture sweep at plan time, not at GREEN. The fix is realistic UUID fixtures (`cq-test-fixtures-synthesized-only`), never a looser guard. **Why:** #5344 — a "1 source + 1 test file" estimate broke ~34 fixture files. See `knowledge-base/project/learnings/2026-06-15-id-shape-guard-test-fixture-blast-radius-and-syntactic-sast.md`.
   - **A PATH-shimmed fake that dispatches on `$1` only puts the fixture seam ABOVE the code under test — assert the argv the SUT must send.** When a suite stubs an external CLI (`docker`, `gh`, `psql`, `curl`), a stub that returns a fixture regardless of `--filter`/`--format`/subcommand cannot detect the SUT querying the WRONG thing, so the query shape ships unpinned: measured, switching a gate from `.NetworkSettings.Ports` to `.HostConfig.PortBindings` and corrupting its label selector (so it matched zero rows and passed forever) BOTH left a 12/12 suite green. Make the fake `exit 64` on a missing required flag, and key multi-entity output per id (`FIXTURE_<id>`) so an early-`break` is detectable — a fake that answers identically for every entity makes "one clean, one dirty" fixtures impossible. `.claude/hooks/stub-argv-fidelity.test.sh` codifies this class but scopes to `.claude/hooks/` with `STUB_CMDS="gh|jq|git"`, so a stub elsewhere is unguarded. **Why:** #7081 — 11 of 17 mutations survived. See `knowledge-base/project/learnings/2026-07-30-every-green-signal-i-had-certified-a-gate-with-six-fail-open-paths.md`. **The same seam also sits above every property the VENDOR validates, so an external-API client needs one verification on the REAL request path.** The rule above pins the argv the SUT sends; this is the half nothing local can reach. A fake that answers regardless of the request cannot observe request ENCODING, parameter FORMAT, header shape or auth form — and a mutation battery cannot either, because it perturbs the SUT and then scores it THROUGH that same fake, so every row is judged by an oracle blind to the class. Exercise the real path once, end to end, with the CLIENT's own argument construction; probing the endpoint with hand-written arguments probes the endpoint. Then teach the stub to reject what the vendor rejects, one exit code per contract violation. **Why:** #7706 — `to_iso` emitted a timestamp with no timezone designator, which the vendor answers with HTTP 400 on EVERY ref, window and source, so the tool could not complete one live query; green through 3 suites, 2 batteries reporting no survivors, 12 review agents, shellcheck and actionlint, and the plan's own live probes missed it because a hand-written curl carries the `Z` by reflex. See `knowledge-base/project/learnings/test-failures/2026-09-02-my-fake-curl-put-the-seam-above-everything-the-vendor-validates.md`.
   - **A finding names an INSTANCE; the fix is scoped to the CLASS — enumerate all sites {A,B,C} BEFORE writing it.** When a review finding says "X can be added at site A", the work is not "fix A". It is: name the property X violates, enumerate every site that can reach the same sink, THEN fix. Record the enumeration AND the command that produced it (`git grep -nE '<syntactic anchor>'`, a directory walk, the array's own definition) — a remembered list is a snapshot, and the next one-line edit invalidates it. **A mutation protocol validates a FIX; it never tells you the fix's POPULATION is right** — this is the trap, because the protocol feels like the rigorous part and it is orthogonal to scope. Two tells that the population is wrong: (a) the fix lives INSIDE an array/window/branch while the sink is reachable from outside it; (b) you can describe the fix as "at site A" rather than "at every site that reaches S". **Why:** the preflight Check 10 work (merged 2026-08-10) — the `/home` re-bind was fixed inside `BWRAP_ARGS` and validated FOUR ways; three more mount-injection entry points existed outside that array (`GIT_BIND`, `BWRAP_PROC`, the exec line), and three separate one-line edits each re-opened the operator's credential surface with the whole suite green, verified against live bwrap reaching the Doppler token, `~/.ssh` and the gh token store. Five review rounds each found a different instance of that one unperformed enumeration. See `knowledge-base/project/learnings/2026-08-10-a-guard-that-cannot-be-driven-red-is-vacuous-four-rounds-four-instances.md`. When the deliverable is itself a guard, the plan's `## Guard Contract` (plan Phase 2.12) already names the assembly — implement against THAT, not against the finding's example site.
   - **Sweep-class fixes use grep-enumerated work-lists, not intuited ones.** When a plan declares a multi-file sweep with a verification grep (e.g., AC5-style `git grep -nE '<pattern>' <scope> | grep -vE '<safe-form>' | wc -l = 0`), run the grep ONCE at Phase 0 to enumerate the authoritative work-list (capture it via `targets=$(mktemp)` and write the hits there — never a fixed `/tmp` name a sibling session would clobber), fix each line, then re-run the grep after each batch. The plan's narrative enumeration of "files X, Y, Z" is a starting hypothesis; the grep result is the work-list. Same applies to regex widenings: enumerate the configs/verbs/paths invoked by the IN-SCOPE runbooks, not the configs the incident occurred against — the incident is one data point; the trap-class config-set is the full union of every config the runbooks touch. **Why:** PR #4031 — initial sweep handled 9 named runbook hits but missed 2 buried in deeper sections of the same files; widened regex covered `(prd|prd_terraform|dev|ci)` per the plan's leak-footprint enumeration but missed `prd_orchestration` which 2 in-scope runbooks operate against. Pattern-recognition + security-sentinel caught both at multi-agent review. See `knowledge-base/project/learnings/best-practices/2026-05-18-sweep-class-fixes-grep-enumerated-not-intuited.md`.
   - **A plan-prescribed cross-file drift guard ("all N files reference all M tokens") must be verified against each file's ACTUAL token usage before writing the assertion.** A family of parsers is rarely homogeneous — a secondary member often shares only a subset (e.g. a wipe-gate that checks only `--postgres-uri` and never parses redis). Run `grep -cF -- "$tok" "$file"` for every (file, token) pair at Phase 0 and let the matrix define the per-file token set; asserting the full set on a file that uses a subset false-fails a correct codebase. The plan is authoritative for the guard's intent, never its exact token/file set (same class as `hr-when-a-plan-specifies-relative-paths-e-g`). Corollary: plan-quoted AC verify commands are preconditions to re-derive, not facts — a `git diff origin/main | grep -c 'ssh '` proxy false-positives on self-referential prose + branch-divergence in cited-but-unedited files; scope it to the changed code files. **Why:** #5553 — the drift-guard spec required 4 tokens on all 3 ExecStart parsers, but `inngest-wiped-volume-verify.sh` references only 2; scoped per-file at /work. See `knowledge-base/project/learnings/best-practices/2026-06-18-cross-file-drift-guard-verify-per-file-token-usage.md`.
   - **When you write a conditional gate in PROSE, every rung must end in an action and the safe branch must be reachable by READING, not by remembering.** Three checks, because the failure is silent: (a) a rung ending in a classification ("that is the uncertain case") prescribes nothing, so the reader falls through to the document's default — end every rung in an executable verb and say so in the ladder's preamble; (b) the fail-safe sentence's predicate must be the UNION of what each rung can leave undetermined, or it reads as not firing for the case that actually reached it; (c) state the condition FIRST at each prescription site — a relaxed imperative with a parenthetical pointer to the conditional several hundred lines away makes the safe outcome depend on the reader following a link they have no reason to follow. **Why:** #7352 — a two-rung check for whether a project has a CI-enforced gate answered YES at rung 1, undeterminable at rung 2, took no branch, and handed self-hosted users the relaxed path the design forbade reaching by assumption. See `knowledge-base/project/learnings/2026-08-12-my-ladder-rung-ended-in-a-label-so-it-fell-through-to-the-unsafe-branch.md`.
   - **A plan's own `## BLOCKING` section is a WORK-LIST, not context — check each finding off against the diff before review, and do not trust a subagent's summary for what that list contains.** Two failures compose here and both are cheap to prevent. (a) The findings a plan marks blocking are written before implementation and read during it, so they are exactly the items an implementer treats as already-absorbed background; grep the plan for its blocking identifiers at Phase 0 and record a disposition per identifier, because "I read that section" is not a disposition. (b) A planning subagent's Session Summary is a CLAIM ABOUT A FILE — verify every identifier it cites exists in the artifact, since a summary that invents or renames findings sends you to check things that do not exist while the real ones go unchecked. **Why:** #7569 — the plan named F14/F15/F16/F18 as blocking, the summary reported `F2`/`F4`/`F7` (none of which exist), and F15 — an unanchored `--grep` letting one row that merely QUOTED a marker suppress the alarm indefinitely — shipped unimplemented into review, which rediscovered it independently. See `knowledge-base/project/learnings/2026-08-17-an-empty-read-is-three-states-and-my-guard-shipped-two-fail-opens.md`.
   - **When a gate cannot use the obvious discriminator, the replacement is probably a PROXY — name the property before you write the predicate.** The shape recurs whenever a plan says "X cannot tell these apart" (an ack that sees identical counts, a status code shared by several outcomes, a timestamp that moves for two reasons): the fix reaches for a field that *correlates* with the hazard, and a proxy fails in both directions. It misses the hazard when the proxy reads clean for a legitimate reason, and it fires when the proxy is absent for a legitimate reason — the second landing on post-partial-failure states, which are exactly the states with no fixture. Write the property as one sentence first ("no two addresses at this name"), then check the predicate expresses THAT; and enumerate the system's states including after a half-completed operation, saying what the guard reports in each. A guard that blocks a state you would need to recover from is a defect even if nobody has reached it. **Why:** #7640 PR4b — see `knowledge-base/project/learnings/2026-09-03-my-guard-blocked-the-recovery-and-missed-the-hazard.md`.
   - **Architectural-fork decisions route to the CTO agent, NOT the operator (HARD GATE — both modes).** When mid-work you discover the plan's prescribed mechanism is structurally blocked or contradicted by the code (a plan-vs-codebase contradiction surfaced by tracing the ACTUAL producer) AND the resolution is an *engineering/architecture* decision with material trade-offs — schema/audit substrate, data model, technology choice, security model, or which load-bearing module to disturb — route the BINDING decision to the `soleur:engineering:cto` agent (`Agent` tool, `subagent_type: soleur:engineering:cto`). Do NOT surface it to the operator via `AskUserQuestion`: the operator is non-technical, and architecture is the CTO's call. Reserve operator escalation for *product / scope / preference* decisions (what to build, never how). Hand the CTO the discovered evidence (`file:line`), the candidate options with trade-offs, and the plan's `brand_survival_threshold`; then implement exactly what it returns and record the decision + rejected alternatives in an ADR (`/soleur:architecture`). This is a routing rule, not an approval gate — it fires in pipeline mode too. **Why:** #5325 — /work found the plan's `action_sends`-reuse mechanism (deepen P0-1) was structurally blocked (NOT NULL `message_id` FK with no agent-path message id; UI-only `scope_grants` creation); the substrate choice (dedicated WORM table vs gate-only vs full reuse) is an architecture decision that was first (wrongly) offered to the operator before being routed to the `cto` agent, which ruled. See `knowledge-base/project/learnings/workflow-patterns/2026-06-15-architectural-fork-decisions-route-to-cto-not-operator.md`.
   - **Legal/compliance-substantive decisions route to the CLO agent, NOT the operator (HARD GATE — both modes).** The exact sibling of the CTO rule above, for the legal domain. When mid-work or at review you hit a decision about *legal posture* — whether a published claim is adequately scoped, whether a lawful basis needs a balancing test or an Art. 30 entry, whether Art. 13/14 notice is adequate for third-party data subjects, whether a correction is a scoping clarification or a retraction, whether a notice and a bilateral instrument may say different things, or whether a determination condition can be discharged at all — route the BINDING decision to the `soleur:legal:clo` agent (`Agent` tool, `subagent_type: soleur:legal:clo`). Do NOT surface it via `AskUserQuestion`: the operator is non-technical, and legal posture is the CLO's call exactly as architecture is the CTO's. Hand the CLO the findings, the governing records (determination, Art. 30 / 30(2) registers, LIA, any instrument), and the binding constraints, and require **drafted replacement wording** back — not just a verdict; then implement exactly what it returns. Two boundaries: (a) **weight is not a routing signal** — a decision feeling consequential is the reason to route it to the CLO, never past it; (b) the genuine external escalation is a **qualified-counsel review**, which is a different act with a different cost, and it routes through the threshold catalog in `knowledge-base/legal/recommended-tools.md` plus a tracked issue — never as an inline operator question. Prompt the CLO with "do NOT use AskUserQuestion" (it defaults to orchestrator mode and would hang a headless run). **Why:** #7347 — a legal-corpus PR blocked on ten P1s surfaced three decisions (a 6(1)(f) limb with no balancing test, an Art. 14 route for third-party subjects, an annex/notice asymmetry on a transfer mechanism); all three were offered to the operator, who asked why the CLO was not taking ownership. See `knowledge-base/project/learnings/workflow-patterns/2026-08-09-legal-decisions-route-to-clo-not-operator.md`.
   - **Verify that every BINDING item LANDED — a prohibition sweep cannot detect an omission (HARD GATE).** When a plan, review, or amendment list marks items binding, the post-implementation check must assert each one **positively** (grep the artifact for the thing that had to appear), not merely assert that forbidden things are absent. The two sweeps feel interchangeable and are not: "don't touch X / don't assert Y / don't publish Z" can pass 6-for-6 while a required edit was silently dropped, because nothing in a negative predicate quantifies over the obligations. Build the check from the binding list itself — one `grep` per item, output the item id next to a hit/miss — and treat any miss as blocking. Applies with equal force when the implementation was delegated: the delegate's report names what it did, never what it forgot. **Why:** #7347 — a 6/6-green prohibition sweep (T&C untouched, §3.1(b)/(d) unasserted, "eleven processing activities" unchanged, no PR refs, pre-existing drift untouched, no forbidden term-classes) shipped a diff that had silently dropped two amendments recorded as binding by the PR's own plan review, plus a third closed-list fix applied in one document and not its sibling. Same learning file.
   - **A delegation brief that hands over CONTENT without PLACEMENT delegates the placement decision — and bound the delegate's read scope or it stalls.** Two halves of one lesson about brief shape, both measured on the same corpus in one session. (a) **Per-site, not per-string.** A brief supplying N fixed strings plus a list of target files leaves "which sentence goes in which section" to the delegate, and template-pasting is the predictable result — a section-scoped sentence lands in sections that are not in that scope, which on a published document is a false statement rather than a style nit. Label every block with target file + section + whether the mirror/sibling takes the same change; generate the site list FROM the binding-items list rather than restating it, or the two disagree and nothing compares them. (b) **Bound the read scope.** "Read all N target files, then edit" is the shape that hits the stream watchdog; scope the delegate to one document at a time, its own blocks only, with a commit after each, so a stall costs one unit rather than the run. Prefer **resume over respawn** when one does stall — a resume keeps the transcript. **Why:** #7347 — round 1 (ten strings, no placement) produced 10 P1s, 6-7 of them greps, and cost a four-agent panel plus a full re-implementation; round 2 (14 blocks, each labelled file + section + mirror) applied cleanly, and its only deviations were contract bugs rather than placement errors. Two subagents stalled on the wide-read shape. See `knowledge-base/project/learnings/2026-08-09-three-green-gates-and-the-published-page-was-still-wrong.md`.
   - **Emergent PRODUCT/SCOPE/PREFERENCE decisions classify via the taxonomy, and headless never pauses on them.** For a mid-work decision that is NOT an architecture fork (those route to the CTO agent, above) — dropping/deferring operator-requested scope, a user-visible behavior choice, a money/compliance call — classify it Mechanical / Taste(user-legible) / User-Challenge per [decision-principles.md](../brainstorm-techniques/references/decision-principles.md) (ADR-084). Mechanical + technical-taste: auto-decide silently. A user-legible Taste or a User-Challenge (both against the operator's stated direction) in a headless/one-shot run: keep the operator's stated direction as the default and **persist** it to `knowledge-base/project/specs/<branch>/decision-challenges.md` (append; alongside `session-state.md`) — never a mid-pipeline pause. `ship` Phase 6 renders that artifact into the PR body and files the `action-required` issue the operator actually sees. The one exception to no-pause: a security/feasibility regression halts terminally before merge (see the reference doc).
   - **A composite read fails as its WEAKEST arm, and a verdict must never rest on an instrument that was not shown to work.** When a query `UNION`s two sources (or a check ANDs two probes), verify each answers INDEPENDENTLY before relying on the pair: a `UNION ALL` over a healthy arm and an absent one fails entirely, so the read errors on every attempt regardless of the data — and if the loop only inspects results when `rc == 0`, "the read failed" becomes indistinguishable from "the read found nothing". The damage is downstream: the code then consults a CONTROL source, which is a DIFFERENT table whose health licenses nothing about yours, and emits a substantive verdict off a query that never ran. Track whether the read ever succeeded, report UNKNOWN when it did not, and carry the transport error so the next reader does not re-derive it. **Why:** #7855 — a round-trip probe's readback UNIONed an archive arm whose named collection does not exist for that source (669, measured), so it failed on every poll and the probe posted a public vendor data-loss accusation off it; the same PR's own header claimed the opposite behaviour. See `knowledge-base/project/learnings/security-issues/2026-09-06-the-file-i-added-to-fix-a-p1-shipped-a-p1.md`.
   - **Interactive mode only:** If anything is unclear or ambiguous, ask clarifying questions now. Get user approval to proceed. **Do not skip this** - better to ask questions now than build the wrong thing.
   - **Pipeline mode:** Skip clarifying questions and approval. Proceed directly to step 2.

2. **Setup Environment**

   First, check the current branch by running `git branch --show-current`. Then determine the default branch by running `git symbolic-ref refs/remotes/origin/HEAD` and extracting the branch name. If that fails, check whether `origin/main` exists (fallback to `master`).

   **If already on a feature branch** (not the default branch):
   - **Interactive mode only:** Ask: "Continue working on `[current_branch]`, or create a new branch?"
   - **Pipeline mode:** Continue on current branch without asking.
   - If continuing, proceed to step 3
   - If creating new, follow the worktree creation instructions below

   **If on the default branch**, you MUST create a worktree before proceeding. Never edit files on the default branch -- parallel agents cause silent merge conflicts, and this repo uses `core.bare=true` where `git pull` and `git checkout` are unavailable.

   Create a worktree for the new feature:

   ```bash
   SOLEUR_SKILL_NAME=work SOLEUR_EXPECTED_DURATION_MIN=240 \
     bash ${CLAUDE_PLUGIN_ROOT:-./plugins/soleur}/skills/git-worktree/scripts/worktree-manager.sh --yes create feature-branch-name
   ```

   Then `cd` into the worktree path printed by the script. The worktree manager handles bare-repo detection, branch creation from latest origin/main, .env copying, and dependency installation. The env vars wire a session lease so sibling cleanup-merged invocations refuse to reap this worktree.

   **Phase Exit (release lease).** At the end of the workflow — after `/soleur:ship` returns OR if you exit without shipping — release the lease so a sibling `cleanup-merged` can reap the worktree once it's actually merged:

   ```bash
   # Degrade open (#7409): releasing is advisory — an unreleased lease expires
   # on its own window — so a missing library must not fail the pipeline here.
   SS_LIB="${CLAUDE_PLUGIN_ROOT:-./plugins/soleur}/scripts/lib/session-state.sh"
   if [[ -r "$SS_LIB" ]]; then
     bash "$SS_LIB" release_lease "$(basename "$PWD")" || true
   else
     echo "SOLEUR_SESSION_STATE_UNAVAILABLE path=$SS_LIB reason=lease-not-released"
   fi
   ```

   The release is a no-op if the lease was already removed by the multi-signal trap (EXIT/INT/TERM/HUP fires on abnormal exit). Stale leases get swept after 24 hours regardless.

   Use a meaningful name based on the work (e.g., `feat-user-authentication`, `fix-email-validation`).

3. **Create Todo List (TDD-First Structure)**

   Structure tasks as RED/GREEN/REFACTOR units, not as "implement everything, then test":

   - For each feature requirement with Acceptance Criteria or testable behavior:
     - Create a **RED task**: "Write failing test for [feature]" — the test file with at least one failing test
     - Create a **GREEN task**: "Implement [feature] to pass tests" — blocked by its RED task
     - Group these as a TDD unit with `blockedBy` dependency (GREEN blocked by RED)
   - Infrastructure-only tasks (config files, CI, scaffolding, legal docs) are exempt from RED/GREEN pairing — create them as standalone tasks
   - Place a final test-and-lint task at the end, blocked by all other tasks. **If the project has no CI-enforced full-suite gate on the merge branch, make it "Run the full test suite and lint" — see **Full-suite fallback** in Phase 2 §9, and when in doubt this is the branch you take.** Otherwise "Run the touched-file suites and lint".
   - Keep tasks specific and completable

   **Anti-pattern to avoid:** Creating a task list like `[implement A, implement B, implement C, ..., write tests, lint]`. This structure guarantees TDD violation because the agent executes tasks in order. The correct structure is `[RED: test A, GREEN: implement A, RED: test B, GREEN: implement B, ..., lint]`.

   **Post-creation validation (HARD GATE):** After creating all tasks, scan the task list for any non-exempt implementation task (GREEN) that does NOT have a corresponding RED test task in its `blockedBy` list. If found, restructure the task list before proceeding. Do not start Phase 2 with an invalid task structure. **Why:** In PR #2428, the agent created flat tasks ("Fix X", "Write tests") and started implementation before tests — the user had to intervene and force a restructure. The anti-pattern instruction was not enough without a validation gate.

### Phase 2: Execute

**Output discipline (all tiers).** Long execution phases blow the response-token ceiling and truncate mid-pipeline, losing the thread. Keep inline output bounded: when a command's output is large (full diffs, build logs, test dumps), write it to a file and reference the path rather than pasting it — never echo a diff over ~200 lines inline (`d=$(mktemp -t <task>.XXXXXXXX.diff); git diff > "$d"; echo "DIFF=$d"` then summarize, citing the path). After each task or logical unit, emit a one-line `## Work Phase <N> complete` checkpoint marker so an interrupted run has a clear resume point. This complements hard rule `hr-never-run-commands-with-unbounded-output` (which forbids unbounded *commands*); this is about bounding your own *narration* of them.

1. **Execution Mode Selection** (HARD GATE — must complete before executing ANY task)

   **Do NOT execute any task before completing this analysis.** Analyze independence first, select the execution tier, then begin. Starting sequential execution "because the first tasks feel simple" is a workflow violation — it forfeits parallelization savings on the remaining tasks.

   Before starting the sequential task loop, check for parallelization opportunities:

   **Step 0: Tier 0 pre-check (Lifecycle Parallelism)**

   Read the plan. Apply a single judgment: "Does this plan have distinct code and test workstreams that can be assigned to separate agents with non-overlapping file scopes?"

   - If yes (interactive mode): offer Tier 0 to the user
   - If yes (pipeline mode): auto-select Tier 0 without prompting
   - If declined or ineligible: fall through to Step 1 below

   **Read `plugins/soleur/skills/work/references/work-lifecycle-parallel.md` now** for the full Tier 0 protocol (offer/auto-select, generate contract, spawn 2 agents, collect/commit, test-fix-loop, docs). If Tier 0 executes, proceed directly to Phase 3 after completing Step 06 of the protocol. If declined, fall through to Step 1.

   ---

   **Standing constraint for EVERY tier — a spawned agent never runs the gate.**
   Spawned agents run only the suites targeting the files they were given. **`SOLEUR_SUBAGENT=1` is
   a convention a lead MAY export before spawning — the harness does not set it** (measured
   2026-08-19 from inside two independent spawned agents: UNSET in both, and no repo-controlled
   spawn path exists to set it). They must not run [scripts/test-all.sh](../../../../scripts/test-all.sh),
   `apps/web-platform/infra/run-registered-suites.sh`, or any other full-gate runner: concurrent
   full-gate runs inflate each other's timings and corrupt the measurement. Measured 2026-08-11 —
   three agents running lints and suites at once turned an 860 s battery into 1675 s, and that
   1.9x figure was then nearly used as the suite's cost budget. The lead runs the gate ONCE,
   after collecting fan-out work. Two mechanical backstops exist, and neither depends on an agent
   volunteering anything: [scripts/test-all.sh](../../../../scripts/test-all.sh) exits 4 when it MEASURES a sibling full-gate
   run already in flight (#7553), and `tc_acquire`'s advisory lock (ADR-133) serialises whatever
   gets past that. The `SOLEUR_SUBAGENT=1` exit-4 path is real and reachable by anyone who exports
   it deliberately, but it is a convention rather than an enforced one — so the paragraph above IS
   agent discretion for the non-concurrent case, and is written as an instruction, not a claim
   about the harness.

   **Step 1: Analyze independence**

   Read the TaskList. Identify tasks that have no `blockedBy` dependencies and reference
   different files or modules (no obvious file overlap). Count the independent tasks.

   If fewer than 3 independent tasks exist, skip to **Tier C: Sequential** below.

   If 3+ independent tasks exist, proceed through the tiers in order (A, then B, then C).
   Each tier either executes or falls through to the next.

   ---

   **Pipeline mode override:** If running in pipeline mode (plan file argument detected in Phase 1), auto-select Tier 0 if eligible (Step 0 above). If Tier 0 is ineligible, skip Tier A entirely and auto-accept Tier B without prompting. Do not present "Run as Agent Team?" or "Run in parallel?" questions -- proceed directly to Step B2 of the Subagent Fan-Out protocol if 3+ independent tasks exist, otherwise fall through to Tier C.

   ---

   **Tier A: Agent Teams** (highest capability, ~7x token cost)

   **Read `plugins/soleur/skills/work/references/work-agent-teams.md` now** for the full Agent Teams protocol (offer, activate, spawn teammates, monitor/commit/shutdown). If declined or failed, fall through to Tier B.

   ---

   **Tier B: Subagent Fan-Out** (fire-and-gather, moderate cost)

   **Read `plugins/soleur/skills/work/references/work-subagent-fanout.md` now** for the full Subagent Fan-Out protocol (offer, group/spawn, collect/integrate). If declined **or failed**, fall through to Tier C.

   The `or failed` is load-bearing and was missing: Tier 0 and Tier A both read "If
   declined **or failed**, fall through", so a spawn FAILURE at those tiers has a defined
   next step. Tier B said only "If declined" — leaving a spawn failure (agent tool
   withheld, `529 Overloaded`, headless run with no agent surface) with no prescribed
   behavior at the LAST tier that can degrade. Tier C is always available because it is
   sequential execution in the main context, so the fall-through costs nothing and closes
   the gap. When it fires, say so: "Tier B fan-out unavailable (<reason>) — executing
   sequentially" rather than silently running Tier C as though it were the chosen tier.

   ---

   **Tier C: Sequential** (default)

   Proceed to the task execution loop below.

2. **Task Execution Loop**

   **Design Artifact Gate (before first UI task):** If `DESIGN_ARTIFACTS` was set in Phase 0.5, spawn the `ux-design-lead` agent with the artifact paths and ask it to produce an **implementation brief** (see ux-design-lead "Wireframe-to-Implementation Handoff" workflow). The brief is a structured description of every section, its content, and its layout — this becomes the binding input for all UI tasks. Do not write any markup until the brief is received.

   **UX artifact commit checkpoint (after Design Artifact Gate):** After the implementation brief is received, commit before proceeding to UI tasks:

   1. Run `git status --short` to discover the implementation brief and any generated design files
   2. Stage output files: `git add <discovered files>`
   3. Commit: `git commit -m "wip: UX implementation brief for <feature-name>"`

   This checkpoint ensures the implementation brief survives session crashes.

   For each task in priority order:

   ```text
   while (tasks remain):
     - Mark task as in_progress in TodoWrite
     - Read any referenced files from the plan
     - If task creates UI/pages: verify implementation brief exists (HARD GATE)
     - TDD GATE: (see below)
     - Look for similar patterns in codebase
     - RED: Write failing test(s) for this task's acceptance criteria
     - GREEN: Write minimum code to make the test(s) pass
     - REFACTOR: Improve code while keeping tests green
     - Run the touched-file suites after changes — **full suite instead if the project has no CI-enforced merge gate; see **Full-suite fallback** in Phase 2 §9**
     - Mark task as completed in TodoWrite
     - Mark off the corresponding checkbox in the plan file ([ ] → [x])
     - Evaluate for incremental commit (see below)
   ```

   **No mid-plan pause gates (HARD GATE).** A multi-phase plan
   (`tasks.md` Phase 0 through Phase N) is a SINGLE execution unit.
   Do NOT insert "Pause for review or continue?" prompts between
   phases. Do NOT end a turn after one phase commits with "Continue
   into Phase N+1 next turn?". The skill's Phase 4 handoff is the
   only sanctioned stopping point — until then, chain straight
   through every phase the plan defines, including phases the plan
   labels "Pre-merge verification" or "Post-merge (operator)" if
   they're automatable per the next gate. **Why:** the founder is a
   solo operator; every "continue or pause?" is a context switch
   that defeats the entire point of a multi-phase plan. Pipeline
   mode (file-path arg in Phase 1) means pipeline mode for the WHOLE
   plan, not per-phase.

   **Operator-step automation gate (HARD GATE).** Before treating
   any task in `tasks.md` as "operator-driven" (apply migration,
   verify pg_cron, verify Storage bucket, run end-to-end smoke,
   `gh pr ready`, `gh pr merge --auto`), check whether it is
   automatable via a loaded MCP server or CLI:

   - Supabase migrations + `cron.job` queries + Storage bucket
     existence + RLS spot-checks → `mcp__plugin_supabase_supabase__*`
     **with Doppler `DATABASE_URL_POOLER` fallback when MCP is
     unavailable** — see "Supabase fallback chain" below. When a
     migration needs a SECURITY DEFINER RPC (e.g. to bypass an RLS /
     column-grant restriction), start from
     [`sql-security-definer-rpc-scaffold.sql`](./references/sql-security-definer-rpc-scaffold.sql)
     — it encodes the `search_path` pin + 4-role REVOKE + `auth.uid()`
     authorization pin that `test/migration-rpc-grants.test.ts` enforces.
   - `gh pr ready` / `gh pr merge --squash --auto` / `gh issue close`
     → Bash via `gh` CLI
   - End-to-end UI flow → Playwright MCP (`mcp__playwright__*`)
   - Cloudflare DNS / WAF / Workers → `mcp__plugin_soleur_cloudflare__*`
   - Live Stripe state → `mcp__plugin_soleur_stripe__*`

   If automatable, EXECUTE it inline as part of the work pipeline —
   never list it back to the operator. The /ship skill already
   handles `gh pr ready` + auto-merge + migration verification (see
   `plugins/soleur/skills/ship/SKILL.md`); chain to `/soleur:ship`
   at Phase 4 and let it run. For migration **apply** to dev (vs
   verify), invoke `mcp__plugin_supabase_supabase__apply_migration`
   inline at the phase where the migration lands, not as a
   post-merge todo. **Why:** see ship/SKILL.md:1027 ("Every 'please
   run this manually' is a context switch") and ship/SKILL.md:1177
   (PR #1375 — migration verification was left as a manual
   "post-merge todo" instead of being executed; deployed code
   expected the new schema and broke). Same class as the
   Playwright-first audit in Phase 4: if a tool exists, use it.

   **Supabase fallback chain (when MCP OAuth fails).** The Supabase
   MCP OAuth flow at `https://api.supabase.com/v1/oauth/authorize`
   intermittently rejects valid URLs at the dashboard `auth_id`
   handoff (cause: external — Supabase-side). When that happens, do
   NOT fall back to "paste this SQL into the dashboard SQL editor"
   handoff — that's a manual-step rationalisation that violates
   `hr-never-label-any-step-as-manual-without`. Instead walk down the
   `hr-exhaust-all-automated-options-before` priority chain:
   (1) Doppler `DATABASE_URL_POOLER` — already provisioned for every
   env; the migration apply path. (2) Verify the project ref in the
   URL matches the plan's stated dev/prd refs — Doppler is the
   source of truth (plan-quoted project refs are preconditions to
   verify, never facts; the plan can drift). (3) Rewrite the URL's
   port `:6543` → `:5432` so the pooler runs in session mode (multi-
   statement DDL works; transaction mode rejects with SQLSTATE 42601
   "cannot insert multiple commands into a prepared statement").
   (4) Apply via `pg` (node-pg, bun-installed in `/tmp` if missing)
   wrapped in `BEGIN; <migration>; COMMIT;`. The direct DB host
   `db.<ref>.supabase.co:5432` is IPv6-only and typically
   unreachable from operator/CI networks; the pooler is IPv4.
   (5) Post-apply, verify schema via the same connection — RLS
   enabled, policy_count, trigger names, RPC signatures + SECURITY
   DEFINER flag, UNIQUE constraints. Write the verification artifact
   to `knowledge-base/project/specs/feat-<name>/migration-checklist.md`.
   **Why:** PR #3853 / #3205 — Supabase MCP OAuth was rejecting URLs
   at the auth_id handoff; the agent first proposed "paste SQL into
   dashboard" (manual-step violation), then pivoted to Playwright-
   first audit on dashboard navigation (correct), then discovered
   Doppler had the working `DATABASE_URL_POOLER` and applied via
   pg directly — the path it should have taken at step 1.
   **Session stickiness:** once the MCP OAuth handoff has failed even
   once in the current session, treat Doppler `DATABASE_URL_POOLER` as
   the default for ALL subsequent Supabase operations this session — do
   not re-attempt the OAuth flow per-operation. Re-probing a known-flaky
   external auth each time is the wasted-cycle trap; the fallback is not
   slower once you are already authenticated to Doppler.

   **Pre-apply collision check (always, even on first attempt).**
   Before invoking pg apply (or `supabase migration up`) against any
   shared env, run `git fetch origin main && git ls-tree origin/main
   -- apps/web-platform/supabase/migrations/ | awk '{print $4}' |
   grep -oE '^[0-9]{3}_[^.]+' | sort -u`. For each LOCAL migration
   file the branch introduces, assert no DIFFERENT filename with the
   same 3-digit prefix exists in that list. A collision means a
   sibling PR is landing the same number window; renumber FIRST,
   then apply under the final filename. **Why:** PR #4225 — applied
   053–057 in the morning; PR #4251 landed `054_schema_migrations_
   content_sha.sql` 10 hours later and main's CI drift probe flagged
   the entire branch; the recovery (renumber 054→058, 055→059, 056→060,
   057→061 + reconcile `public._schema_migrations` on both dev + prd
   via `git hash-object` content_sha) took ~30 min and could have been
   zero-cost if the operator had grepped origin/main first.

   **The collision window extends through `/ship`, not just work-time.** This
   check at work-start is necessary but NOT sufficient: a sibling migration can
   land on main DURING the (often 30–90 min) ship phase — especially under a
   fast-moving-main burst where `/ship` Phase 7 performs repeated `git merge
   origin/main` auto-syncs on `OPEN BEHIND`. Each sync that pulls in a sibling
   `supabase/migrations/NNN_*.sql` sharing your prefix is a silent collision the
   BEHIND loop pushes straight to CI (where the migration drift/shape gate fails,
   ~16 min later). After ANY ship-time sync whose merge output lists
   `supabase/migrations/`, re-run the prefix check above and renumber-during-ship
   (`git mv` both up/down + update every in-repo reference: migration headers,
   code comments, plan/tasks/learning) BEFORE the next push. **Why:** PR #5760 —
   `114_disk_io_top_wal_statements` (a #5739-sibling) landed mid-ship; my
   `114_prune_cron_job_run_details` collided and surfaced only at CI after ~6
   auto-syncs; recovery was a renumber to 115. See
   `knowledge-base/project/learnings/workflow-patterns/2026-06-30-migration-number-collision-mid-pipeline.md`.

   **The collision set includes the shared DEV DATABASE, not just `origin/main`.** The
   check above greps `origin/main`, and a sibling branch's migration is invisible there
   until it merges — but it is fully visible in dev's `_schema_migrations`, because the
   sibling session APPLIED it. Query all three sources and take the union: `origin/main`,
   every worktree's `supabase/migrations/`, and
   `SELECT filename FROM public._schema_migrations ORDER BY filename DESC LIMIT 5` over
   Doppler `DATABASE_URL_POOLER`. **Why:** #7829 — `136` was free on `origin/main` and
   already applied to dev by a sibling; the collision surfaced only when a live probe for
   an unrelated question happened to list the applied set, after the number had been
   written into a function body (which Postgres stores in `prosrc`), an Article 30
   register cell, and 38 prose sites.

   **Tracking row in the SAME transaction as the migration body.**
   The project's canonical `apps/web-platform/scripts/run-migrations.sh`
   writes `INSERT INTO public._schema_migrations (filename, content_sha)
   VALUES ('<basename>', '<git-hash-object>')` in the same transaction
   as the migration SQL. The Doppler+pg fallback MUST mirror this —
   bare `BEGIN; <migration>; COMMIT;` produces a phantom-applied state
   where the schema reflects the migration but `_schema_migrations`
   does not, and the next deploy attempts re-apply (failing on
   non-idempotent statements like `CREATE TRIGGER`). The reconciliation
   pattern (UPSERT with `ON CONFLICT (filename) DO UPDATE SET
   content_sha = EXCLUDED.content_sha`) is the recovery shape — but
   doing it inline is cheaper.

   **PostgREST schema cache reload via session-mode pooler does NOT
   work.** `NOTIFY pgrst, 'reload schema'` over a `:5432` pooler
   connection does not reach PostgREST's `LISTEN` (PgBouncer
   multiplexes; LISTEN/NOTIFY channel scope is bound to backend
   process identity, not session). 90 attempts over 5 minutes
   returned `PGRST205`. After a direct-pg apply: either wait for the
   natural ~10-min schema poll cycle, OR use the Supabase Management
   API to restart PostgREST. The direct DB host
   (`db.<ref>.supabase.co:5432`) is IPv6-only and typically
   unreachable from operator networks, so the canonical "NOTIFY via
   direct connection" workaround documented upstream isn't available.

   **Storage-bucket migrations: `down.sql` cannot DELETE storage tables;
   column-takeover proof is permissive-vs-restrictive, not name-count.**
   Supabase installs a platform `BEFORE DELETE` trigger (`protect_objects_delete`
   → `storage.protect_delete()`) that blocks direct `DELETE FROM storage.objects`
   AND `storage.buckets` ("Direct deletion from storage tables is not allowed").
   So a bucket migration's `down.sql` reverts only SQL-droppable objects
   (policies → function → column) — NOT the bucket/objects (Storage-API/operator
   teardown; 019/042 precedent ship none; 071's `DELETE FROM storage.buckets` is
   a dormant bug). Runtime cleanup uses `service.storage.from(b).remove([...])`
   (allowed). And when verifying "no client can write column X" (read-proxy
   trust), assert no **PERMISSIVE** INSERT/UPDATE/DELETE/ALL policy (a
   RESTRICTIVE `FOR ALL` like `workspaces_jti_not_denied` only denies, never
   grants) + a behavioral authenticated `UPDATE` affecting **0 rows**. The
   pooler also presents a self-signed CA chain → transient node-pg verify
   scripts use `ssl:{rejectUnauthorized:false}` (dev-only, mirrors
   run-migrations.sh `sslmode=require`; no committed code disables TLS verify).
   See `knowledge-base/project/learnings/2026-06-04-supabase-bucket-migration-down-and-rls-takeover-proof.md` (#4916).

   **TDD Gate (HARD GATE):** Before writing ANY implementation code for a task, determine if the task has testable behavior:

   Emit rule-application telemetry (records that the TDD gate was reached — see AGENTS.md `cq-write-failing-tests-before`):

   ```bash
   echo 'SOLEUR_RULE_APPLIED rule=cq-write-failing-tests-before note=Write failing tests BEFORE implementation code whe'
   ```

   1. **Check:** Does the plan have a "Test Scenarios" or "Acceptance Criteria" section that covers this task? If yes, this task requires test-first.
   2. **Exempt:** Infrastructure-only tasks (config files, CI workflows, scaffolding directories, dependency installs) are exempt. If the task only creates/modifies config, it skips to Infrastructure Validation below.
   3. **Enforce:** For non-exempt tasks, write the failing test file FIRST. The test must:
      - Import the component/function/module that will be created (the import will fail — that is correct)
      - Assert the specific behavior from the acceptance criteria
      - Be runnable via the project's test command (even if it fails due to missing implementation)
   4. **Verify RED:** Run the test. It must fail (missing module, assertion failure, etc.). If it passes, the test is not testing new behavior — rewrite it. **For gating/sequencing primitives (semaphores, locks, queues, ordering guarantees), the test must distinguish gate-absent from gate-present: add an intermediate-state assertion that would fail without the primitive (e.g., `count === 2` while two slots are held) in addition to the final-state assertion. A test that passes identically with and without the primitive isn't testing the primitive.** See `knowledge-base/project/learnings/test-failures/2026-04-18-red-verification-must-distinguish-gated-from-ungated.md`. **Test-environment fidelity:** if the SUT's buggy code lives behind a guard (`if [[ -d "$X" ]]`, `if (cache.has(key))`, etc.), the harness MUST seed the precondition the guard requires — otherwise both buggy and fixed paths short-circuit identically and any negative-space assertion passes vacuously. See `knowledge-base/project/learnings/test-failures/2026-04-22-red-test-must-simulate-suts-preconditions.md`. **Early-exit shadowing:** if the SUT has a guarded fast path (substring strip like `replaceAll(arg, "")`, cache-hit, env-flag short-circuit) that handles a superset of inputs the slow path under test handles, RED inputs MUST choose identities ONLY the slow path can produce. Sharing a fixture across the fast/slow boundary lets the fast path scrub first and the regex/branch under test never fires — the assertion passes without testing the fix. Add an invariant guard test asserting the fast/slow fixtures do not collide. See `knowledge-base/project/learnings/2026-05-04-vacuous-red-via-shared-fixture-and-toolchain-pinning.md`. **In-component state machines (RTL):** when the gate-under-test is component-local state (`useState`/`useRef`/`useReducer`), drive the SUT through state transitions with `result.rerender(<C />)` — never `unmount()` + fresh `render()`. Remount resets the in-component bookkeeping that IS the gate, producing vacuous green. See `knowledge-base/project/learnings/test-failures/2026-05-11-rerender-not-remount-for-in-component-state-machine-tests.md`. **Laundered-target resolvability (normalizer/strip/prefix-mangle security fixes):** a regression guard for an anchored-strip / path-canonicalization / allowlist-key fix is vacuous unless the fixture makes the LAUNDERED (mis-normalized) target resolvable to an observable effect — if the downstream gate rejects it for an *unrelated* reason (nonexistent skill/row/file), the test passes identically with and without the fix. Litmus: under the buggy impl, does this input produce a DIFFERENT output than under the correct one? See `knowledge-base/project/learnings/test-failures/2026-07-05-security-fix-regression-guard-must-make-the-laundered-target-resolvable.md`. **Fixture-space cardinality (ask this per contract sentence, and note that your own passing mutation battery cannot answer it):** for each property the test claims, name the SET it quantifies over and count how many distinct members the FIXTURE instantiates — one member is a sample, not a proof, and code-mutation coverage does not detect a fixture-space gap. Three shapes recur: (a) a **temporal** contract (a wait/retry/debounce/convergence) sampled only by STATIC fixtures probes t=0 and t=∞ but never the transition — the case the guard exists for — so deleting the loop's `break` stays green; drive it with a stateful stub that changes on the Nth invocation and assert the success arm was reached VIA the loop; (b) a **bidirectional** guard (`-w`, an ordering, a comparison) must be fixtured in the direction where the weaker implementation gives a FALSE POSITIVE, not the direction that fails either way; (c) a stub that ignores `argv`, or a `sleep`/clock stubbed to a no-op, silently voids the call-shape and budget contracts — validate `"$*"` in the stub and COUNT the stubbed calls against the design's bound. **Why:** #6441 — a 7-mutation battery reported 7/7 RED while nine unimagined mutations (loop-`break` deleted, `grep -qwF`→`-qF`, `addr show`→`link show`, bound 30→1, `sleep 2`→`600`) all survived. See `knowledge-base/project/learnings/2026-07-19-my-own-mutation-battery-was-the-false-confidence.md`. **A SURVIVING mutant is a result, and it has exactly two readings — label which:** either the fixtures do not exercise the property (fix the FIXTURES, not the guard) or the mutant is EQUIVALENT (prove no verdict changes, and record that it survives). Leaving it unlabelled is how a guard that is merely adjacent to a working one ships. Measured: two anchoring fixes survived their own mutations because an earlier `action=` split already caught every fixtured row, and both were reachable in production once the fixture used a field whose sanitiser PERMITS the colliding literal; a third survived and was correctly left alone as equivalent. **Why:** #7220 — see `knowledge-base/project/learnings/2026-08-05-my-anchoring-fix-survived-its-own-mutation-and-three-gates-were-green-elsewhere.md`. **A DISJUNCTION needs one fixture per disjunct, ALONE — and when the operands come from `@tsv`, check the parse before the logic.** A fixture that satisfies every arm of an `||` at once proves the set is non-empty and nothing else: dropping either arm keeps the suite green, so a load-bearing signal and a dead one are indistinguishable. The sharpest instance is not a missing fixture but a PARSE bug the all-arms fixture hides: tab is IFS-**whitespace**, so `while IFS=$'\t' read -r a b c` COLLAPSES runs of tabs and drops empty middle fields, and `@tsv` emits an empty field for an absent key — so `digest<TAB><TAB>unknown` parses as `b="unknown", c=""`, shifting every later field one position left. The arm reading `c` is then unreachable in exactly the case where it is the only signal. Sentinel every optional `@tsv` field (`(.x // "-")`) so no field is ever empty, and fixture each disjunct in isolation. **Why:** #7378/PR #7379 — the guard written to stop `crane validate` gunzipping an in-toto attestation layer sent that layer to `crane validate` whenever the annotation was absent, while emitting a message asserting that was impossible; five further guards in the same PR (a `> 0` count that could not see partial enumeration, an untested `ref_repo`, two `verify_die` arms with no failing fixture, and the whole new block behind no assertion floor) were vacuous the same way. See `knowledge-base/project/learnings/2026-08-10-the-guard-i-wrote-to-close-7378-reopened-it-one-field-to-the-left.md`. **A numeric predicate that is a LOWER BOUND ONLY cannot distinguish a measurement from a CONSTANT — bound it on both sides, against a value the artifact itself reports.** `value >= N` over any measured quantity (an elapsed, a count, a byte size, a row total) passes identically against a hardcoded literal, so a suite of them tests that something happened and never that the something is a function of its inputs. Litmus per assertion: **name a constant that satisfies it** — if you can, it is a shape check wearing a measurement's clothes. Prefer a ceiling derived from the output itself (the banner prints its own budget; the report prints its own input size) over a second magic number, and place the two bounds so no single value can satisfy both arms at once. **Why:** #7484 — a PR whose entire thesis was "report the measured wait instead of asserting one" had all three elapsed predicates as one-sided floors and the discriminating premise ("a free lock reads single-digit ms") sitting in a PROSE COMMENT; `printf '%sms' 1500` passed 95/0, and dropping the `/1000` printed a claimed 33-minute wait against a 2-second budget, also green. Its 7-row mutation battery could not see either: every row perturbed the SUT toward *small or absent*, so a plausible constant was outside the axis it edited. See `knowledge-base/project/learnings/2026-08-13-a-lower-bound-cannot-tell-a-measurement-from-a-constant.md`.
   5. **Only then:** Write the minimum implementation to make the test pass (GREEN).
   6. **Refactor:** Improve code while keeping tests green.

   Skipping this gate — writing implementation before tests — is a workflow violation equivalent to committing directly to main. The rationalization "this is simple enough to not need test-first" is exactly the reasoning TDD is designed to prevent.

   - **Three shell-authoring traps that fail SILENTLY and cost a full debug cycle each.** (a) **Tab is IFS whitespace**, so `while IFS=$'\t' read -r a b c` collapses a LEADING or empty field — a sentinel emitted as `\t__MARK__\t12` arrives as `a=__MARK__ b=12 c=`, and the count lands in the wrong variable. Put a sentinel in a non-empty FIRST field. (b) **An apostrophe anywhere inside `awk '…'` closes the block** — including in a prose comment written to explain the code ("split on the shell's own separators"), which surfaces as a bash syntax error pointing at an unrelated line; grep the block for `'` after editing. (c) **A `case` glob's `*` matches `/` but the pattern is still ANCHORED**: a root-anchored `<dir>/*.sh` pattern covers arbitrary depth BELOW that directory and matches no same-named directory elsewhere in the tree, so a two-pattern list read as "covers everything" while missing ~185 tracked scripts. When claiming a pattern's reach, name the axis and test a member on the OTHER one. **Why:** #7084/PR #7566 — (a) reported "parsed 0 jobs" and would have been "fixed" by lowering the floor; (c) shipped in a commit message asserting the opposite. See `knowledge-base/project/learnings/security-issues/2026-08-16-a-guard-asserting-absence-was-backwards-and-my-floors-counted-the-wrong-thing.md`.
   - **A gate placed AFTER a cheap pre-filter is only reachable for inputs the filter admits — give every gate a counter, or its removal has no observable.** When a walk is structured as cheap-filter-then-expensive-check, ask per gate *which inputs actually reach this line*. Measured: an own-uid gate inside the classifier counted ZERO against a real `/proc`, because a foreign process's `/proc/<pid>/fd` is mode 500 so the `fd/255` pre-filter rejected it first — the gate could not fire and deleting it changed nothing, which is a guard that no test can falsify. Moving it ahead of the pre-filter as a fork-free `[[ -O ]]` took it from 0 to 390 of 627. Same session, same shape one level out: `(( x < $SEAM ))` on a NON-numeric seam does not abort — it errors and evaluates the `if` as FALSE, silently skipping the branch, so an unvalidated `MIN_AGE_S=10m` signalled a two-second-old process while every counter reported a clean walk. Validate numeric seams as `^[0-9]+$` at the boundary. **Why:** #7537 — see `knowledge-base/project/learnings/2026-08-20-my-test-suite-reaped-the-live-box-and-every-gate-was-green.md`.
   - When adding MCP tools to an existing registration block in agent-runner.ts, verify each tool's prerequisites are independent of the block's guard condition. Write a test that validates the new tool works WITHOUT the existing block's prerequisites (e.g., Plausible tools work without GitHub installation).

   - When adding route handler tests that require `vi.mock()`, create a separate test file from existing unit tests that import the real module. Vitest hoists all `vi.mock()` calls to the top of the file, clobbering real imports for the entire file regardless of describe block scope.
   - When creating test files with `vi.mock()` factories that reference shared variables, use `vi.hoisted()` from the start -- vitest hoists `vi.mock` to the top of the file before `const`/`let` declarations execute.
   - When a NEW shared module will be imported (directly or transitively) by files that already have test suites mocking a node builtin (`vi.mock("node:child_process")` with spawn-only factories is the common case), do NOT destructure that builtin's exports at module top level (`promisify(execFile)` crashes EVERY sibling suite at import). Lazy-import inside the function that uses it. **Why:** #5091 — `_cron-safe-commit.ts`'s top-level `promisify(execFile)` broke 28 cron-bug-fixer tests at module load. See `knowledge-base/project/learnings/2026-06-10-bot-cron-safe-commit-substrate-symlink-removal.md`.
   - Before adding a `vi.mock("<module>")` to an EXISTING test file, grep the file for a pre-existing mock of the same module (`grep -n 'vi.mock' <file> | grep <module-basename>`) and wire your spy into that block instead. Vitest registers one mock per resolved module per file; a duplicate does not error — it silently picks one, and your hoisted spy captures zero calls. **Why:** PR #5090 — a new egress-posture-log spy was added as a second `@/server/logger` mock while the factory test already mocked it ~150 lines down; cost 4 debug cycles. See `knowledge-base/project/learnings/bug-fixes/2026-06-10-sandbox-network-plane-not-token-plane-error-shape-triage.md`.
   - A WHOLESALE `vi.mock("<module>", () => ({...}))` replaces the ENTIRE module, dropping every export the factory omits — so a module with multiple named exports (`@/server/logger` exposes `default` AND `createChildLogger`; observability, db-helper, supabase wrappers similarly) breaks any REAL sibling in the SUT's import graph that consumes a different export. Default to `vi.mock(spec, async (importOriginal) => ({ ...await importOriginal(), <override> }))`; reserve wholesale factories for modules you fully replace — or skip the mock entirely if the thing under test already mocks the export's consumer. Detection is free: run the FULL test file (never `-t "<new test>"` alone) — the RED run surfaces unexpected sibling failures naming the missing export. **Why:** #5689 — a wholesale `@/server/logger` mock dropped `createChildLogger` (used by `probe-octokit.ts` via `_cron-shared`), breaking 10 unrelated arm-2 tests. See `knowledge-base/project/learnings/test-failures/2026-06-29-wholesale-module-mock-drops-named-exports-needed-by-transitive-siblings.md`.
   - A partial `vi.mock(spec, async (importOriginal) => ({ ...actual, B: spy }))` override only changes what **importERS** see — it does NOT intercept a call made by a REAL sibling function `A` (kept via `...actual`) to `B` **within the same module**; `A` references `B` through the module's internal lexical binding, not the export object. Symptom: the spy reports 0 calls even though the path clearly runs `B`. To observe `B` while keeping `A` real, mock the deeper boundary `B` itself crosses (`fetch`, the DB client, `child_process`) and assert there. Decision rule: **mock the seam the unit under test does not own.** **Why:** #5728 — overriding `postSentryHeartbeat` didn't intercept the real `finalizeOutputAwareHeartbeat`'s internal call; fixed by keeping it real + stubbing `fetch` + asserting the POST URL. See `knowledge-base/project/learnings/test-failures/2026-06-30-partial-module-mock-does-not-intercept-intra-module-calls.md`.
   - When mocking `child_process.spawn`, `fetch`, or any constructor returning an event-emitter-like object, use `mockImplementation(() => factory(...))` rather than `mockReturnValue(factory(...))`. `mockReturnValue` evaluates the factory eagerly at test-setup time; any `queueMicrotask` / `setTimeout` / `setImmediate` scheduled inside the factory fires BEFORE the SUT attaches its listeners, producing empty event data or an "uncaught error" test timeout. See `knowledge-base/project/learnings/test-failures/2026-04-17-vitest-mockReturnValue-eager-factory-async-event-race.md`.
   - A `vi.fn(() => value)` mock declared with a ZERO-parameter implementation cannot be invoked via a `(...args) => mock(...args)` forwarder (the standard `vi.mock` factory shape) — `tsc` rejects the spread with TS2556 "A spread argument must either have a tuple type or be passed to a rest parameter", even though the vitest run is GREEN (vitest type-checks test files lazily). Give the impl a rest param: `vi.fn((..._args: unknown[]) => value)`, matching sibling `vi.fn()` mocks. Only a standalone `./node_modules/.bin/tsc --noEmit` catches it. **Why:** #5817 — `execFileSyncMock = vi.fn(() => Buffer.from(""))` passed 36/36 tests but failed tsc. See `knowledge-base/project/learnings/test-failures/2026-07-01-vitest-zero-arg-mock-cannot-take-spread-suite-green-tsc-red.md`.
   - When the SUT `await`s something (`mkdtemp`, a config read, a lock) BEFORE it calls the mocked `spawn`/`fetch` and attaches listeners, emit the child's `close`/`error`/`data` events from INSIDE the spawn mock (`spawnMock.mockImplementation(() => { queueMicrotask(emit); return child; })`), NOT from a sibling top-level `queueMicrotask` in the test body. A test-level microtask scheduled right after calling the SUT fires during the pre-spawn `await` gap — before listeners exist — so the settle-once promise never resolves and the test times out (16s). The emit must be scheduled relative to when `spawn` is actually invoked. **Why:** PR #4970 — adding `await mkdtemp` before `spawn` in `c4-render.ts` timed out 6 tests until the emit moved inside the mock; see `knowledge-base/project/learnings/best-practices/2026-06-05-external-cli-exit-0-is-not-proof-validate-the-artifact.md`.
   - When using `vi.doMock("specifier", () => { throw new Error("X") })` to simulate a module-init failure, do NOT assert on the inner error message via the SUT's caller. Vitest wraps factory throws with its own synthetic Error (`"[vitest] There was an error when mocking a module..."`) and the inner string is unobservable. Assert on the SUT's observable contract (return shape, observability mirror call) instead — the throw is a *trigger*, not a *contract*. See `knowledge-base/project/learnings/2026-05-07-vitest-domock-factory-throw-wrapped-message.md`.
   - To prove a cache-hit skips work (not just that the response status is correct), wrap the real implementation in a spy via `vi.importActual` rather than stubbing the return value: `vi.mock("@/module", async () => { const actual = await vi.importActual(...); return { ...actual, expensiveFn: (...args) => { spy(...args); return actual.expensiveFn(...args); } })`. Stubbed returns break any downstream behavior that depends on the real output (hash-match, SQL row shape, etc.); wrapping preserves the contract while exposing call counts for assertions like `expect(spy).toHaveBeenCalledTimes(1)` across a HEAD+GET sequence. **Why:** In PR #2515, verifying that HEAD populates `shareHashVerdictCache` so a follow-up GET skips the SHA-256 drain required counting `hashStream` calls, not stubbing its return — a stubbed return would have broken the post-drain hash-equality check and masked the very regression the test was meant to catch.
   - When testing decorative images (alt="") with happy-dom, use container.querySelector instead of screen.getAllByRole("img", { hidden: true }) -- happy-dom excludes presentational elements from role queries even with hidden: true.
   - When asserting against a conditional render branch in a component test, grep the test file's `vi.mock(...)` factories for the inputs the branch reads and confirm the mock returns values that activate the target branch. Mocks that simplify (e.g., `getDisplayName: (id) => id.toUpperCase()`) often skip production branches like `leader.title.includes(displayName)` — assertions on the skipped branch fail for non-bug reasons. **Why:** PR #3427 — see `knowledge-base/project/learnings/2026-05-07-test-assertion-must-verify-mock-activates-branch.md`.
   - A wait-on-ABSENCE (`await vi.waitFor(() => expect(queryByTestId(x)).toBeNull())`) is vacuous — it passes on the FIRST tick, before the async work resolves, so it never proves "absent AFTER the state commit." Anchor the wait on a positive settle signal (e.g., a `.finally(() => { settled = true; })` flag on the mocked response body), then assert absence. Also: vitest's `vi.waitFor` and RTL's `waitFor`/`findBy*` have independent 1 s defaults and independent config surfaces — a global RTL `asyncUtilTimeout` bump does not touch `vi.waitFor` call sites. **Why:** #5113 — see `knowledge-base/project/learnings/test-failures/2026-06-10-parallel-load-flake-two-mechanisms-and-vacuous-absence-waits.md`.
   - An intermittent absence-wait that times out at the **FULL (explicit) timeout** is a component/state RACE, not a timeout-floor problem — raising the timeout cannot fix it. Discriminator: if the failing `vi.waitFor` site already carries an explicit `{ timeout }`, the floor is irrelevant; trace the component's effect ordering. A passive effect that resets state on EVERY render where a condition holds (`if (cond) setX(false)`) rather than on a `prev→curr` transition races any user action that should win (React runs passive effects AFTER commit, so it can land after the click and undo it) — gate such effects on the transition via a `prevValue` ref. **Why:** #5796 — see `knowledge-base/project/learnings/test-failures/2026-06-30-vi-waitfor-floor-vs-component-rearm-race.md`.
   - When testing a fallback ladder or mode option (primary-then-degrade, retry-then-cache, mergeMode direct→arm-auto-merge), assert the FIRST rung was *attempted* (the primary call fired), not just the fallback's effect — an effect-only assertion passes identically against an option-ignoring implementation whose default path yields the same end state. **Why:** PR #5133 — two mergeMode-direct fallback tests passed against the pre-#5111 helper; see `knowledge-base/project/learnings/2026-06-11-pipeline-consolidation-behavior-preserving-migration-traps.md`.
   - When adding `sessionStorage` usage to React components, ensure the component's test file includes `sessionStorage.clear()` in its `beforeEach` block. Shared jsdom environments leak sessionStorage between tests, causing ordering-dependent failures.
   - When adding a React-context-dependent hook (`useTheme`, `useRouter`, any provider-gated hook) OR a new provider import to a SHARED component, grep `test/` for every file that renders that component DIRECTLY (not via a `vi.mock` of its module) and add the provider stub in the SAME commit. `tsc` and the component's own test pass; sibling direct-render tests fail at RUNTIME with `<hook> must be used inside <Provider>`. **Why:** PR #5217 — `C4Canvas` gained `useTheme()`; `c4-fullscreen.test.tsx` (the only direct `<C4Canvas>` renderer) broke 8 tests until stubs for `theme-provider` + `@mantine/core` were added. See `knowledge-base/project/learnings/2026-06-12-likec4-mantine-color-scheme-seam-and-vendored-theme-preservation.md`.
   - To reproduce a provider's SSR-hydration "no-bootstrap" state in jsdom (lazy `useState` initializer landed on a server fallback like `"system"` WHILE durable storage holds the real value AND the DOM attribute is absent), do NOT use a `Storage.prototype.getItem` call-count spy — it bleeds across tests in the shared jsdom worker (passes in isolation, fails in-suite) and a leftover DOM attribute pollutes later inits. Instead use REAL localStorage (empty at init) and write the stored value from inside the `matchMedia.matches` getter (fires during the resolved-state initializer — after both init storage reads, before the first-mount effect); scrub the attribute + clear storage inside the mount helper and `cleanup()` in `afterEach`. Pair it with a precondition self-check (`if (!released) throw`) so a future init refactor that stops touching `matchMedia` fails as a clear FIXTURE error, not a phantom SUT regression. A naive client-only mount masks the bug (initializer reaches the durable value directly → vacuous green). **Why:** PR #5312 — see `knowledge-base/project/learnings/test-failures/2026-06-15-ssr-hydration-no-bootstrap-theme-test-gate.md`.
   - When asserting on `vi.getTimerCount()`, remember that `vi.useFakeTimers()` mocks every timer-like API by default — including `requestAnimationFrame`, `setImmediate`, `queueMicrotask`, `requestIdleCallback`. The count is a SUM across all fake timer types, not just `setTimeout`. Prefer stability assertions (`count before N extra calls === count after`) over magnitude assertions (`count === 1`) so refactors that add a well-behaved rAF or microtask don't falsely read as leaks. See `knowledge-base/project/learnings/test-failures/2026-04-17-vitest-getTimerCount-counts-requestAnimationFrame.md`.
   - When a component exports an interface that a test harness consumes (e.g., `ChatInputQuoteHandle`), have the test import it via `type X = ExportedInterface` — never shadow with a local duplicate. Duplicate interfaces silently drift when the exported type gains a method; the `tsc --noEmit` failure surfaces only at build time.
   - When adding a new npm dependency, check the installed major version (`node -e "console.log(require('<pkg>/package.json').version)"`) and read the type definitions before using API from docs or training data. Library APIs change across major versions (e.g., `react-resizable-panels` v4 uses `Group`/`Separator`/`orientation`/`useDefaultLayout`, not v2's `PanelGroup`/`PanelResizeHandle`/`direction`/`autoSaveId`).
   - For sizing APIs from third-party libraries, always pass **explicit units as strings** (e.g., `"18%"`, `"100px"`, `"1rem"`) rather than bare numbers. Docstrings may claim a default unit but runtime parsers often treat numbers as pixels. **Why:** `react-resizable-panels` v4 doc said "Percentage of the parent Group (0..100)" for numeric sizes, but the runtime treated `18` as 18px, producing a ~18px-wide sidebar in production. Explicit units make intent visible at the call site and survive library version upgrades.

   **Test environment setup:** If the project's test runner cannot run the type of test needed (e.g., React component tests require jsdom but vitest is configured for node), set up the test environment BEFORE starting the task. This is part of RED — the test infrastructure must exist for the test to fail properly.

   - When configuring bun preload scripts that register DOM globals (e.g., happy-dom), use dynamic `await import()` for all subsequent dependencies — static ES imports are hoisted before any imperative code, causing libraries like @testing-library/react to initialize without DOM globals. See `knowledge-base/project/learnings/test-failures/2026-04-03-bun-test-dom-preload-execution-order.md`.
   - **Bash performs QUOTE REMOVAL on `word` in `${VAR:?word}`, so quotes there are consumed, not printed — and an UNBALANCED one is a whole-file parse error reported far from its cause.** `: "${d:?git -C '' would retarget}"` prints `git -C  would retarget` (note the double space): the `''` never reaches the operator, so it buys nothing and costs the parse hazard. One extra apostrophe — a possessive like `caller's` alongside a `''` pair — makes three, and bash then reports `syntax error near unexpected token` at a line 150 below the real one. Write these messages quote-free (`git -C <empty>`), which removes the hazard class rather than balancing it, and run `bash -n` over the WHOLE file after any edit rather than eyeballing the edited region. **Why:** #7709 — hit twice on one branch, the second time after the trap was written into a commit message. See `knowledge-base/project/learnings/2026-09-03-every-p1-was-in-the-verification-not-the-fix.md`.
   - Never write a literal `*/` inside a `/* … */` / `/** … */` block comment — it closes the comment early. The trap is documenting a regex that ends in `*/` (`/--[^\n]*/g`, `foo/**/*`): esbuild/tsc then parses the trailing prose as code and reports `Expected ";" but found <token>` at a line deep inside the docstring (a red herring — the real cause is the stray `*/` upstream). Describe the regex in prose or use a `//` line comment; `grep -nF '*/' <file>` after authoring confirms every hit is real code. **Why:** #5920 — a `*/g` in a JSDoc comment broke collection of `byok-rpc-body-markers.test.ts`. See `knowledge-base/project/learnings/build-errors/2026-07-03-jsdoc-block-comment-closed-early-by-regex-star-slash.md`.
   - When a test file calls a SUT that lazy-imports a heavy module (`pdfjs-dist`, `sharp`, `puppeteer`, `playwright`, `@xenova/transformers`, `onnxruntime`), pre-warm the module in `beforeAll(async () => { await import("<module>"); }, 30_000)`. The cold-start cost (~5-10s on CI runners) otherwise lands on the first `it()` and blows the default 5s vitest timeout — the second test in the same file runs at warm ~9ms because subsequent calls hit the module cache. Cheapest detection: `git grep -lE '(pdfjs-dist|sharp|puppeteer|playwright|@xenova/transformers|onnxruntime)' -- '*.test.ts'` and check for sibling `beforeAll`. **Why:** PR #3681 `pdf-text-extract.test.ts` cold-start flake (7s vs 9ms, #3687).
   - When uploading files via Playwright MCP, save files to repo-accessible paths (not `/tmp/`). Playwright MCP restricts file access to the repo root. When Google Search Console offers Cloudflare auto-verification, prefer "Any DNS provider" manual flow — the popup OAuth flow opens an external tab that crashes the Playwright browser context.
   - **Vendor-token extraction via Playwright MUST use `browser_evaluate(filename: ...)` from the FIRST attempt** — the return value otherwise enters the conversation transcript and the token is leaked even after revocation. AND the `filename` parameter JSON-encodes the result (surrounding quotes), so the canonical pipe is `python3 -c "import sys,json; sys.stdout.write(json.loads(open('<path>').read()))" | doppler secrets set <KEY> --no-interactive`. Validate via the vendor's API (HTTP 200 + length check) before shredding the file — some vendors silently tolerate quoted tokens via `Authorization: Bearer "abc"`, but Terraform's HCL parser does not. For `●●●`-masked UI tokens (Doppler personal tokens), click the in-page copy button via `browser_evaluate`, then `xclip -selection clipboard -o > <path>`; clear with `xclip -i </dev/null`. **Doppler TF var storage convention:** drop the `TF_VAR_` prefix from the secret name — `--name-transformer tf-var` ADDS the prefix at injection time (`DOPPLER_TOKEN_TF` → `TF_VAR_doppler_token_tf`; storing the already-prefixed `TF_VAR_DOPPLER_TOKEN_TF` produces `TF_VAR_tf_var_doppler_token_tf`). See [`2026-03-21-doppler-tf-var-naming-alignment.md`](../../../../knowledge-base/project/learnings/2026-03-21-doppler-tf-var-naming-alignment.md). **Why:** PR #3973 (#3960) — full pattern + recovery flow at [`2026-05-18-vendor-token-mint-and-oci-image-content-carrier-patterns.md`](../../../../knowledge-base/project/learnings/2026-05-18-vendor-token-mint-and-oci-image-content-carrier-patterns.md).
   - After any `Write` whose hook output emits a warning (security, style, rule), immediately `Read` the file to verify the full content landed. PreToolUse hooks that print error output but return non-blocking status can still cause partial writes — detecting this only when tests fail wastes a debug round. See `knowledge-base/project/learnings/2026-04-15-kb-share-binary-files-lifecycle.md`.
   - When adding source-reading regex tests (`readFileSync(path)` + `expect(src).toMatch(...)`) as a negative-space regression gate after an extraction, put them in a standalone `*.test.ts` file — never add them to an existing test file that already mocks `node:fs` or `node:path`. The existing `vi.mock("node:fs", ...)` factory likely omits `readFileSync`, and the new test will fail at collection with "No `readFileSync` export is defined" before any assertion runs. Also trim the gate to only the assertion that cannot be expressed behaviorally — usually the negative "symbol-not-present" check. Positive assertions (import regex, await-call regex) duplicate coverage that mock-based behavioral tests already provide and are brittle to barrel re-exports, aliases, and whitespace. See `knowledge-base/project/learnings/best-practices/2026-04-17-regex-on-source-delegation-tests-trim-to-negative-space.md`.
   - **Ask whether the guard protects the CARDINALITY or the IDENTITY of what it covers — and then DELETE ITS BODY and confirm the suite reds.** Two failures that travel together. (a) A ratchet written over a COUNT is structurally blind to a RENAME when the guarded quantity is a SET: measured, four layers pinning a config floor (`>= N`, `floor == inventory count`, a derived-list equality, a run-time re-assertion) all held while one config was swapped for another, so reach shrank and the scan reported a clean `13/13`. Tightening the counts never closes it — they measure the wrong thing, which is exactly why the gap survives review. Assert the SET (e.g. a superset check against the merge base, with an explicit ack array for deliberate removals), and prefer the MERGE BASE over `origin/main`'s tip so an un-rebased branch does not red for a shrink it did not make. (b) An anti-vacuity `PASS + FAIL >= N` floor counts assertion CALLS, not BODIES: deleting the new guard's whole comparison loop still reported 61/61 and exit 0, because a gutted control still calls `pass()`. Give a loop-based assertion its own accounting (count what it examined, reconcile against the expected input size) and mutation-prove it — reading the guard is not sufficient, this one was written by someone who knew the vacuity class. **Why:** #7234 — see `knowledge-base/project/learnings/2026-08-04-a-count-framed-ratchet-cannot-see-a-rename.md`.
   - **NARROWING THE SCOPE IS NOT THE FIX — ANCHOR ON SYNTAX.** The bash body-grep rule below generalizes to EVERY source-reading assertion (`readFileSync` + `toContain`/`toMatch` over `.tf`/`.yml`/`.ts`), and the obvious correction — slice a narrower region — FAILS when a file puts explanatory comments INSIDE the construct: there is then no scope that holds the config but no prose. Anchor on something a comment cannot produce (`^\s*key\s*=` — a comment line starts with `#`; a call shape `Fn\(\s*arg`), never a bare word. Treat every `toContain` of a token that also appears in a nearby comment as guilty until mutation-tested, and give every slice helper an explicit lower bound plus an `indexOf === -1` guard (`slice(-1)` yields the last character, so `.not.toMatch()` against it always passes). **Why:** #6456 shipped FOUR — `/value\s*=\s*2/` matched its own "WHY value = 2 AND NOT 3" comment; `toContain("IssueOwners")` matched an in-body comment while the entire `actions_v2` block was deleted (the rule then paged nobody — the outcome the test was named for) and stayed 10/10 green; a boundless `scopeResource` swallowed the next resource's comment so a GROUPING-anchor check was satisfied by the pointer to the paragraph it was meant to find. All four were read-and-believed; only mutation caught them. Count failures from the runner's summary line after stripping ANSI — `grep -cE '^\s+×'` always returns 0. See `knowledge-base/project/learnings/2026-07-15-narrowing-is-not-anchoring-and-a-documented-class-recurred-four-times-in-one-pr.md`.
   - When adding a static-grep assertion over a SCRIPT BODY (a `.sh`/`.test.sh` body-grep gate, an AC `grep -n … | head -1` order check), anchor it on the syntactic write/call construct (`rest/v1/<table>`, a function-call shape) — NEVER a bare token (`<table>`, `<flag-name>`) that the same file also names in a COMMENT or header-inventory. A body-grep sees comments too, so the moment a task requires both a "must / must-not contain X" assertion AND documenting X in a comment, they collide: a negative `! grep -qE 'X'` false-FAILs on the explanatory comment, and a `grep -n X | head -1` order check returns the comment line, not the code. Reword forbidden-literal comments to drop the literal. Same class as the source-reading-regex rule above, for bash. **Why:** PR for #5501 — `seed-live-verify-user.sh`'s `user_session_state` upsert: the test's `! grep '/rpc/set_current_workspace_id'` tripped on a comment naming the RPC, and the AC3 bare `grep user_session_state` matched the new header-inventory line. See `knowledge-base/project/learnings/test-failures/2026-06-17-grep-assertion-over-script-body-false-matches-own-comments.md`.
   - When a drift-guard test EXTRACTS a substring (regex over SKILL.md/source) to compare against an **exact-equality production Set** (an allowlist, a carve-out, a canonical-literal set), the extraction must (a) span the SAME command/token boundaries the production checker uses — capture the FULL command, not a salient prefix; a verb-terminated `(?:list|ls)\b` lets `… list --json` extract the bare `… list` (a member) and pass GREEN while the real command is a non-member — and (b) match EVERY shape the producer can legitimately emit (optional `bash ` prefix, env-prefixed direct-exec) — an unmatched shape silently escapes the guard, it is not caught drift. Litmus: mentally mutate the producer (add a flag, drop the prefix, change the anchor) and confirm the test goes RED for each; `.trim()` the extract to mirror the checker's own normalization. Do NOT assert a failure *consequence* in the docstring you haven't traced in the gate code (e.g. "DENIED on server" when the path only degrades to the review-gate). **Why:** PR #6152 (#6121) — the plugin-root list/ls coupling test's regex false-GREENed on trailing-arg + no-`bash` drift; caught by test-design + user-impact review. See `knowledge-base/project/learnings/best-practices/2026-07-07-drift-guard-extraction-must-mirror-production-checker-boundaries-and-all-emission-shapes.md`. <!-- markdownlint-disable-line MD038 -->
   - When a bun-test file mutates `process.env.*` or `globalThis.*`, capture originals at module top-level (before any `describe`) and restore in `afterEach` using `delete` when the original was `undefined` — `bun test` runs every file in a single OS process, so mutations leak to sibling files and to any `spawnSync` subprocess launched after the mutation. Vitest isolates files in workers by default; bun does not, and has no built-in `stubEnv`/`unstubAllEnvs` equivalent. **Why:** PR #2579 — `bot-fixture-helpers.test.ts` stubbed `SUPABASE_URL` in `beforeEach` with no restore, causing 4 integration tests in `bot-fixture.test.ts` (same run) to ConnectionRefused against the stub host. See `knowledge-base/project/learnings/test-failures/2026-04-18-bun-test-env-var-leak-across-files-single-process.md`.
   - When a vitest test asserts a `process.env.X === "true"`-gated default-off path, add `vi.stubEnv("X", "")` to `beforeEach` regardless of whether the current Doppler/CI config injects the var. `vi.unstubAllEnvs()` reverts `vi.stubEnv` writes only — it CANNOT delete a process-inherited env var (Doppler dev / CI secrets / `direnv` / devcontainer envs). The test passes locally with plain `npx vitest run` and fails deterministically under `doppler run -p soleur -c dev -- npx vitest run` when the dev config flips the flag on. Tests that need the flag on continue to call `vi.stubEnv("X", "true")` in their own `it()` bodies — the local stub overrides the beforeEach default (overwrite-semantics). **Why:** PR #4141 (#4128) — `cc-dispatcher.test.ts > T-W4-basic-off` failed 1/1 under Doppler dev because `CC_PERSIST_USAGE=true` injection survived `unstubAllEnvs()`. See `knowledge-base/project/learnings/test-failures/2026-05-20-vitest-unstub-does-not-clear-process-inherited-env-vars.md`.
   - When a test uses retry-on-flake logic (network, LLM non-determinism, timing), collect every attempt into an array and assert the invariant across ALL attempts — not just the last. Early-return after retry silently drops first-attempt failures. If the retry exists to force a precondition (tool invocation, tool output presence), assert the precondition WAS met on the final attempt; a refusal on retry is a hard failure, not a silent pass. **Why:** PR #2610 FR2-smoke/FR8/FR9 originally used `if (!condition) { retry; return; }` which let attempt-1 leaks slip through. See `knowledge-base/project/learnings/test-failures/2026-04-19-retry-once-early-return-masks-first-attempt-failures.md`.
   - When adding Eleventy `_data/*.js` files: (a) name the file in camelCase matching a valid JS identifier — kebab-case filenames produce hyphenated template variables that Nunjucks dotted access cannot resolve; (b) keep the module **default-export-only** — sibling `export` statements silently disable Eleventy's data-module registration (no error, no warning, the benchmark log omits the file); attach test helpers as properties on the default export. Verify each new `_data/*.js` appears in the build's `Benchmark ... (Data) ...` log. **Why:** PR #2596 — see `knowledge-base/project/learnings/build-errors/2026-04-18-eleventy-data-module-loading-and-nunjucks-null-test.md`.
   - Nunjucks has **no** `is null` / `is not null` test — the parser accepts `{% if x is not null %}` but evaluates it unpredictably for numeric values. To distinguish `undefined` / `null` / `0`, precompute a boolean in the `_data/*.js` module (e.g., `{ stars, showStars: stars != null }`) or accept a truthy guard. **Why:** PR #2596 — see same learning file.
   - When a work task ports a TS regex normalizer to SQL (or vice versa) for a backfill migration, run every fixture from the TS unit test file through the SQL expression BEFORE committing the migration. Cheapest shape: a `WITH fixtures AS (VALUES (<input>, <expected>), ...) SELECT input, expected, <sql-expr> AS actual, expected = <sql-expr> AS ok FROM fixtures` query. The WHERE-clause idempotence guard (`col <> <normalized-expr>`) is necessary but not sufficient — it only catches drift on re-runs, not on first apply. Idempotence fixtures must include at least one repeated-suffix case per strip-class (`.git.git`, trailing `//`) so a `\.git$` that should be `(\.git)+$` is forced to fail. **Why:** PR #2817 — migration 031 had a P1 operator-precedence bug (`.git` stripped before trailing `/`) and a P2 non-idempotency bug (`bar.git.git`), both caught only at multi-agent review. See `knowledge-base/project/learnings/best-practices/2026-04-22-ts-sql-normalizer-parity-when-shipping-backfill-migration.md`.

   **IMPORTANT**: Always update the original plan document by checking off completed items. Use the Edit tool to change `- [ ]` to `- [x]` for each task you finish. This keeps the plan as a living document showing progress and ensures no checkboxes are left unchecked.

3. **Incremental Commits**

   After completing each task, evaluate whether to create an incremental commit:

   | Commit when... | Don't commit when... |
   |----------------|---------------------|
   | Logical unit complete (model, service, component) | Small part of a larger unit |
   | Tests pass + meaningful progress | Tests failing |
   | About to switch contexts (backend → frontend) | Purely scaffolding with no behavior |
   | About to attempt risky/uncertain changes | Would need a "WIP" commit message (exception: UX artifacts use `wip:` prefix) |
   | UX specialist produces artifacts (wireframes, copy, brief) | Specialist is still generating (mid-output) |
   | Domain leader review cycle completes (feedback applied) | Review feedback not yet incorporated |
   | Brand guide alignment pass completes | Alignment still in progress |

   - When lefthook hangs during commit in a worktree (common with `core.bare=true` repos), verify typecheck and tests pass manually, then use `LEFTHOOK=0 git commit`. Always check for stalled lefthook processes (`pgrep -fa lefthook`) before retrying.
   - **`LEFTHOOK=0` also bypasses the `c4-model-regenerate` pre-commit hook.** If the diff edits a `.c4` file, run `bash scripts/regenerate-c4-model.sh` and stage the updated `model.likec4.json` BEFORE the `LEFTHOOK=0` commit — otherwise `c4-model-freshness.test.sh` reds — it is in the `scripts` shard, so include that shard in the Phase 2 touched-shard gate whenever the diff touches a `*.c4` file. `c4-render.test.ts`/`c4-code-syntax.test.ts` do NOT check the committed artifact. **Why:** #6549 — a `model.c4` edit committed under `LEFTHOOK=0` shipped a stale `model.likec4.json`.
   - After a `git mv old new`, never `git add old` (the pre-rename path) — the stale pathspec exits fatal `pathspec 'old' did not match` and ABORTS the entire `git add`, silently dropping every other path, so the commit captures only the rename (`0 insertions`). Stage the NEW path (or `git add -A <dir>`) and verify `git show --stat HEAD` shows the full expected file set before trusting the commit; a rename-only stat on a content change is the tell. **Why:** #6448 — a `git add …/docker-daemon.json` after renaming it to `.tmpl` produced a rename-only first commit; fixed by `--amend`.
   - **Never pass a commit message containing backticks via `git commit -m "…"` — bash command-substitutes them and the text is silently EATEN.** **The same trap has two more homes, and one of them is silent.** (a) A backtick inside a double-quoted BASH ARRAY ELEMENT is substituted the same way — `"path | reason <backtick>x.md<backtick> ..."` made a guard exit **127** with `x.md: No such file or directory`, which at least fails loudly. (b) A backtick inside an **unquoted** heredoc (`<<PY`, reached for whenever a shell variable must be interpolated into a Python/awk script) is substituted before the interpreter ever sees the file, so a backticked path inside a STRING LITERAL vanishes and leaves a sentence with a hole in it — measured, it ate a filename out of a GitHub issue body and nothing failed. Use `<<'PY'` and pass values through the environment (`export X=…` / `os.environ["X"]`); markdown backticks are safe in markdown and unsafe in every shell-quoted context that carries them. The message is double-quoted, so every `` `…` `` span runs as a command and is replaced by its stdout (usually empty). The commit still succeeds, the shell prints an unrelated-looking `command not found` you skim past, and the log carries a sentence with a hole in it — which is worse than a garbled one, because the remaining prose still reads as complete. Measured: three spans (`` `before` ``, `` `declare -F plan_gate_assert_readable` ``, `` `grep -L …` ``) vanished from a commit whose whole subject was that command's vacuity. Use `--file <path>` (Write the body first) or a quoted heredoc (`git commit -F - <<'EOF'` — note the quoted `'EOF'`, which is what disables substitution); verify with ``git log -1 --format=%B | grep '`'``. Same class as the `sed` replacement `&` trap: `&` expands to the whole match, so an unescaped `&` in a `sed` replacement silently duplicates the matched text. **Why:** #7035.
   - **When a commit needs a machine-readable trailer (`Allowlist-Widened-By:`, `Signed-off-by:`, `Reviewed-by:`, etc. — anything downstream parses via `git log --format='%(trailers:key=NAME,valueonly)'`), keep the FINAL paragraph as a pure contiguous block of `Token: value` lines.** Two silent-drop shapes: (a) blank line between the new trailer and `Co-Authored-By:` makes the former part of the body, not a trailer; (b) ANY non-key:value line in the final paragraph (e.g., `Closes #3877.`, `Refs #3874 (precedent).`) invalidates the WHOLE block — both legitimate trailer lines below it drop silently. Put `Closes`/`Refs`/`Fixes` in mid-body prose; GitHub auto-close still works anywhere in the body. Verify locally with `git interpret-trailers --parse < <(git log -1 --format=%B)` — empty output for a trailer that should exist is a hard fail. See `knowledge-base/project/learnings/2026-05-16-git-trailer-parser-requires-contiguous-key-value-block.md`.

   **Heuristic:** "Can I write a commit message that describes a complete, valuable change? If yes, commit. If the message would be 'WIP' or 'partial X', wait."

   - **A mutation battery restores from a PRISTINE COPY, never from `git checkout` — and the fix it tests must be COMMITTED first.** `git checkout -- <file>` restores to HEAD, which is a different thing from "what I had a moment ago" in exactly the situation a battery runs in: a fix in flight. Measured: a P0 battery restored that way against an UNCOMMITTED fix, so row 1 reverted the fix and every later row scored the DEFECT against itself — two rows reported SURVIVED while measuring a file that no longer contained the thing under test. The only thing that caught it was the battery's own `diff pristine vs SUT` restore check at the end, which most batteries omit. Add that check, `cp` a pristine copy before row 1, and commit the unit before mutating it. **Why:** #7104 PR-B. See `knowledge-base/project/learnings/2026-08-19-my-battery-reverted-the-fix-it-was-testing.md`.
   - **A SURVIVING mutant is fixture-inadequate OR equivalent — decide which before touching the guard.** The reflex is "the guard has a gap", and the expensive version of this is a row that does not reproduce the attack it names: a composite bypass reconstructed as only its *balanced-phantom* half survived, correctly, because balanced phantoms around a genuinely-terminal assert perturb nothing. It read as a guard gap and was a FIXTURE gap. Reconstruct the attack exactly — every half, in order — before concluding anything about the guard, and record the finding in the row's own comment so the next reader does not re-litigate it.
   - **Commit each verified unit IMMEDIATELY — a worktree sync can revert uncommitted work with no warning.** `worktree-manager.sh` carries a "Syncing on-disk files from git HEAD" pass that restores tracked files to HEAD, and `.claude/hooks/guardrails.sh` can invoke it mid-session; anything verified-but-uncommitted is silently lost. Never hold verified work in the working tree across a long-running background job (a full test-all, a review agent). Where an edit must be followed by a commit, do BOTH IN ONE Bash call (`cat > file <<'EOF' … EOF; git add …; git commit`) so no window exists. Corollary: a reconciliation script that silently no-ops on a missing anchor (`python str.replace`, `sed s///`) will print success against a reverted file — assert the anchor (`assert old in s`) or the edit is unverified. **Why:** #6578 — two full re-applications of verified work; the revert was caught only because a re-run printed numbers that contradicted a result verified minutes earlier.

   **UX artifact heuristic:** "Did a specialist just produce or revise artifacts? If yes, commit with `wip: UX <description> for feat-X`. UX artifacts are high-effort and low-recoverability -- err on the side of committing too often rather than too rarely."

   The `wip:` prefix is intentional -- UX artifacts are valuable at every revision stage, and WIP commits are squashed on merge with no impact on final git history. Do not run compound before UX WIP commits -- compound runs once in Phase 4.

   **Compound-before-commit scope:** AGENTS.md Workflow Gates says "Before every commit, run compound." Within this skill, that rule applies to the **final Phase 4 commit** (the one that closes the feature), not to Phase 2 incremental commits. Running compound per incremental commit is recursive (compound creates commits) and defeats the point of incremental checkpoints. A single compound at Phase 4 covers the whole feature's session-error inventory and learnings.

   **Commit workflow:**

   ```bash
   # 1. Verify tests pass (use project's test command)
   # Examples: bin/rails test, npm test, pytest, go test, etc.

   # 2. Stage only files related to this logical unit (not `git add .`)
   git add <files related to this logical unit>

   # 3. Commit with conventional message
   git commit -m "feat(scope): description of this unit"
   ```

   **Handling merge conflicts:** If conflicts arise during rebasing or merging, resolve them immediately. Incremental commits make conflict resolution easier since each commit is small and focused.

   **Note:** Incremental commits use clean conventional messages without attribution footers. The final Phase 4 commit/PR includes the full attribution.

4. **Follow Existing Patterns**

   - The plan should reference similar code - read those files first
   - Match naming conventions exactly
   - Reuse existing components where possible
   - Follow project coding standards (see CLAUDE.md)
   - When in doubt, grep for similar implementations
   - **An acceptance checkbox is a CLAIM — never bulk-toggle `- [ ]` → `- [x]`, and never append to a markdown table row past its closing pipe.** Two write-time foot-guns that both convert unverified work into work that reads as verified. (a) A bulk checkbox replace marks every AC "verified" at once with zero verification performed; run each AC's command and let the output decide (in #6781 a bulk toggle marked 15 ACs done and AC13 was then measured FALSE). This is the `session-state.md` decisions-are-intent rule applied to your own artifact in the same session. (b) Appending prose after a table row's trailing `|` creates a cell beyond the header count, and GFM **discards** it — so the text survives in raw markdown, passes any grep-based AC, and renders as though the edit never happened. That shipped an Article 30 statutory-register amendment that displayed as unamended. Cheapest gate after any table-row edit: `awk '{n=gsub(/\|/,"|"); print NR, n}' <file>` and compare the row's pipe count against a sibling row. See `knowledge-base/project/learnings/2026-07-21-the-guard-i-shipped-could-never-have-fired-and-my-fake-certified-it.md`.
   - **Before writing a new format, date, or util helper in any app, `ls` + grep the app's canonical `lib/` directory (e.g., `apps/web-platform/lib/`) for equivalents.** Canonical helpers are often single-purpose small files named by verb (`relative-time.ts`, `format-currency.ts`); typecheck and tests will not catch duplicated logic. See `knowledge-base/project/learnings/2026-04-17-grep-lib-before-writing-format-helpers.md`.
   - **When the plan says "mirror precedent X 1:1" but the new table/RPC reuses a precedent column for a NEW role X never had (a fencing token, a WORM-audit row, a portability-export field), enumerate the new role's invariants and check each against the precedent's lifecycle — a passing 1:1 mirror is NOT proof of correctness.** A precedent's guarantees hold only for the role it was built for: `acquire_conversation_slot` (029) is a concurrency slot, so its DELETE-on-release is correct there but silently breaks a *fencing-token* contract (the next acquire resets `lease_generation` to the column default → the git-data `reject gen < max` fence inverts into a write outage). The fix lives at the lock service, never the resource server; route the data-model fork to the `cto` agent. tsc + the precedent-cloned ACs pass green — only `architecture-strategist`, prompted with the downstream contract (the ADR), catches it. **Why:** #5274 PR A — DELETE-on-release vs ADR-068 §3 monotonic-max; CTO ruled tombstone-on-release. See `knowledge-base/project/learnings/best-practices/2026-06-30-precedent-mirror-for-new-role-breaks-fencing-token-monotonicity.md`.
   - **When the plan's specified path is wrong and you correct it during implementation, immediately `git grep` the corrected path's basename across the diff scope and fix EVERY secondary citation in the same edit cycle.** The plan often appears as an authoritative path source in multiple secondary artifacts (Article 30 register entries, runbooks, ADRs, README references); fixing only the primary landing site leaves a silent drift the reviewer must catch. The plan is authoritative for intent, never for paths (`hr-when-a-plan-specifies-relative-paths-e-g`). **Why:** PR #4287 — plan §6.2 named `knowledge-base/engineering/runbooks/cron-retention-monitor.md`; runbook landed at the correct `engineering/operations/runbooks/...` but the PA-20 Article 30 entry cited the plan's wrong path; caught at multi-agent review by `git-history-analyzer`. See `knowledge-base/project/learnings/2026-05-22-or-semantics-allowlist-inverse-lint-and-keyset-cursor-tiebreak.md`.
   - **When you DELETE an entity from an enumeration in prose, grep the SAME sentence/cell/bullet for clauses whose SUBJECT was that entity — a claim family is removed whole or not at all.** Deleting the head silently re-points its dependent clauses at whatever remains, which can make a FALSE claim strictly worse: bound to a named phantom (`…web-2 plus a dedicated git-data host. Stored workspace git data sits on a LUKS-encrypted volume…`) the tail reads as a claim about *that host*; unbound it reads as a claim about the **live** substrate. Litmus: after removing X from "…A, B, and X. X does P, Q, R." — ask *what does "does P" now attach to?* If the answer changed, you rewrote a claim you never meant to touch. Verify mechanically (per-file clause counts byte-identical to `main`), never by eye. Prose sibling of `cq-ref-removal-sweep-cleanup-closures`. **Why:** #6538/PR #6568 — removing a never-provisioned git-data host left its LUKS encryption-at-rest clause dangling onto `hcloud_volume.workspaces` (plain `ext4`), converting a scoped falsehood into a live false Art. 32 claim on the published privacy policy; 4 review agents converged, the edit cycle never saw it. See `knowledge-base/project/learnings/2026-07-16-removing-a-false-claim-can-strengthen-the-false-claim-that-leaned-on-it.md`.
   - **Sweep the SEMANTIC quantity, not its formatted representation.** A stale-figure sweep anchored on exact literals (`grep -E '176\.11|595\.82|92\.81'`) misses the same quantities written as rounded prose (`~$176/mo`, `4 paying users`, `~93%`) — and rounded prose is where *outward-facing summary sections* live, so the miss lands on the highest-consequence text. Enumerate the derived figures first (subtotal → each break-even → each margin → each rounded restatement), then grep per figure. Same root as `cq-assert-anchor-not-bare-token`. **Why:** #6538 — a "clean" sweep left §6 Pricing Gate asserting the exact `~93%` framing §5 retired one screen above.
   - **Before offering the operator options on a governed surface, read the governing rubric — an option the rubric forbids is not a choice, it is a trap.** Consent/versioning/retention surfaces carry signed policies (`knowledge-base/legal/tc-version-bump-policy.md`) that pre-decide the option space; presenting a forbidden option gets it chosen, then retracted. **Why:** #6538 — offered "repin SHA, no `TC_VERSION` bump" for a T&C edit; the CLO-signed rubric makes Tier 3 (no bump) *typos/whitespace/markdown only* and Tier 2 clarifying **BUMP REQUIRED**, tie-break *"if unsure, treat as clarifying"*. The operator's real intent (no forced re-acceptance) was served by not editing the T&C at all.
   - **When you INSERT or DELETE lines in a doc, every `path.md:N`/`:N-M` line-number citation pointing INTO that doc (from the plan, tasks.md, PIR, ADRs, sibling runbooks — including files this PR isn't "about") goes stale below the edit point.** After the insertion, `grep -rn '<doc-basename>:[0-9]' knowledge-base/` and fix every hit in the SAME edit cycle; for churn-prone targets, prefer an insertion-stable section/anchor reference over a bare line range. tsc/tests are blind to prose offsets — review catches it as a P2 round-trip. **Why:** #5548 — a 10-line runbook §A callout shifted `:71-74`→`:81-84` and `:126`→`:132` across plan/tasks/PIR; caught by pattern-recognition + code-quality. See `knowledge-base/project/learnings/best-practices/2026-06-18-doc-insertion-stales-cross-artifact-line-citations.md`.
   - **Plan-prescribed redaction filters for captured-real fixtures are intent, never authority. Audit the filter against every secret-class the artifact can contain before executing it.** When a plan instructs "capture real provider output → run jq redaction → commit as fixture" (terraform-show-json, supabase log dumps, sentry event payloads, vendor API captures), the prescribed jq filter is a starting point — not a sufficient scrub. Same shape as `hr-when-a-plan-specifies-relative-paths-e-g`: plan is authoritative for intent (which fields to scrub), never for completeness (which fields exist). Concretely: for `terraform show -json` captures, **always** prepend `del(.variables)` regardless of the plan's prescribed filter — terraform-show-json embeds plan-input variables verbatim including `sensitive=true` declarations (`sensitive` masks render-time text output, NOT JSON serialization). After redaction, run a mandatory canonical scan: `! grep -qE 'BEGIN [A-Z ]*PRIVATE KEY|ghp_[A-Za-z0-9_]{20,}|ghs_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-ant-api03-[A-Za-z0-9_-]{20,}|sk_(test|live)_[A-Za-z0-9]{20,}|sbp_[A-Za-z0-9]{20,}|xoxb-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|dp\.(pt\|st\|sa\|ct)\.[A-Za-z0-9_-]{40,}' <fixture>` — must return rc=1 (no matches). A token-prefix-only scan (the plan's typical `no token values, no actor IDs` framing) misses PEM headers. **Why:** PR #4420 — the captured-real fixture initially shipped with the full GitHub App RSA private key embedded under `.variables.github_app_private_key.value`; caught at post-implementation multi-agent review by `security-sentinel` AFTER 9 other agents had read the file. Full incident post-mortem at `knowledge-base/project/learnings/security-issues/2026-05-25-terraform-show-json-leaks-sensitive-variables-into-fixtures.md`. Backstop: PreToolUse hook `git-commit-secret-scan.sh` runs gitleaks on the staged index at every `git commit` regardless of `.git/hooks/pre-commit` installation state.
   - **Before writing data-layer tests that use new PostgREST operators, read the shared mock helper (e.g., `apps/web-platform/test/helpers/mock-supabase.ts`) to confirm it covers every operator the code under test uses.** If not, extend it at the START of Phase 2, not after the first cryptic test failure.
   - **When a change adds or edits a Supabase embedded ``.select(`…`)`` against an UNTYPED client, neither `tsc` nor a select-arg-discarding mock catches a non-existent column — it ships green and errors with Postgres 42703 at runtime (silently returns `[]`). Add an arg-capturing select-string test (capture `chain.select.mock.calls[i][0]`, assert no `auth.users`-only column like `raw_user_meta_data`) AND/OR an opt-in `*.integration.test.ts` vs dev. The embed FK target `users` resolves to `public.users`, NOT `auth.users`.** See `knowledge-base/project/learnings/2026-06-01-untyped-supabase-select-nonexistent-column-ships-green.md` (#4715/#4713).
   - **A supabase-js `.update()/.delete().eq(...)` returns NO error when the WHERE matches 0 rows — a write whose success path returns 200 WITHOUT reading back what it wrote silently no-ops (the active id has no row / a stale resolved-id).** Append `.select("id")` and assert `data.length === 1` (`!== 1`, not `< 1`) → fail loud + distinct Sentry breadcrumb instead of a false success. A sound robustness guard — but do NOT assume a 0-rows no-op is the cause of a "didn't persist"-shaped bug without a live read proving the row is actually absent (see the cautionary case below). Mirrors `.update().select()` precedents in `account-delete.ts`/`ws-handler.ts`. See `knowledge-base/project/learnings/bug-fixes/2026-06-08-supabase-update-eq-zero-rows-silent-noop-and-code-trace-repro.md`.
   - **A route that 302-redirects a browser asset (`<img>`/`<script>`/`<link>`) to a SIGNED STORAGE URL must emit it on a host present in the matching CSP fetch directive.** `createServiceClient` signs against `SUPABASE_URL` (raw `<ref>.supabase.co`), but CSP `img-src` is built from `NEXT_PUBLIC_SUPABASE_URL` (the public custom domain) — so the redirect target is silently CSP-blocked in the browser (`onError` → fallback) while server-side `curl` succeeds (no CSP). Rewrite the signed URL's origin to `NEXT_PUBLIC_SUPABASE_URL` before redirecting; grep `createSignedUrl` whose result reaches the browser and confirm its host is in the CSP directive. **Why:** #4996→#5012 — workspace logo persisted + served 200 but never displayed; the 302 host was absent from `img-src`. See `knowledge-base/project/learnings/bug-fixes/2026-06-08-supabase-update-eq-zero-rows-silent-noop-and-code-trace-repro.md`.
   - **When extending a Supabase wrapper module (e.g., `apps/web-platform/server/conversation-writer.ts`) with a new chained method (`.eq`, `.select`, `.in`, `.maybeSingle`, etc.), grep `apps/web-platform/test/` for every supabase mock chain — both shared helpers (`test/helpers/*-mocks.ts`) AND inline `vi.mock("@supabase/supabase-js", ...)` setups — and extend each one in the same edit cycle.** `tsc` is silent on chain-shape drift; only the full vitest suite catches it. Recursive-by-default mock chains (every chained call returns the same chain object) survive future extensions transparently. Same class as `cq-raf-batching-sweep-test-helpers` and `cq-preflight-fetch-sweep-test-mocks` but for the data-layer fluent API. See `knowledge-base/project/learnings/best-practices/2026-04-27-wrapper-extension-test-mock-chain-sweep.md`.
   - **When source-SWAPPING the data source a shared hook reads (e.g. `use-conversations` repo scope moved from `users.repo_url` to `fetch("/api/workspace/active-repo")`), the test blast radius is {tests importing the hook} − {tests that `vi.mock` the hook away}.** Derive it via `git grep -l '<hookName>' apps/web-platform/test/` minus the `vi.mock("@/hooks/<hook>"` set, and stub the NEW source in every one. Never name-filter that list by topic — page-level renderers (`command-center.test.tsx`, `start-fresh-onboarding.test.tsx`) render the real hook without the feature word in their filename, so a topical grep silently drops them and they fail only once their whole shard runs — which, since #7352, is at the `/ship` Phase 4 full-battery checkpoint rather than at the Phase 2 exit. Derive the list from the import graph, not from filenames. **Why:** PR #5317. See `knowledge-base/project/learnings/best-practices/2026-06-15-hook-source-swap-sweep-all-real-hook-renderers-not-name-filtered.md`.
   - **When adding a SECOND `.on()` registration to a Supabase Realtime channel chain (e.g. adding an `event: "INSERT"` handler beside an existing `"UPDATE"`), every test whose channel mock returns a non-chainable subscribe-only stub from `.on()` (`vi.fn().mockReturnValue({ subscribe })`) breaks with `on is not a function`.** Same blast radius as the `.from()`-chain sweep above, for the realtime channel API: `git grep -l '<hookName>' apps/web-platform/test/` minus the `vi.mock("@/hooks/<hook>")` set, and make each channel mock chainable (`.on()` returns the channel object) in the same edit cycle. `tsc` is silent; only the full vitest suite catches it. Relatedly, a new realtime event handler that maintains a client list must scope-guard on the SAME columns the list query filters on (a subset surfaces rows the refetch drops). **Why:** PR #5391. See `knowledge-base/project/learnings/best-practices/2026-06-16-realtime-event-guard-must-equal-fetch-query-scope.md`.
   - **When an ADR/migration relocates a state column AND migrates SOME consumers to a new resolver (e.g. ADR-044's `users → workspaces` + service-role `resolveActiveWorkspaceKbRoot`), the migration is NOT done until `git grep <oldResolver>` returns 0 — WRITE routes (`kb/share`, `kb/upload`) are consumers too and are easy to miss because the READ routes already "work."** An unmigrated write route keeps the old resolver's divergent failure surface (here: a tenant-scoped read of stale `users.workspace_status` → silent 503), and if that route has un-mirrored failure branches the divergence is invisible until a user hits it. Pair the consumer-sweep with an observability pass over the unmigrated route's silent returns (mirror each to Sentry with `reason=<code>`) BEFORE/with the swap so the fix is confirmed, not assumed; and when the new resolver returns an id that downstream code reuses, thread the ONE resolved id through all sites (a second independent resolve re-introduces divergence). **Why:** PR #4953 — share/upload left on `resolveUserKbRoot` after ADR-044. See `knowledge-base/project/learnings/best-practices/2026-06-05-adr-resolver-migration-must-sweep-write-routes-not-just-read-routes.md`.
   - **When a change adds real parsing/validation of an input (a private key via `createPrivateKey`, a JWT, a config blob) that runs BEFORE a mocked constructor/boundary, sweep every test that stubs that input — not just the test for the file you edited.** Mocking the downstream SDK (`vi.mock("@octokit/app")`) no longer shields tests from malformed placeholder fixtures once real parsing runs first; bogus stub keys (`"…BEGIN RSA PRIVATE KEY…\nfake\n…"`) start throwing at parse time. Grep `git grep -lE '<ENV_OR_FIXTURE_NAME>' -- 'test/**'`, and for each hit decide whether it exercises the REAL factory (replace the stub with a synthesized real value via `generateKeyPairSync`, per `cq-test-fixtures-synthesized-only`) or mocks the factory wholesale (fixture irrelevant — no change). See `knowledge-base/project/learnings/2026-05-29-credential-parsing-before-mocked-sdk-breaks-stub-key-fixtures.md`. Redundant mutations mask trigger regressions — production signup silently breaks while the test keeps passing. Turn the setup step into a canary: read the row the trigger should have created, assert it exists. See `knowledge-base/project/learnings/security-issues/2026-04-18-rls-for-all-using-applies-to-writes.md`.
   - **When extracting a pure reducer out of a React hook, migrate ALL companion state (refs the reducer reads or writes) to the reducer's state boundary in the same change.** A half-extraction — pure function plus mutable ref inside a `setState` updater — advertises purity the call site doesn't honor and recreates the StrictMode/concurrent-rendering hazard the extraction was meant to eliminate. See `knowledge-base/project/learnings/best-practices/2026-04-14-pure-reducer-extraction-requires-companion-state-migration.md`.
   - **When extracting an `async` body out of a `useEffect` into a `useCallback`/helper for reuse, carry the effect's CLEANUP-scoped state with it (cancellation flag, AbortController, interval/timeout/subscription teardown).** The lift looks mechanical but strips the callback of the effect lifecycle — a stale fetch can resolve after the precondition changed and clobber newer state. Restore via a liveness predicate the caller owns (the effect passes `() => active` and flips it on cleanup; synchronous callers default to always-current). tsc + happy-path tests pass; only a stale-response test or multi-agent review catches it. **Why:** PR #4947 — `checkShare` extraction in `share-popover.tsx` dropped the `cancelled` guard. See `knowledge-base/project/learnings/best-practices/2026-06-04-extracting-fetch-effect-to-usecallback-drops-cancellation-guard.md`.
   - **When extracting a module-level `export const NAME = ...` binding to a new module and re-exporting from the source, grep the source file for internal `NAME` references and add a sibling `import { NAME } from "./new-module"` — the re-export alone does NOT put `NAME` back in local scope.** `tsc --noEmit` flags this as TS2304 but ESM-friendly bundlers may silently swap in `undefined`. Same class as `cq-ref-removal-sweep-cleanup-closures` but for module-level bindings. **Why:** #2653 — see `knowledge-base/project/learnings/2026-04-19-enoent-on-optional-mount-should-not-alarm.md` session errors.
   - **When extracting inline logic into a NEW module, the new module's STATIC import graph loads wherever it is statically imported — a symbol previously reached via lazy `await import()` becomes eager.** Importing a heavy module just for one symbol (an error class for `instanceof`, a constant) drags its module-init side effects (top-level `createChildLogger`, `promisify(execFile)`, client construction) into every consumer's test-collection graph and crashes sibling tests with incomplete mocks. Use `import type` + duck-type (`err.name === "ByokLeaseError" && err.cause === "escape"`) or a lazy thunk. ALSO: a new `server/**` file importing `createServiceClient` must be added to `.service-role-allowlist` in the same commit, and you MUST run the EXISTING tests that exercise the extracted code path (`vitest run test/<donor>-*.test.ts`) — the new file's own test + `tsc` passing is not sufficient. Since #7352 the Phase 2 exit runs only your touched shards, so run the donor's existing suites yourself — do not wait for the merge gate to find it. **Why:** PR #5409 — `auto-sync-trigger.ts` extraction pulled `byok-lease`'s static graph into `/api/repo/setup` and missed the allowlist. See `knowledge-base/project/learnings/best-practices/2026-06-16-extracting-helper-from-route-pulls-heavy-static-graph-and-misses-existing-tests.md`.
   - **When a caller of `reportSilentFallback` runs in an environment where the "error" path is a known degraded state (e.g., `readdir` on an optional mount that doesn't exist in dev/CI), filter the error code before paging.** ENOENT on a configured-but-optional path is not a silent fallback — it's a documented zero, and routing it through Sentry exposes every request to any bug in the alarm pipeline itself. Only page on truly unexpected errors (EACCES, I/O, pathological). **Why:** #2653 — same learning file.
   - **After any content-move or template port that preserves `{{ site.url }}<path>` interpolations from the source, build the site and grep rendered output for host-letter concatenation artifacts.** `{{ site.url }}` + path-without-leading-slash produces `https://soleur.aiblog/...` when `site.url` has no trailing slash — Eleventy emits it without warning; source-grep cannot detect it (the source diff is plausibly "consistent with the original"). Cheapest gate: `grep -oE "https://${HOST}[a-zA-Z]" _site/<page>/index.html` — every hit is broken. **Why:** #2705 — see `knowledge-base/project/learnings/best-practices/2026-04-21-eleventy-site-url-concatenation-broken-without-leading-slash.md`.
   - **When editing a `"use client"` component or a `lib/` module reachable from client code, never import from `@/server/observability` or any `@/server/*` module that transitively pulls `pino`.** `next.config.ts` `serverExternalPackages` only externalizes for the server chunk; pino will bundle into the browser. Use `@/lib/client-observability` (a thin `@sentry/nextjs`-only shim) or add a new shim with the same signature. Verify with `grep -rn "@/server" <new-or-edited-file>`. **Why:** PR #2860 — see `knowledge-base/project/learnings/2026-04-23-render-time-scrub-sentinels-and-client-bundle-boundaries.md`.
   - **Any textual tokenize-scrub-restore pipeline (stash regex matches under placeholders, scrub the remainder, restore from an index) must use a per-call random sentinel (≥24 bits of entropy) and THROW on out-of-range restore indices.** Human-readable placeholders (` PRESERVED_N `, `__TOKEN_N__`) are a substitution oracle — assistant-controlled prose containing the literal splices in stashed content, and `?? ""` fallback silently deletes the literal. Pattern: `SOLEUR_PRES_${8hexchars}_${i}`. **Why:** PR #2860 — same learning file.
   - **Debounce/throttle "not-yet-fired" sentinels must be `undefined` or `-Infinity`, never `0`.** Combined with `vi.useFakeTimers({ now: 0 })`, a `0` default produces `Date.now() - 0 >= threshold` = false on the first fire, starving the very path the debounce was supposed to time. Use `if (last === undefined || now - last >= THRESHOLD_MS)`. **Why:** same learning file, session error #3.
   - **For a transitive-only bump (e.g. a Dependabot security bump), edit `package-lock.json` — the single lockfile of record since ADR-191 — via `npx --yes npm@11 update <pkg>`, and validate with `npm ci --ignore-scripts`.** This repo no longer carries `bun.lock`; do not recreate it. The banned-command rule survives the change and still applies to any bun-based repo: never `bun update <pkg>` (elevates the target to a direct dep) or bare `bun update` (bumps every direct caret-ranged dep). See [work-lockfile-bumps.md](./references/work-lockfile-bumps.md). **Why:** PR #3488 — three failed bun invocations rediscovered the constraint at task time.
   - **When a new call site needs coverage by a boundary-enforcing drift-guard whose walk array (`*_DIRS`/`*_PATHS`/`*_GLOBS`) does NOT include the new file's directory, extract the call site into the existing scope — do NOT widen the walk.** The guard encodes an architectural convention ("auth verbs live in `app/(auth)` + `components/auth/`", "CSRF coverage applies to `app/api/`", etc.). Widening the array to absorb one new call site (e.g., adding `app/(dashboard)` because a single `(dashboard)/layout.tsx` calls `signOut`) inverts the convention into "any file in this whole route group that happens to call the verb must carry the guard's tags." The shortest path leaves a worse architecture. Refactor to a hook/util living in the existing scope (`components/auth/use-sign-out.ts`) so the guard's directional rule is preserved. **Why:** PR #3576 — see `knowledge-base/project/learnings/2026-05-11-drift-guard-scoping-extract-call-site-not-widen-walk.md`.
   - **When a migration changes a SECURITY DEFINER RPC's signature for which prod callers exist, prefer overloading (additive `CREATE OR REPLACE` with a new parameter list) over `DROP FUNCTION` + `CREATE`.** Postgres distinguishes overloads by parameter list; supabase-js sends named-arg PostgREST envelopes that route to whichever overload matches by parameter name. Overloading is rolling-deploy-safe: (a) prd-schema-without-app keeps the v1 signature alive for old pods; (b) prd-app-without-schema keeps writes succeeding because the v1 signature still exists. DROP+CREATE creates a window where one direction silently zeros the write path. Drop the v1 in a follow-up migration after the old build ages out. **Why:** PR #3626 — see `knowledge-base/project/learnings/2026-05-12-stub-handlers-as-silent-undercount-vectors.md`.
   - **When a grant-flip / SECURITY DEFINER migration mirrors a precedent that ships a paired `apps/web-platform/supabase/verify/NNN_*.sql` runtime sentinel, carry the verify sentinel forward in the SAME PR.** The migration-shape regex test asserts the GRANT *text* exists but cannot catch a *live* GRANT mismatch post-apply (`2026-05-06-tenant-jwt-rpc-grant-mismatch-vitest-blind.md`); the `verify/` sentinel (run by CI's `verify-migrations` job via `has_function_privilege('authenticated', …) = false`) is the only runtime guard for the privilege-escalation class. Cheapest gate: `ls apps/web-platform/supabase/verify/<precedent-NNN>_*.sql` when copying the precedent — if present, author the sibling `verify/<new-NNN>_*.sql` alongside the migration. **Why:** PR #4768 (#4765) — `verify/092` was forgotten at work-time and caught by `pattern-recognition-specialist` at review; see `knowledge-base/project/learnings/security-issues/2026-06-01-caller-override-rpc-needs-service-role-only-grant.md`.
   - **Stub event handlers ("wire in Stage N when X lands") in dispatcher/router code are silent telemetry-loss vectors.** A no-op handler that satisfies the type system, sits next to fully-wired siblings, and has no error path is invisible to skim-review and Sentry alike. Either throw `Error("handler not yet wired: <name>")` until the wiring lands, OR fan out to an instrumentation counter so a "stub still present" alert can fire. **Why:** same PR #3626 — `cc-dispatcher.ts:1202` `onResult` shipped as a no-op for 3 weeks (originally added 2026-04-24 #2858), under-counting API cost by 60-90% for every cc-soleur-go conversation while the legacy path's wiring made the surface look complete.
   - **When extending or mirroring a parallel runner/dispatcher/writer path (e.g., cc-dispatcher mirroring agent-runner), grep BOTH role-side persistence calls in the new path AND the reference path before declaring the implementation done.** If the new path has only one role's `from("messages").insert(...)` (or equivalent persist) and the reference has multiple, the asymmetry will land as a UI bug downstream via the resume hydration code (`api-messages.ts` → reducer state → `isClassifying`-style gates). Cheapest gate at work time: `grep -n "saveMessage\|messages.*insert" <new-path>` + `grep -n "saveMessage" <reference-path>`. Role-count parity must match or the divergence must be documented with rationale. **Why:** PR #3286 — cc-dispatcher persisted only the user role; agent-runner.ts:1079 persisted both; the gap surfaced as a "Continue thread" routing-chip regression that PR #3251 made visible. See `knowledge-base/project/learnings/integration-issues/2026-05-05-cc-dispatcher-assistant-persistence-asymmetry.md`.
   - **A new server→client WS turn-boundary lifecycle hook (turn-reset, per-turn binding, telemetry, cost gate) must be wired into BOTH turn-boundary entry points — the legacy fan-out (`sendUserMessage` → `dispatchToLeaders`) AND cc-soleur-go (`dispatchSoleurGoForConversation`), which `break`s before `sendUserMessage` and NEVER calls `registerSession`.** Any state normally populated by `registerSession` (e.g. a userId→conversationId binding) must be set explicitly on the cc path. Cheapest gate: `grep -n "<your new hook>\|resetTurn\|registerSession" apps/web-platform/server/*.ts` and confirm a call on BOTH lineages — cc-soleur-go is the dominant production path (#3270), so wiring only the legacy path silently breaks the feature for nearly all traffic with green CI. **Why:** PR #5290 (#5273) — `streamReplayBuffer.resetTurn` + the active-turn binding were wired only in `sendUserMessage`; cc gap-emitted frames were silently dropped (empty replay) until multi-agent review caught it. See `knowledge-base/project/learnings/integration-issues/2026-06-14-ws-lifecycle-hook-must-cover-both-legacy-and-cc-soleur-go-turn-boundaries.md`.

5. **Test Continuously**

   - **RED**: Write a failing test before implementing any new behavior
   - **GREEN**: Write the minimum code to make the test pass
   - **REFACTOR**: Improve code while keeping tests green
   - **Unless the project has no CI-enforced full-suite gate on the merge branch — see **Full-suite fallback** in Phase 2 §9, and take that branch when in doubt — run the touched-file suites after each RED/GREEN/REFACTOR cycle** rather than the whole battery per cycle. When running test suites via Bash, always capture both failure details AND summary in a single run — use `grep -E "(FAIL|ERROR|Test Files|Tests )"` or `| tail -30`, never `| tail -10` which discards failure names and forces a wasteful second run. **Why:** In PR #2430, `| tail -10` discarded failing test names, requiring a full re-run just to identify which 2 of 1580 tests failed.
   - The agent harness's `bash -c` does NOT inherit `set -o pipefail`, so `bash <test-script> 2>&1 | tail -N` reports `tail`'s exit (always 0) and silently swallows the test runner's non-zero exit. For aggregate test scripts whose pass/fail signal is load-bearing ([scripts/test-all.sh](../../../../scripts/test-all.sh), `bun test`, `pytest`, `go test ./...`), prefer `log=$(mktemp -t <script>.XXXXXXXX.log); bash <script> > "$log" 2>&1; rc=$?; echo "EXIT=$rc LOG=$log"` and inspect `rc` explicitly; only then `tail` or `grep` `"$log"` for context. **Why:** PR #4011 — a `bash test-all.sh 2>&1 | tail -40` invocation reported exit 0 while the runner exited 1 (3 pre-existing failed suites); the false-pass nearly chained through to ship. See `knowledge-base/project/learnings/2026-05-18-test-all-tail-masking-and-monitor-exit-condition-tightness.md` §1.
     - **Never read a suite's verdict from an `echo` that contains a command substitution — it reports the SUBSTITUTION's status.** `echo "$(basename "$t") EXIT=$?"` runs `basename` while expanding the arguments, and that resets `$?`, so the line prints `basename`'s exit code and not the suite's. Measured: a four-suite loop written that way reported EXIT=0 for all four while one was `rc=1` from the author's own edit, and the only tell was a `FAIL` line in a log belonging to a suite just recorded as passing. Same family as the `| tail` masking above and as `cmd | grep …; echo "rc=$?"` (which reports `grep`'s status and will happily hide a non-fast-forward `git push`). Capture into a variable on its own line — `bash "$t" > "$log" 2>&1; rc=$?` — before any expansion that can run a command, and prefer `PIPESTATUS[0]` when a pipeline is unavoidable.
     - **A mutation battery whose CONTROL row is empty is VOID, not passing — read the control before reading any mutant.** The extraction pipeline is an instrument in its own right and needs its own self-test, upstream of the suite's (`ASSERT THE VALUE THAT MUST NEVER APPEAR…` above covers the suite's helpers, not the harness that parses their output). Strip ANSI first — `sed -r 's/\x1B\[[0-9;]*[mGKHF]//g'` — because vitest and most modern runners colour their summary line, so a `grep`/`awk` extraction anchored on plain text returns empty for EVERY row and each mutant then reads as killed-or-survived arbitrarily. Measured: #7826/#7834 — a 14-row battery came back uniformly empty, control included, and looked like a completed run; re-run ANSI-stripped it showed 14 surviving mutants.
   - **`rc` is the verdict, not the whole report — read the runner's PREAMBLE and EPILOGUE before calling it green.** `rc=0` answers "did every suite it ran pass?", never "did it run the suites that gate my diff?" nor "was this run trustworthy?". **Non-zero is no longer one thing, either:** `test-all.sh` exits `1` when >= 1 suite FAILED and `3` when zero suites failed and >= 1 suite terminated with a signal-shaped status (rendered `[KILLED]`, plus a one-line breakdown immediately above the terminal marker) — UNRESOLVED, neither green nor red, because that suite's coverage was not obtained and the runner did not measure what terminated it. Never report `rc=3` as a broken diff, and never report it as a pass; re-run that one suite in isolation instead. `test-all.sh`'s own `EXIT CONTRACT` block is where that contract lives — including that `3` is top-level only, so a nested runner handing 3 back to `run_suite` classifies as a plain FAIL. Aggregate runners answer those two in prose the exit code cannot carry, and it sits at BOTH ends of the log: `test-all.sh`'s preamble announces whether `apps/web-platform/infra/` will be covered by this run and `tc_preamble`/`tc_acquire` fire the named `LOW_TMP_HEADROOM` / `SIBLING_RUN_DETECTED` / `SIBLING_SUITE_DETECTED` / `LOCK_CONTENDED_PROCEEDING` banners, while the tail carries the suite summary, `tc_epilogue`'s scratch-leak `NOTE`, and a restatement of the coverage boundary. A `tail -40` sees the second set and none of the first. So after reading `rc`, `grep -nE 'NOTE:|NOTE \(|\[contention\] BANNER|LOCK_CONTENDED|^\[budget\]|^\[KILLED\] [^ ]+ \(exit=' "$log"` (position-agnostic, catches both ends; the `[contention] BANNER` prefix is what makes the banner list above a description rather than a hard-coded set, so a newly added banner is caught without editing this grep; anchored on the emitter prefix so the contention suite's own `[ok] … names the LOW_TMP_HEADROOM condition` assertion lines are not mistaken for banners, and on `LOCK_CONTENDED` rather than `LOCK_` so the happy-path `LOCK_ACQUIRED` line does not make "act on every hit" fire on every run) and READ every hit — since #7103 R5(a) the coverage NOTE has two polarities, so a hit is not automatically a warning: `IS covered above` means the infra runner ran as a nested suite and `rc=0` accounts for it, while `is NOT covered above` (wrong `TEST_GROUP`, diff does not touch it, or `SOLEUR_INCIDENT_SKIP=1`) means `rc=0` is evidence for less than the diff. A contention banner still means a RED may not be yours; a `^\[KILLED\]` hit is the unresolved class described above, so re-run that suite alone rather than reading the summary as either colour. **Why:** #6969 — an infra diff was validated by the wrong runner behind a green summary that had already been qualified elsewhere in the same log.
     - **A STRUCTURAL/PARSE error from a fail-closed gate is not noise on the way to the real output — it IS the gate reporting that it stopped looking.** A linter that fails closed on malformed input emits a message about the INPUT (`unterminated code fence`, `could not parse`, `invalid YAML`), which reads like a formatting nit and gets cleared reflexively — while the scan it was meant to perform never ran, so its silence on everything else is not a clean bill. Treat the post-fix re-run as a FIRST result, never as a confirmation of the one you already had. The tell is a gate whose failure names a property of the file's SHAPE rather than of its CONTENT. **Why:** #7640 — a Python splice across a fence boundary left a plan block unterminated; `lint-infra-no-human-steps.py` reported the fence and scanned nothing, and repairing it surfaced a real AC-level finding that had been invisible behind the "structural error" for the whole session.
   - **A GitHub Actions `run:` block with no `shell:` key runs under `bash --noprofile --norc -eo pipefail {0}` — `-e` is ON, and `set -uo pipefail` inside the block does NOT clear it.** Two consequences that read as correct code and are not. (a) `X="$(cmd)"; rc=$?` is an ABORT, not a capture: the assignment's status is the command substitution's, so a non-zero `cmd` kills the step AT THAT LINE and every line below — the `$GITHUB_OUTPUT` write, the `::error::`, the whole diagnosis — never runs. Use `rc=0; X="$(cmd)" || rc=$?`. The same applies to any unguarded `X="$(… | grep …)"`: `grep` exits 1 on no match and `pipefail` promotes it, so a legitimately-empty result silently kills the step, typically between detecting a failure and REPORTING it. (b) Actions `==` is **LOOSE**: when operand types differ both cast to Number, so an UNSET step output is `null`, `null == '0'` is **TRUE**, and a step gated on `outputs.exit_code == '0'` fires on a producer that wrote nothing at all. Pair every numeric-literal gate with a non-numeric discriminator, and prefer `!cancelled()` to `always()` when the step mutates anything (`always()` is also true on cancellation). **Corollary for the tests:** a harness that extracts a `run:` block and executes it under a bare `bash` cannot observe ANY of this — run extracted blocks under `bash --noprofile --norc -eo pipefail` or the whole `set -e` class is structurally invisible to a green suite. **Why:** #7242 — a fix for "messages naming unmeasured causes" shipped three instances of it: the alarm's `always()` restoration was inert (the producer aborted before writing the outputs its conditions read), a new telemetry read silenced the bridge failure it existed to explain, and an auto-close fired on a crashed checker via the `null == '0'` coercion. All three were green through a 261/261 suite. See `knowledge-base/project/learnings/2026-08-04-the-pr-that-fixed-unmeasured-claims-shipped-three-of-them.md`.
   - **Verify the INSTRUMENT before reading its output, and put its artifacts somewhere durable.** An instrument's output arrives already shaped like an answer, so a broken one does not error — it *answers*. Four mechanical checks, seconds each: (a) run the UNMUTATED control **in the harness that will run the rows** and require GREEN — a sandbox that copies a subtree can strip the repo context a suite needs, and every row then prints KILLED/SURVIVED against a red baseline; (b) assert each mutation LANDED against a pristine copy (`md5sum` before/after), because a mutation that does not land reports the BASELINE, which is indistinguishable from a pass — note `sed` with a `|` delimiter silently fails on patterns containing `||`, and `cp -r dir "$sb/"` copies INTO an existing dir rather than over it, so row N re-runs row N-1's mutation; (c) give every search-based assertion a non-empty guard that ABORTS (`assert hits, "INSTRUMENT FOUND NOTHING"`) — a zero-match search that prints a verdict is the worst outcome, and BRE `\$` is a literal `$` while an unescaped `(` will not match a regex the SUT escapes. **A search tool's non-zero exit is not one thing:** `git grep` exits 1 on no matches and 128 on a BAD PATTERN, and since it defaults to BRE an escaped open-paren is a group, so an ERE-shaped literal is a fatal — a `catch` that returns `[]` for both converts a broken pattern into a clean sweep. Match literals with `-F`, and branch on the exit code rather than on truthiness (#7989: four SDK send sites sat outside a guard's window while it reported clean); (d) never take an exit code through a pipe. **Artifacts: `/var/tmp` or the worktree, never `/tmp`** — it is actively reaped here, and the reap takes restore SOURCES and rc files, not just logs. A monitor must test `-s` on an rc file, not `-f`: an empty rc is truncation, not a verdict. **Why:** #7795 — six instruments broke in one session and three printed confident verdicts from results that measured nothing; separately `/tmp` swept a battery's restore source (leaving a tracked file mutated) and later truncated a 374-suite log at exactly 114688 bytes while emptying its rc, and the second hit happened because the first fix was applied to the instance and not the class. See `knowledge-base/project/learnings/2026-09-08-six-instruments-were-broken-and-three-printed-a-verdict-anyway.md`.
   - **A POSITIVE CONTROL THAT SHARES THE SUSPECT FILTER IS NOT A CONTROL — put it on the far side
     of the narrowing, and when a query returns zero, re-run it UNNARROWED and diff the counts.**
     The instrument-verification rule above says run a known-positive; this is the half that
     decides whether the known-positive can see anything. A control built by copying the failing
     command and changing only its subject inherits every flag the command carries, so it proves
     the transport works and is structurally blind to a filter biased against the one class under
     test — and it reads as diligence in the write-up, which is why nobody re-checks it. Ask per
     control: *which narrowing does this share with the assertion, and what would it return if that
     narrowing were the bug?* **Why:** #8017 — `betterstack-query.sh` implements `--raw-only` as
     `raw NOT LIKE '%SYSLOG_IDENTIFIER%'`, excluding EVERY journald row by construction; a probe
     query carrying it returned 0 rows over 36h and 57 without it, and the conclusion drawn from
     that zero ("this host does not deliver via its logger line") was written into a plan, a spec
     and a commit message. Both positive controls run to "verify the instrument" carried the same
     flag. See `knowledge-base/project/learnings/2026-09-10-every-instrument-i-verified-was-verified-inside-its-own-blind-spot.md`.
   - **`mktemp`, never a name derived from the script.** A path built from the script name is a pure function of it, so every concurrent session writes the SAME file — and parallel worktrees are this repo's documented workflow. Observed 2026-07-15: a full-suite log was truncated mid-run by a sibling session and came back holding a DIFFERENT worktree's absolute paths; `rc` was still correct, but the log — the artifact you read to learn WHICH suite failed — was someone else's. Echo `LOG=$log` so the path is recoverable, and keep `rc` as the pass/fail signal: a clobbered log costs a re-run, but reading a SIBLING's green log and concluding your own run passed is the failure that ships. `$$` is not a fix either (predictable across concurrent runs in shared shells — `token-efficiency-report.sh:36-56` rejected it for the same reason). Use a workspace/git-dir-scoped path instead only when a LATER, separate Bash call must find the artifact by name.
   - **A harness that fails to SET UP must abort, never continue — and a self-invoked `.test.sh` must default `TMPDIR` itself.** A sandbox/fixture harness (mutation battery, golden-parity runner, anything that `cp`s a pristine tree per case) that ignores its own `cp`/`mkdir`/`mktemp` exit code does not degrade into a missing result; it degrades into a CONFIDENT WRONG one — the next case runs against the PREVIOUS case's mutation and reports "the guard did not detect this", which is a verdict about the SUT produced by a harness that could not copy a file. Check every setup command and `exit 2` on failure. The trigger is usually capacity, not logic: `/tmp` is a machine-global 4 GiB tmpfs shared by parallel worktrees, and [scripts/test-all.sh](../../../../scripts/test-all.sh) + `run-registered-suites.sh` default `TMPDIR=/var/tmp` while a DIRECT invocation of the suite — the documented inner loop while editing the thing under test — inherits the bare `/tmp`. Add `export TMPDIR="${TMPDIR:-/var/tmp}"` to any `.test.sh` that builds a sandbox, or its verdicts become a function of another session's disk usage. Litmus for the flake: three runs of an UNCHANGED tree giving three DIFFERENT failure sets is a harness defect, never a re-run to repeat. **Why:** #7014 — at `/tmp` 94% full under 3 sibling runs the battery reported P6, then M29a/b/c, then clean; 4/4 green under identical conditions after both halves were fixed.
   - When running test/lint/budget commands from inside a worktree pipeline, chain `cd <worktree-abs-path> && <cmd>` in a single Bash call, or use absolute paths. CWD *does* persist between Bash calls, but relying on ambient CWD is fragile: any intervening call that `cd`s elsewhere (a prior `cd "$(mktemp -d)" && git clone ...`) silently redirects everything after it to the wrong tree — and in this repo the bare repo root holds stale synced copies of tracked files, so the failure surfaces as wrong pass/fail counts that look like real regressions rather than a missing-file error. **Why:** PR #2683 `bun test` reported 1005/1 (baseline-state result) from bare root after a *drifted* CWD; worktree re-run was 1006/0. See `knowledge-base/project/learnings/bug-fixes/2026-04-19-admin-ip-drift-misdiagnosed-as-fail2ban.md` session errors.
   - **The invariant a suite enforces on the SUT applies to the HARNESS — grep the test file for the shape it forbids.** A suite written to forbid `X | grep -q` under `pipefail` (SIGPIPE → 141) used exactly that at 12 of its own assertion sites; on every NEGATIVE assertion (`if ! calls | grep -q …`) the 141 fails **OPEN**, so those cases and their mutation twins reported green while the property was violated — and the same shape in the `undef()` vacuity guard (whose sentinel is line 1, so the match is *always* early) let "must exit non-zero" cases pass against a script where the functions did not exist. Predicates must grep a FILE directly (`grep -qE -- "$pat" "$CALLS"`) or use bash `[[ == ]]`; never a pipe. Two adjacent vacuity traps from the same session: a multi-MB fixture passed via `env` exceeds the argv limit (E2BIG) so the subshell dies BEFORE any precondition (pass large fixtures by file), and a stub ending in `return 0` swallows the producer SIGPIPE its mutation exists to reproduce (`return $?`). Give every happy-path case a `CASE_RC` positive control — assertions that read a calls-file populated before an unrelated `die` pass without the function ever succeeding. **Why:** #6588 — the class was documented one day earlier and recurred in the file meant to pin it. See `knowledge-base/project/learnings/2026-07-19-the-harness-broke-the-rule-it-enforced-and-the-canary-could-not-fail.md`.
   - When AUTHORING a `set -euo pipefail` accumulate-then-`exit` `.test.sh` (the `pass()`/`fail()`/`fails` convention), three foot-guns pass a naïve GREEN run and only bite on drift/edge inputs: (a) a deliberately-nonzero command (`diff`/`grep`/`comm`) inside a command substitution (`x="$(diff <(…) <(…) | tr …)"`) makes `set -e` abort BEFORE `fail()` prints — wrap it `… || true` inside the `$(...)` and gate on `[[ -z "$x" ]]`; (b) a loop derived from a data source (registry/array/file via `done < <(node …)`) silently `exit 0`s with ZERO coverage on an empty/unreadable source — process-substitution failure escapes `set -e`, so add a minimum-cardinality guard (`n=0; …; n=$((n+1)); …; [[ "$n" -lt 1 ]] && fail`); (c) a verify-the-verifier negative that injects a CONSTANT guaranteed-absent tests the coreutil (`diff(A+x,A)≠∅` is always true), not the gate — route real data through the production idiom and assert the injected token on the specific diff side. Mutation-test every drift class and confirm a CLEAR MESSAGE prints, not just `exit 1`. **Why:** PR #5721 (#5703) — all three surfaced authoring `registry-completeness.test.sh`; (a) caught by self-mutation-test (truncated log), (b)+(c) by multi-agent review. See `knowledge-base/project/learnings/test-failures/2026-06-29-bash-accumulate-then-exit-gate-test-three-footguns.md`.
   - In a `set -o pipefail` gate, `producer | grep -q PATTERN` flakes to a FALSE NEGATIVE when the match is EARLY and the producer streams (`sed` from a `strip_comments`, or a `printf` of a >64 KB body): `grep -q` closes the pipe on first match, the producer takes SIGPIPE (141), and `pipefail` makes the pipeline exit non-zero *even though grep matched* — so `&& echo 1` is skipped. Invisible on the first green run and on append-mutations (match lands at EOF, no early close); only a `holds` pattern matching near the top flakes. Use a herestring (`grep -Eq PATTERN <<<"$var"` — no pipe) or `grep -Ec` (`[ "$(… | grep -Ec PATTERN || true)" -gt 0 ]` — reads all input), never `grep -q` on a pipe. **Why:** #6649 — `workspaces-luks-header.test.sh` H4/H11 passed then failed with no change. See `knowledge-base/project/learnings/test-failures/2026-07-18-pipefail-grep-q-early-match-sigpipe-flakes-drift-guards.md`.
   - **A test that RELOCATES a script must carry every file that script resolves via `${BASH_SOURCE[0]}` — and a FAIL-CLOSED source turns the omission into a whole-suite RED, not a skipped check.** Sandbox harnesses `cp` the SUT to a temp dir, which silently breaks `$(dirname "${BASH_SOURCE[0]}")/lib/…`; a defensively-sourced lib degrades to stubs and hides it, a fail-closed one `exit`s before the first assertion, so every arm then measures that guard firing rather than the thing under test. Grep `BASH_SOURCE` in the SUT and copy each resolved file into the sandbox. Companion: **a probe of a helper that is SOURCED into a stricter shell must reproduce that shell's `set` options** — `x=$(cmd | awk …)` is a PIPELINE whose status is `awk`'s, and `awk` exits 0 on empty input, so the producer's failure is invisible without `pipefail`; without it the "degrades, never aborts" mutation is EQUIVALENT and the arm passes over it. **Why:** #7441 — a mid-review rebase added a third relocator and left the branch at 39/31 in an untouched suite; and a `df`-failure arm passed over its own fix until the probe ran under `set -o pipefail`.
   - **MEASURING this race (or any race) requires pinning the binary, and a "refuted" result needs a positive control.** In an agent Bash session `grep` resolves to a **ugrep shim shell function** whose `-q` DRAINS its input — the producer never blocks, so the race cannot occur and it measures 0/N at every size. That is not evidence of absence; it is the instrument removing the phenomenon. Run race probes under `env -i PATH=/usr/bin:/bin bash --noprofile --norc`, assert `type -t grep` is `file`, and include something that MUST fail (`yes | grep -q y` → `141`) — if the control does not fire, the measurement is void, not clean. The threshold is the 64 KiB pipe buffer, so a bash **builtin** `echo` races too above it (30/30 at 128 KB), and the sweep pattern must be `-[A-Za-z]*q` — the obvious `-q` form misses `-Eq`/`-iq`/`-Fq`. **Why:** #6992 — a plan-phase measurement under the shim reported 0/40 and declared the filed defect REFUTED, re-scoping the whole task around an inverted conclusion; the real hook denied 0 of 30 on a 128 KB body. See `knowledge-base/project/learnings/2026-07-27-my-refutation-measured-a-shim-and-my-safe-fixture-hid-12240-deletions.md`.
   - A golden-parity / "compare new-impl vs old-impl" `.test.sh` must pin its OLD baseline to a **frozen committed fixture** (`test/fixtures/legacy-*.sh`), NEVER to `git show <mainbranch>:<the-file-this-PR-replaces>` — that file BECOMES the new implementation at merge, so the baseline flips and the suite reddens on `main` for the next contributor (green on the branch, a merge-time time-bomb). Same class as the worktree-vs-main-CWD trap. Also pin `LC_ALL=C` on any `sort`+`comm` set-diff (a locale sort makes `comm` read its input as unsorted → undefined diff → the parity guard runs blind). **Why:** #5987 — Test 5a/9 fetched `git show main:redact-sentinel.sh` as the "old engine"; caught by `code-quality-analyst` at review. See `knowledge-base/project/learnings/test-failures/2026-07-05-parity-baseline-must-not-be-git-show-main-of-the-replaced-file.md`.
   - For a `run_in_background: true` Bash whose body is `<cmd> > /tmp/log 2>&1; echo "EXIT=$?"`, the harness's task-completion notification reports the exit of the **trailing `echo`** (always 0) and the real command's output landed in `/tmp/log`, NOT the background task's output file — so a "completed (exit code 0)" notification is NOT proof the command passed. Either drop the redirect (let the bg output file capture stdout/stderr directly), or ALWAYS grep the redirected log for the runner's own summary (`vitest "Tests N failed"`, `playwright "N failed"`, `Failed to compile`) before trusting a background pass. **Why:** #5512 — `next build` and the full `vitest run` both reported a misleading bg "exit code 0" while the redirected logs held the real failures (a build route-table line and 3 Doppler-env flakes respectively).
   - When the project pins a test runner via `devDependencies` (e.g., `vitest@3.2.4`), invoke `./node_modules/.bin/<tool>` — never `npx <tool>`. `npx` resolves to its own cache and silently major-version-jumps (e.g., installing vitest 4.x against a vitest 3.2.4 config), producing `Could not resolve 'vitest/config'` and `Unexpected JSX expression` parse errors that look like real regressions. **Why:** PR #3186 — `npx vitest` installed 4.x and rolldown rejected the project's JSX config; switching to `./node_modules/.bin/vitest` (3.2.4) restored a passing run. See `knowledge-base/project/learnings/2026-05-04-plan-precedent-search-must-include-lib-helpers.md` session errors.
   - Fix failures immediately -- never move to the next task with failing tests
   - **A non-zero failure count in a run YOU initiated is a stop condition, not a data point — `hr-when-a-command-exits-non-zero-or-prints` applies hardest to your own test output.** The rule is reflexively applied to other people's commands and reliably skipped for a suite you are mid-way through authoring, because a failure there *feels* like a work-in-progress state rather than a finding. It is not: a fixture can be failing because it is structurally incapable of passing, and once the suite goes green for unrelated reasons that fixture is invisible forever. Diagnose before the next action, even mid-edit. **Why:** #7109 — a run printed `Tests 3 failed | 25 passed` on a just-added guard and the failure was carried past; `test-design-reviewer` later found all three fixtures used one-liner hook bodies (`beforeAll(async () => {}, 60_000);`) that the parser drops for want of a closer line, so they asserted nothing and were rated P0. See `knowledge-base/project/learnings/2026-07-31-i-reported-a-live-suite-as-finished-and-three-more-results-i-asserted-instead-of-measuring.md`.
   - When a class becomes hard to test (too many dependencies), extract an interface and inject dependencies. See the `/atdd-developer` skill for detailed TDD guidance.

6. **Infrastructure Validation**

   When any task modifies files in `apps/*/infra/`, run these checks after each change (in addition to or instead of the app test suite):

   **Renaming a Terraform resource's ADDRESS is a destroy + create, and `RequiresReplace` does not license it.** Before concluding a rename is "update-only", check which of the two you actually reasoned about: a plan-modifier governs a resource at a FIXED address, while an address leaving the configuration is a delete at the old name plus a create at the new one, whatever the provider schema says. Add a `moved {}` block (precedent: `dns.tf`, `placement-group.tf`) and VERIFY by feeding the plan through the repo's own destroy-guard filter rather than re-asserting the claim — measured `resource_deletes: 1, resource_creates: 1` without the block, `0/0` with it. The cost of getting it wrong is more than a blocked gate: on a full-root auto-applied root the delete lands before the create, so an acked plan discards the live resource's history and opens a coverage window with no rollback. **Why:** #7798 — the plan reasoned from `name` being an in-place attribute; four review agents converged and `sentry-destroy-required` would have blocked the PR on the monitor the change existed to repair. See `knowledge-base/project/learnings/2026-09-07-my-guard-pinned-the-three-attributes-the-vendor-already-refuses.md`.

   1. **cloud-init schema**: For each modified `cloud-init.yml`:
      `cloud-init schema -c <file>` -- validates YAML syntax AND cloud-init schema in one step. Warnings about missing datasource are expected; only non-zero exit codes are failures. If `cloud-init` is not installed locally, warn and continue.
      - **When the `cloud-init.yml` is a Terraform `templatefile()` (interpolated in `server.tf`/`git-data.tf`/etc.), `cloud-init schema` on the RAW file always fails on the un-rendered `${...}` — validate the RENDERED output instead.** Render via `terraform console` (`printf 'templatefile("<abs>", { <full var map> })\n' | terraform -chdir="$(mktemp -d)" console`), strip the `<<EOT … EOT` wrapper, then `cloud-init schema -c <rendered>`. In the source template, shell `${VAR}`/`${VAR:-x}` must be `$${...}` (double-dollar escapes the TF interpolation) and `%{` must NOT appear at all — **including inside comments** (TF's directive scanner does not skip prose). Run the render after every edit; it catches both escaping traps. See `knowledge-base/project/learnings/best-practices/2026-07-14-cloud-init-templatefile-escaping-and-ci-deploy-payload-testing.md`.

   2. **Terraform format**: For each infra directory with modified `.tf` files:
      `terraform fmt -check <dir>` -- exit 0 means formatted; exit 3 means violations. Fix with `terraform fmt <dir>`.

   3. **Terraform validate**: For each infra directory with modified `.tf` files:
      `terraform init -backend=false` then `terraform validate` -- catches HCL syntax errors and undefined references without requiring provider credentials.

   4. **Field-type verification for version-pinned providers**: when writing HCL for a provider pinned below the registry's latest major (e.g., `cloudflare ~> 4.0`), verify field TYPES via `terraform providers schema -json` from a SCRATCH dir pinned to the exact version (minimal `required_providers` main.tf + `terraform init`) -- `validate` silently coerces wrong primitives (v4 `cloudflare_list` redirect items take `"enabled"`/`"disabled"` STRING enums, not booleans; registry/context7 docs show the latest major's syntax). The scratch dir matters: `providers schema` inside the real infra dir demands full backend init. **Why:** PR #5082 -- drafted booleans validated green and would have failed only at apply, behind a BLOCKING token-widen step that would have masked the diagnosis. See `knowledge-base/project/learnings/2026-06-09-cloudflare-bulk-redirects-v4-schema-and-phase-order.md`.

   These checks replace the "tests may be skipped" exemption for infra files. If any check fails, fix before proceeding to the next task.

   - **Before adding a systemd sandboxing directive, enumerate what it changes about the namespace the unit's own script OBSERVES.** `ProtectSystem=strict` makes a writable subdirectory expressible only as a **separate mount**, so systemd bind-mounts every `ReadWritePaths=` entry onto itself — and any in-script predicate reading mount state then answers about the sandbox rather than the host. Measured: `ReadWritePaths=/mnt/data` made `mountpoint -q /mnt/data` return true for a `nofail` volume that never mounted, disarming the gate that refuses an irreversible `FLUSHALL` when the durability latch cannot be recorded. Use a predicate the bind cannot forge (device-vs-parent `st_dev`; a bind mount preserves `st_dev`). Same class: `PrivateTmp=true` gives a fresh private `/tmp` per start, so any on-disk fallback cache whose config dir lives under `/tmp` becomes per-invocation — check the unit's own comments do not still describe that cache as buying resilience across fires. And do NOT reach for a `-` prefix to dodge a missing directory: it leaves the path read-only under `ProtectSystem=strict`, so the script's `mkdir -p` fails on every fire instead. Use `StateDirectory=` for paths systemd should create, and name the MOUNTS in `ReadWritePaths=`, never the subdirectories the script creates inside them. **Why:** #7761. See `knowledge-base/project/learnings/security-issues/2026-09-03-the-hardening-i-added-disarmed-the-guard-over-the-flushall.md`.
   - **`terraform validate` passing says nothing about `terraform test`, and a comment-only infra edit can force an SSH re-provision of a LIVE host.** **ADDING OR REMOVING A ROOT VARIABLE IS THE SECOND TRIGGER, and it is the one the wording above does not name:** `terraform test` must resolve EVERY root variable before it evaluates any run block, so a new `sensitive`/no-default var breaks every existing run block with `has a required variable "<name>" with no set value` — while `validate` stays green throughout, because it never resolves variable VALUES. Also run `fmt -check -recursive`: bare `fmt -check <dir>` does NOT recurse, so a `tests/` directory is never inspected. And BEFORE adding a root variable at all, grep the root for a `local`/`resource` that already produces the value — re-importing one Terraform already owns re-creates the staleness the bake exists to avoid (a `-replace=random_password.X` writes the new value downstream while `templatefile` renders the OLD `TF_VAR_` env snapshot) and puts a whole-apply failure mode in front of every merge, since root variables resolve before `-target` pruning. **Why:** #7462/PR #7516 — two `TF_VAR_zot_pull_*` were added for `local.zot_pull_user` + `random_password.zot_pull` declared in the SAME root; it broke every run block, and only CI caught it. Two blast radii `validate` cannot see. (a) When the diff adds a `lifecycle.precondition`/`postcondition` — or any assertion on a **provider-computed** attribute — run `terraform test`: `mock_provider` synthesizes a RANDOM STRING for computed attributes, so `data.<x>.architecture == "x86"` fails EVERY run block with a message naming a vendor anomaly that never happened. Fix with `override_data`, then prove the pin does not DISABLE the guard (flip the var to the bad value; it must still red). (b) Before editing ANY infra file — including a comment — `git grep` its basename against every `triggers_replace` / `filesha256`: those hash file BYTES, so a citation fix on a script feeding a `terraform_data` trigger destroys+creates it, and if that resource sits in the per-merge `-target=` list on a step with no destroy-guard, merging root-SSHes the live serving host. **Why:** #6570 — the precondition red-lined `infra-validate-required` unnoticed (Phase 6 ran only `validate`), and a `web-git-data-probe.sh` comment edit would have re-provisioned web-1. See `knowledge-base/project/learnings/2026-07-27-my-battery-was-green-because-it-only-tested-the-two-endpoints-not-the-wire.md`.

   - **A fail-closed guard deciding on a LIVE API response must treat that response as adversarial input — a `200` is not proof of shape, and a `404` is trustworthy only per-URL-scheme.** Two fail-open seams recur: (a) a count over `.result.<field>` written as bare `jq '… | length'` reads a degraded/error `200` body (`{"result":null}`, `{"success":false}`, or a missing key) as `0 == empty == PASS` — jq's `null|length` is `0` and `jq -e` exits 0 on numeric `0`, so the intended "unparseable → fail-closed" branch never fires; gate on `if type=="array" then length else error` first. (b) a control probe that proves "`404` means empty" validates only the URL scheme it actually ran against — if the gate builds two schemes (`zones/$z/…` AND `accounts/$a/…`), a zone-only control leaves the account `404` seam wide open; add a per-scheme control (e.g. `GET accounts/$a/rulesets`, memoized) requiring `200` before trusting that scheme's `404`. And every fail-closed branch (incl. `known-after-apply` null URL fields serialized as `null` in the plan JSON) needs an isolating fixture that goes RED when only that guard is neutered. **Why:** #6767/PR #6833 — a green 31-assertion suite shipped both seams + 3 vacuous fail-closed branches; caught only by the multi-agent review panel. See `knowledge-base/project/learnings/2026-07-23-live-api-fail-closed-guard-counts-degraded-200-as-empty-and-control-probe-must-cover-every-scheme.md`.

   - **`[^\n]` in a POSIX ERE is a bracket expression excluding backslash and the LETTER `n` — not "any char but newline".** grep is line-oriented, so `.*` is the correct "rest of the line". The failure is silent and asymmetric: `grep -qE 'curl[^\n]*-m'` cannot cross `-o /dev/null` (it contains an `n`), so the POSITIVE assert fails loudly (fixable) while every NEGATIVE assert (`! grep -qE '…[^\n]…'`) passes VACUOUSLY — a broken regex never matches, so the guard reports clean forever. Sweep any `[^\n]` in a `.test.sh`/`toMatch` and replace with `.*`; mutation-test each negative assert to prove it can still fail. **Why:** #6537 — four structural asserts were silently unmatchable and two were vacuous negatives.
   - **In a Terraform `templatefile`, `%{` is a DIRECTIVE and must be escaped `%%{` — including inside comments** (the directive scanner does not skip prose; `hr-when-a-plan-specifies-relative-paths-e-g`'s sibling for template escaping). So a `curl -w '%{http_code}'`, a printf `%{...}`, or a doc comment quoting one makes the render fail outright — caught only by rendering, never by `terraform validate` on the `.tf`. Pair with the existing `$${VAR}` shell-escape rule and re-run the render (`.github/scripts/validate-infra-templates.sh <infra-dir>`) after every template edit. **Why:** #6537 — `-w '%{http_code}'` in `cloud-init-registry.yml`.
   - When a `terraform_data` remote-exec `inline` block carries assertions or probes, `"set -e"` MUST be the first element — terraform joins `inline` into ONE script with NO implicit errexit and fails only on the LAST command's exit, so any assert before a trailing `echo` is decorative (silent-green). `!`-prefixed pipelines are errexit-EXEMPT: write enforcement probes as explicit `if cmd; then echo FAILED; exit 1; fi`. **Why:** PR #5089 — the cron-egress provisioner's "merge-precondition" probes couldn't fail the apply; 5 review agents concurred; sibling sweep tracked in #5101.
   - When writing a token-anchored drift guard over HCL, match attributes with `attr[[:space:]]*=[[:space:]]*` (never single-space `attr = `) — `terraform fmt` re-aligns equals signs when a block gains a second attribute, silently blinding the guard to new blocks. Mutation-test by APPENDING a synthetic violating block (fmt-aligned, un-gated) to a copy, not just by removing a token from an existing well-formed one; and enumerate the invariant's targets across the whole infra directory's `*.tf`, not only the file the issue names. **Why:** PR #5132 (#5101) — `inline = \[` false-PASSed a fmt-aligned ungated block, and a same-defect block sat un-swept in sibling ci-ssh-key.tf; both caught only at multi-agent review. <!-- markdownlint-disable-line MD038 -->
   - When a test encodes a cross-file numeric inequality (budget/window drift guards), extract EVERY operand by shape from its source file — exactly-one count check + `^[0-9]+$` validation per extraction, region-scoped when the pattern recurs (awk range over the owning unit/function); a single hardcoded term re-creates the silent-drift class the guard exists to catch. For `${N:-DEFAULT}` shapes take `tail -1` of the digit runs (the default is the LAST run, not the first). **Why:** PR #5146 (#5145) — a copied `+180` TimeoutStopSec literal left one of three files unguarded; 4 review agents concurred. See `knowledge-base/project/learnings/2026-06-11-cross-file-drift-guards-extract-every-operand-by-shape.md`.
   - When cloud-init has `lifecycle { ignore_changes = [user_data] }`, changes to cloud-init templates are never applied to existing servers. Use a `terraform_data` provisioner with `remote-exec` to bridge the gap. Verify systemd services use `EnvironmentFile=` directives (not `/etc/environment`) for token injection.
   - When fixing syscall-level issues in Docker containers, test with `--privileged` first to establish a working baseline, then remove privileges one at a time. Docker's seccomp `includes.caps` is compile-time (evaluated when building BPF filter), not runtime -- processes gaining capabilities inside user namespaces do NOT gain access to capability-gated seccomp rules.
   - When a `terraform_data` provisioner writes a systemd unit or config file via `remote-exec` heredoc, extract the content to a standalone file and use `file()` in both `triggers_replace` and a `file` provisioner. Inline heredoc strings desync from the trigger hash -- partial strings in `triggers_replace` silently skip re-provisioning when the unit content changes.
   - When CLONING a `file` provisioner block, the destination's parent-dir existence is part of the precedent's ENVIRONMENT, not its copied HCL — Terraform's `file` (scp) does NOT create remote parents. Before reusing a `file`-provisioner shape, ask "what guarantees the destination dir exists on the host?"; if the precedent relied on a package (`/etc/fail2ban/jail.d/`) or an earlier provisioner creating it, add a `mkdir -p` `remote-exec` BEFORE the `file` provisioner. `/etc/<tool>.conf.d/` drop-in dirs are the recurring trap — many are NOT shipped by the base package (e.g. `/etc/systemd/journald.conf.d/` is absent on Ubuntu). `terraform validate`/`fmt` + static tests pass green; the failure is apply-time scp `No such file or directory`. **Why:** PR #4800 (#4792) — cloned `disk_monitor_install`'s shape but the journald drop-in dir didn't exist; caught at review. See `knowledge-base/project/learnings/integration-issues/2026-06-02-cloned-ssh-file-provisioner-does-not-inherit-target-dir-guarantee.md`.
   - When adding or removing files from a `triggers_replace` hash in `server.tf`, grep for `TRIGGER_FILES` in `plugins/soleur/test/` and `DEPLOY_PIPELINE_FIX_TRIGGERS` in `plugins/soleur/skills/ship/SKILL.md` — update all three locations in the same commit. The drift guard test catches this post-merge but costs a hotfix PR. **Why:** #4492 added 2 files to `triggers_replace` without updating the test array; CI failed post-merge (#4493 hotfix).
   - When adding a new `sudo <cmd>` (or any command) to a deploy/provision script exercised by a mock-PATH shell test (`ci-deploy.test.sh` et al., whose mock `sudo` strips the prefix and `exec`s the real binary), the harness needs a `create_mock_<cmd>` in `create_base_mocks` — and it must be a **pass-through** that no-ops ONLY for the host effect it cannot reproduce (e.g. `/mnt/*` volume paths) and delegates every other invocation to the real binary (fail loud `exit 1` if none found, never silent `exit 0`). A blanket no-op silently breaks sibling calls to the same command whose dirs the script later writes into. Symptom: a one-line script change makes MANY unrelated assertions fail at once (a `set -e` abort) — confirm by running unmodified origin/main files in an isolated dir before blaming the env. **Why:** #4886 — `sudo mkdir -p /mnt/data/workspaces/.cron` had no mock → real `mkdir` ENOENT'd on host `/mnt/data` → 33/79 failures. See [[2026-06-03-new-sudo-command-in-mocked-deploy-test-needs-passthrough-mock]].
   - When a `.test.sh` drift-guard asserts "the resource DELIVERS file X" by grepping a bare path, anchor the assertion to the delivery construct (`destination = "…/X"` AND `source = "${path.module}/X"`), NOT the bare path — a path that also appears in the block's `chown`/`chmod`/`test -x` lifecycle lines makes a bare-path grep pass vacuously even after the `provisioner "file"` delivery block is deleted. Prove non-vacuity by mutating out the delivery block and watching the guard go red. Also: invoke terraform with `terraform -chdir=<dir> <subcommand>` (never rely on a persisted `cd` — the Bash tool's CWD drifts unpredictably across calls). **Why:** #4811 — AC4 grepped `/usr/local/bin/infra-config-apply.sh` (recurs 4× in-block) and stayed green with delivery deleted; three terraform calls failed on CWD confusion. See [[2026-06-02-drift-guard-bare-path-grep-vacuous-and-terraform-cwd]].
   - Comment-prose sibling of the above: a `.test.sh` drift-guard whose grep target is a bare literal/boolean (`agent = true`, `enabled = false`) can false-PASS by matching that literal inside an explanatory COMMENT in the same awk-extracted block — and stays green-blind after the real config line changes. Anchor on a token only the real config line can carry (a `var.<name>` reference, an HCL operator like `== null`), never the bare literal. When fixing one named drift-guard, run a sibling-query audit (`grep -rln "<stale phrase>" apps/web-platform/infra/*.test.sh`) — false-passing siblings are invisible until grepped. **Why:** #4864 — `journald-config.test.sh` asserted literal `agent = true` (stale post-#4845's dual-context `agent = var.ci_ssh_private_key == null`); the sibling `infra-config-handler-bootstrap.test.sh` false-passed by matching the `#4829` comment prose. See [[2026-06-03-drift-guard-assertion-false-passes-on-comment-prose]].
   - When flipping a bash `${VAR:-default}` value, the consumer sweep MUST include surfaces that depend on the default by OMISSION (a CI step / cron / test that never exports the var) — a `grep VAR` finds explicit readers, never the unset-fallback consumers. Pin the old default explicitly at each such surface in the same PR, AND add one test that runs with the var genuinely unset (`env -i`), since a suite that always pins the var cannot detect a silent default revert. **Why:** #4806 — flipping `SOLEUR_DEFER_DRYRUN:-1` → `:-0` would have false-FAILed `test-pretooluse-hooks.yml` Test 6, which asserted `would_defer` while relying on the hardcoded default. See [[2026-06-02-env-default-flip-breaks-implicit-ci-consumer]].
   - When referencing `cloudflare_zero_trust_access_service_token.*.client_secret` (or any provider-managed credential attribute) in a Terraform `environment {}` block, check the provider docs for write-only attributes. The Cloudflare provider's `client_secret` is available at creation but empty on subsequent `terraform refresh`. Use Doppler variables instead of state references for credentials. **Why:** #4492 → #4494.
   - When HMAC-signing a payload and sending it via curl, always use `--data-binary @file` (not `-d @file`). curl's `-d` strips newlines from the file content, creating a mismatch between what `openssl dgst` hashed (with newlines) and what the server receives (stripped). **Why:** #4492 → #4495.
   - When writing a webhook handler that runs inside a systemd service's mount namespace (`ProtectSystem=strict`), cross-check every destination path against the service unit's `ReadOnlyPaths`/`ReadWritePaths` at implementation time. SSH provisioners run outside the namespace; webhook handlers run inside. `terraform validate` and sandbox test suites do not catch namespace conflicts. **Why:** #4492 P1 review finding.
   - When adding a new `apps/web-platform/infra/*.test.sh`, register it as a named step in `.github/workflows/infra-validation.yml` in the SAME commit — that workflow runs explicit `run: bash …/<x>.test.sh` steps, NOT a glob (the repo-root test-all runner reaches `infra/` ONLY through `run-registered-suites.sh`, which derives its list from that same workflow — so an unregistered suite is uncovered locally too), so an unregistered infra test silently never gates. Grep the workflow for sibling `*.test.sh` steps and add yours alongside; backfill any orphan suites you find. Also verify the next-free ADR number from the directory (`ls knowledge-base/engineering/architecture/decisions | grep -oE 'ADR-[0-9]+' | sort -t- -k2 -n | tail -1`) — a plan-quoted ADR number is stale once a sibling PR lands one. **Why:** #5417 — resource-monitor/cat-deploy-state were orphan suites; plan's "ADR-061" was already taken. The same applies in reverse for a new `tests/scripts/test-*.sh`: nothing auto-discovers that directory either (test-all.sh's `*.test.sh` glob excludes it AND cannot match the `test-*` prefix), so it needs an explicit `run_suite` line in [scripts/test-all.sh](../../../../scripts/test-all.sh). Whichever directory you add a gate to, grep its registration site and confirm the new file appears in the run log — "it will be picked up automatically" is false by default here, and the failure is always silent-and-green. **Why:** #3366/PR #6520 — the harness carrying that PR's entire "the gate cannot silently pass" claim ran in ZERO runners; deleting the fail-open rung it existed to catch shipped green, and test-all.sh already documented this trap six lines above where the registration belonged.
   - Registration is not environment: **read the JOB BODY of any runner you register a suite into** and confirm every external binary the suite needs is installed there. A registration claim can be true in every clause (the glob matches, the job runs it, the context is required) and the suite still cannot execute. Corollary: a REQUIRED, path-filter-free, `merge_group` job is a shared resource — never add `apt-get` to one to satisfy a single suite (it puts a package-mirror dependency on the merge-queue critical path for every PR in the repo); relocate the suite to a job that already carries the tooling and record the dependency contract where the auto-glob lives. **Why:** #6454 — `.github/scripts/test/test-*.sh` auto-globs into `guard-script-fixture-tests` (required, bare `ubuntu-latest` + checkout); a terraform/cloud-init-dependent suite exited 6 on all 22 fixtures and red the required check for every PR. See `knowledge-base/project/learnings/2026-07-15-guard-gate-and-probe-must-pin-the-thing-they-name.md` §8.

7. **Track Progress**
   - Keep TodoWrite updated as you complete tasks
   - Note any blockers or unexpected discoveries
   - Create new tasks if scope expands
   - Keep user informed of major milestones

8. **GDPR / Compliance Gate (single pass, end of Phase 2)**

   [skill-enforced: gdpr-gate at work Phase 2 exit]

   After the per-task RED/GREEN/REFACTOR loop completes and before Phase 2.5, run `/soleur:gdpr-gate` once against the cumulative diff `git diff origin/main...HEAD` (after `git fetch origin main` — the local `main` ref lags in bare-repo worktrees and pollutes the diff with unrelated merged branches). Same advisory-only output and Critical-finding escalation as plan Phase 2.7. **Never per-task** — token budget is ≤4k per invocation, single pass per phase per ADR-026 TR3.

   Skip silently if the cumulative diff does not match the `hr-gdpr-gate-on-regulated-data-surfaces` canonical regex.

**Before claiming a battery proves anything — "mutation-proven", "N/N caught", "no surviving mutants" — count AXES, not rows.** A battery's value is the number of DISTINCT things it perturbs; N mutations of one shape is one mutation, and the axes an author omits are the ones they were not thinking about. The axes below are the consolidated union; [review/SKILL.md](../review/SKILL.md) carries the evidence for each, distributed across its defect-class and sharp-edge bullets rather than in one catalogue section, so treat this list as the index and that file as the case law:

- **dispatch** — neuter the assertion helpers themselves; a suite whose only gate is a failure counter exits 0 having asserted nothing.
- **the gate's own CALL PATH** — not just its lifetime. An anti-vacuity floor that counts a
  variable the assertion helpers increment is dispatched THROUGH the thing it guards, so a helper
  that always takes the pass branch keeps the count intact and the floor never looks. Ask per gate:
  *which helper does my evidence flow through, and does that helper appear in any mutation row?*
  Measured (#7275): `want(){ ok "$1 → $3"; }` — drop one comparison — produced a byte-identical
  `95/95 pass`, exit 0, with six of ten battery rows silently surviving; the floor could not see it
  because it counted `TOTAL`, and the battery's harness row neutered `bad()`, the one edit leaving
  `ok()` and `TOTAL` intact. The remedy is a helper self-test driving both branches and reporting
  with `printf` + `exit`, never through the helpers it guards. Treat floor slack as attack budget:
  that floor sat at 60 against 95 assertions, so all 24 new ones could be undispatched.
- **fixture shape** / **fixture direction** — the producer's shapes no fixture instantiates, and the far side of any transform (a suite whose fixtures all assert must-trip cannot see a matcher becoming too aggressive).
- **member cardinality** — a set sampled once cannot distinguish `1-of-1` from `all-of-1`.
- **assertion count** — an anti-vacuity floor that shares a lifetime with what it guards is not a floor.
- **harness stubbing** — a stub that infers the verdict from a value the case also sets measures the wrong thing.
- **authority/root input** — the source the verifier extracts from, not just the leaves it compares.
- **region boundaries** — a closure assertion over a regex-extracted window pins only what the window spans.
- **population growth** — ADD a member to the guarded set, do not only edit one.
- **demotion** — leave the asserted bytes byte-identical and reword the prose above them to make the prescription conditional. Retained deliberately: `ship/SKILL.md` and `plugins/soleur/test/fullsuite-merge-gate.test.ts` both actively guard this mutation, so dropping it from the list would leave the repo testing for an axis it no longer tells authors to mutate.
- **the floor** — revert the PR's own thesis and confirm the suite reddens.
- **the guard's OWN operand** — the axis every other row misses, because they all mutate the
  SUT and confirm the guard REDS. This one mutates the GUARD and confirms it does not silently
  WIDEN. Degenerate an operand the predicate interpolates — empty a variable inside a glob, a
  regex, or a path prefix — and check whether the guard now accepts everything. Measured: a
  containment check written as `case "$top" in "$ROOT"|"$ROOT"/*) return 0 ;; esac` degrades to
  `/*` when `$ROOT` is empty, so it returned 0 for a LIVE repository — and the suite carrying it
  still reported `40 passed, 0 failed`, because **a guard that accepts everything is
  indistinguishable from a healthy run**. Ask of every guard "how does this fail OPEN", then
  assert the POST-CONDITION its predicate depends on (`case "$ROOT" in /*) : ;; *) exit 2 ;; esac`)
  rather than trusting the producer, and `readonly` it so a later assignment cannot reintroduce
  the degenerate case. **Why:** #7553 — it passed review and shipped in three commits; caught by
  a sibling session reviewing its own copy. See
  `knowledge-base/project/learnings/2026-08-20-every-guard-was-present-read-as-protective-and-did-not-hold.md`.
- **the verifier's own anchor SET** — the axis that survives hardening the PREDICATE. When you build a hand-anchored completeness check (a rebase-composition checklist, a "did the sweep miss anything" list), tightening each anchor is orthogonal to whether the anchors COVER the subject: a checklist upgraded presence → exact-occurrence-count, and positive-controlled against the source tree, still reported 16/16 on a tree where an entire 13-line block had been deleted, because none of its 16 anchors sat in that region. Positive-controlling proves the anchors are well-formed; both trees have the unsampled region intact, so it can never detect one. Where the population is enumerable, enumerate it instead of anchoring — for a rebase over a sibling that touched the same code, `git show <sibling> -- <file> | grep '^+'` then assert each added line is present at HEAD or classify it as deliberately superseded. **Why:** #7291/PR #7510 — 37 lines absent, 13 of them a floor itemisation whose loss left the raise chain unreconstructable, past a green suite and a green checklist. See `knowledge-base/project/learnings/2026-08-19-i-hardened-my-verifier-twice-and-its-sample-was-still-a-sample.md`.

State plainly which axes your battery did NOT edit. Two mechanical companions: run the UNMUTATED control first (a red baseline voids every row), and assert each mutation LANDED against a pristine backup, because a mutation that does not land reports the BASELINE and that is indistinguishable from a pass. **Why:** PR #7470 (issue #7352) — a battery reported 6/6 RED and "no surviving mutants"; it had two axes, and the retraction ran a 13-mutation Round 2 across axes it never touched. Recorded in `knowledge-base/project/learnings/2026-08-12-my-ladder-rung-ended-in-a-label-so-it-fell-through-to-the-unsafe-branch.md`.

**An anti-vacuity floor must append to whatever the VERDICT reads, or its non-zero exit is an accident and its message is a false accusation.** A suite whose verdict is `exit $(( ${#FAILURES[@]} > 0 ))` and whose floor only bumps a `fails` counter does not fail because the floor fired — it fails because the ledger-vs-counter reconciliation trips, and it then prints `fail() was tampered with`, which is untrue and misdirects whoever hits it. Worse, the obvious repair for that false message (relaxing the reconciliation from `-ne` to `-gt`) silently disarms the floor: measured, a suite then reported `102 assertions`, `1 failed`, and exit **0**. Ask of every floor: *does it write to the same place the verdict reads?* **Why:** #8009/PR #8035 — see `knowledge-base/project/learnings/2026-09-10-my-battery-killed-all-26-and-could-not-see-any-of-the-20-escapes.md`.

**And every causal or universal claim your prose ADDS needs its falsifying command run BEFORE you write it** — "the only X", "reaches production", "N suites", "gated by W". Each has a command that settles it in seconds, and a false one propagates into ADRs, PR bodies and runbooks where a reviewer must re-derive it. See `knowledge-base/project/learnings/2026-08-12-my-ladder-rung-ended-in-a-label-so-it-fell-through-to-the-unsafe-branch.md`, which enumerates seven that shipped in one skill rewrite. This is honour-system today; the mechanical form is a diff-lint over added lines flagging superlative/quantifier shapes with no adjacent citation.

9. **Touched-Shard Exit Gate (single pass, end of Phase 2)**

   [skill-enforced: work Phase 2 exit]

   Before entering Phase 3, run the `TEST_GROUP` shards your diff touches — **once each, and not the whole battery**:

   ```bash
   TEST_GROUP=bun bash scripts/test-all.sh
   TEST_GROUP=scripts bash scripts/test-all.sh
   TEST_GROUP=webplat bash scripts/test-all.sh
   ```

   **Shard map — derive it, and derive it from the right place.** The `want_scripts` / `want_bun` / `want_webplat` / `want_infra` functions in [scripts/test-all.sh](../../../../scripts/test-all.sh) are pure `TEST_GROUP` predicates and carry **no path information** — they cannot tell you which shard your diff belongs to. The path→shard mapping is the set of `run_suite` registrations sitting inside each `if want_X; then` block; read those. As a starting index only: `apps/web-platform/**` → `webplat`; `plugins/soleur/`, the repo-root `scripts/` directory, `.claude/hooks/`, `tests/`, `AGENTS*.md` → `bun` and/or `scripts`; `docs/legal/**` → `webplat` (the mirror gate); `*.c4` → `scripts`. **`apps/web-platform/infra/**` is the exception and it wins over the `apps/web-platform/**` row**: defer it to the ship Phase 4 checkpoint, and if the diff touches ONLY that directory the Phase 2 exit runs no shard at all — say so explicitly rather than reporting a green you did not earn.

   **The full battery is not the gate here — it moves to the `/ship` Phase 4 full-battery checkpoint.** The MERGE gate is CI's required `test` context; no local run is the merge gate, and calling one that is the over-claim that would license a future PR to shard it. This ordering is [ADR-183](../../../../knowledge-base/engineering/architecture/decisions/ADR-183-full-suite-runs-at-ship-not-at-implementation-exit.md); do not re-add a second full run at this position without reading it. What reaches `main` is unchanged, because the local runner never was the merge gate: CI's required `test` context (ruleset 14145388) runs the same three shards on the PR head, independent of anything you do here.

   **A shard's summary is not a completeness claim — read the BREAKDOWN line.** Since ADR-181 a suite may DECLINE (relevance-gated), and declines are counted in the denominator, so `N/N` is no longer the ordinary local green spelling: a healthy run commonly reads `N-k/N` with a `k skipped (declined — not relevant to this diff)` breakdown. A shard narrows coverage a second way the runner does NOT announce — it emits a coverage NOTE for the `infra` case only, so nothing tells you `TEST_GROUP=bun` excluded the webplat shard. **State which shards you ran when you report the gate green.** This is the definite-article trap #6969 named, one level down: a green `test-all.sh` was read as the exit gate for a diff half of which it never executed, and a shard makes that easier, not harder.

   **Ask before you launch: `bash scripts/test-all.sh --capacity`.** It answers "can this box
   absorb another full gate?" in **~3 s** (measured p50, 16 cores / ~640 pids — it walks `/proc`
   once; an earlier revision of this passage claimed "under a second" and was wrong by ~10x because
   it walked twice). No suite, no lock, always exit 0. One verdict plus the running worktrees:

   - `CAPACITY_OK measured_runs=0 measured_suites=0 sibling_threshold=1 tmp_avail_mb=3619 tmp_floor_mb=1024 memavail_mb=…`
   - `[contention] BANNER CAPACITY_CONTENDED reason=<sibling_runs|sibling_suites|low_tmp> …` — a
     **statement**, not a refusal. The run is still yours to start; what it changes is what a RED
     under it is worth, which is the three-way confirmation the contention passage below prescribes.
     Note **both** axes: another worktree running the whole runner, and one running an individual
     suite, are the same capacity contention one level apart.
   - `[contention] BANNER CAPACITY_UNKNOWN reason=<unreadable_proc|unparseable_df|unparseable_meminfo|unusable_floor|not_probed|lib_unavailable> …`
     — a reading DEGRADED. Never read this as a healthy box: every underlying probe degrades an
     unreadable result to `0`, which is below every floor, so a degraded value renders as `?` and
     the verdict names which reading failed. `not_probed` and `unusable_floor` are distinct on
     purpose — the first means nothing was measured, the second that `TC_MIN_AVAIL_MB` is not a
     number.

   The two exceptional verdicts carry the `[contention] BANNER` prefix, so the post-run triage grep
   in §"`rc` is the verdict" catches them; `CAPACITY_OK` stays plain because a banner that fires on
   every run carries no information. Every line carries the measured value **and** the threshold.
   The same verdict is emitted on every run between the contention preamble and the lock, so a run's
   log records the capacity it started under.

   `--capacity` deliberately does NOT block or change any exit code — the blocking form was cut on
   measured evidence (ADR-133's 2026-08-19 addendum: a wait `LOCK_ACQUIRED … after 616310ms` was
   *redeemed* at 616 s, which a sibling decline would have refused at t=0, and a non-zero exit here
   would block `git commit` through `lefthook`'s pre-commit hook).

   **A long wait is legible rather than silent.** `TC_LOCK_TIMEOUT` is **3600 s** (raised from 900,
   which was shorter than the ~45-minute run it waits for — the budget expired by construction,
   which is *why* N runs used to land together). While blocked the runner emits
   `[contention] BANNER LOCK_WAIT_HEARTBEAT: queued, not hung — <lock> waited=<N>s of <budget>s`
   every `TC_WAIT_HEARTBEAT_S` (default 60), with elapsed read from a real clock.

   **The heartbeat does not tell you who holds the lock, and will not pretend to.** An earlier
   revision printed a "holder pid/worktree" taken from the first row of a `/proc` walk; measured, it
   named a fellow *waiter* ~83% of the time on the pileup this exists for, so acting on it meant
   killing the wrong session. Run `--capacity` in another shell for the actual list of running
   worktrees. **Read one of those two before killing anything.**

   **One consequence to know.** `lefthook` pre-commit runs the full battery behind this lock on any
   staged `*.{ts,tsx,js,jsx}`, so a `git commit` can now wait up to an hour before the suite even
   starts (it was 15 minutes). CI is unaffected — `tc_acquire` returns early on `CI`. The escape
   hatches are `git commit --no-verify` and `LEFTHOOK=0`.

   **Re-run each touched shard under the environments it SHIPS into, not only yours.** `CI=1` and
   `SOLEUR_SUBAGENT=1` change control flow (CI exemptions, subagent refusals), so a suite can be
   green locally and red on merge — treat any PASS-COUNT DELTA between environments as a finding,
   not just a FAIL. **Why:** #7545 — a new suite was 66/0 locally and 58/8 under `CI=1` because
   `tc_acquire` returns early on `CI` before the code under test; it was registered in the required
   `test` context, so it was green for the author and red on merge. Thirty seconds of work; no
   amount of diff-reading finds it. `review/SKILL.md` carries this rule too, but review runs AFTER
   this gate — by then the panel is already reviewing a suite CI will reject.

   **The lead runs this gate, not a delegate.** [scripts/test-all.sh](../../../../scripts/test-all.sh) exits `4` — REFUSED, nothing ran — when `SOLEUR_SUBAGENT=1` is set without `SOLEUR_ALLOW_FULL_GATE=1`. A ~90 s shard is far likelier to be delegated than a 45-minute battery was, so treat `rc=4` as its own outcome: it is not a reap and it is not a pass.

   **The lead runs this gate, not a delegate.** [scripts/test-all.sh](../../../../scripts/test-all.sh) exits `4` — REFUSED, nothing ran — for either of two reasons, both overridden by `SOLEUR_ALLOW_FULL_GATE=1`: `SOLEUR_SUBAGENT=1` is set (a DECLARED spawned agent — a convention, so this only fires if someone exported it), or a sibling full-gate run is already in flight (a MEASURED condition, #7553 — this is the one that fires in practice). A ~90 s shard is far likelier to be delegated than a 45-minute battery was, so treat `rc=4` as its own outcome: it is not a reap and it is not a pass. The message names which of the two tripped.

   **Why a shard and not a hand-derived command set.** A `vitest --changed` + `git grep` derivation was specified and cut. A shard keeps the contention preamble (`SIBLING_RUN_DETECTED` / `SIBLING_SUITE_DETECTED` / `LOW_TMP_HEADROOM`), the `EXIT CONTRACT`, the terminal `=== N/M suites passed ===` marker, the rc file, and the `rc=3` UNRESOLVED class — an ad-hoc command set has none of them, and `vitest run --changed` with zero matches exits 1, which is indistinguishable from a real red by exit code alone. A shard also has **no empty-set state**: it always runs a defined suite list, so the "empty derived set" fail-open cannot arise. Losing the banner would have moved the earliest sibling-collision signal past the 8-10-agent review fan-out — the cost #7247 paid, where a duplicate implementation surfaced only because a banner named the sibling worktree after a full RED→GREEN cycle had been built and had to be reverted.

   **What this position no longer catches, stated plainly.** The full battery discovers orphan suites (a sibling file covering the same script — e.g. an untouched `tests/scripts/test-rule-metrics-aggregate.sh` alongside the touched `rule-metrics-aggregate.test.sh`) that no shard map reaches, and cross-file breakage where a suite outside your shards asserts on a literal you changed. That class now survives into review, where it can add noise to agent mutation work, and is caught at the merge gate instead. Accepted deliberately: identical gates, better ordering. The `apps/web-platform/infra/` half is the one real gap and it is a RULESET gap, not a test-ordering one — no required status check runs that shard.

   **Linters.** `lefthook.yml` pre-commit already scopes them per commit, but the obligation is checkable rather than honour-system: this file sanctions `LEFTHOOK=0 git commit` as "common with `core.bare=true` repos" — this repo — and `lefthook.yml` has exactly one `pre-push` command, so there is no push-time backstop. **If any commit on this branch was made under `LEFTHOOK=0`, run the corresponding linters explicitly before exiting Phase 2** (`git log origin/main..HEAD` is the commit set; `lefthook run pre-commit` over the staged set is the cheapest way to discharge it).

   **Full-suite fallback (projects with no CI-enforced full-suite gate).**

   Everything above is scoped to THIS repository, whose safety case rests on two backstops a self-hosted plugin user does not have: CI ruleset 14145388, and [scripts/test-all.sh](../../../../scripts/test-all.sh) itself. Wherever this skill prescribes touched-file suites in project-agnostic prose, the same conditional applies: **when the project has no CI-enforced full-suite gate on the merge branch, the full battery stays at implementation exit.** Reordering a gate is only safe when a later gate still catches what the earlier one did; with no later gate, there is nothing to reorder into.

   Determine which case you are in with a bounded, local check — do NOT add a selection script, and do not assume `gh` is installed or authenticated, that the remote is GitHub, or that any ruleset/branch-protection API is readable. **Each rung ends in an action. If you reach the end of a rung without taking one, you have misread it — take the safe branch.**

   1. **Does a CI config run the project's WHOLE test suite on every pull request targeting the merge branch?** Read the repo's CI definitions (`.github/workflows/*.yml`, `.gitlab-ci.yml`, `.circleci/config.yml`, or the project's equivalent). Any of the following disqualifies it — treat the gate as ABSENT, run the full battery at implementation exit, and stop here:
      - a `paths:` / `paths-ignore:` filter, so some PRs get no run at all;
      - a job- or step-level `if:` that can skip the run;
      - `continue-on-error: true`, or a command suffixed `|| true`;
      - dependence on secrets unavailable to fork PRs;
      - **a run that is scoped, sharded, or otherwise a SUBSET of the local battery.** This is the one that catches people, because it is what THIS repository does: its CI runs three shards and excludes `apps/web-platform/infra/` entirely. "Runs the tests" is not the question; "runs at least what the implementation-exit battery would have run" is.

      No qualifying config ⇒ **no gate** ⇒ full battery at implementation exit. Stop here.
   2. **Does that run BLOCK the merge — required, not merely reported?** A workflow that runs is not a gate; a workflow that blocks is. Use only what is already on disk (a checked-in ruleset or branch-protection definition, a documented required-checks list). **If you cannot establish blocking from what you already have — which is the normal case, because GitHub branch protection is a server-side setting that is not checked in — treat the gate as ABSENT, run the full battery at implementation exit, and stop here.** Do not probe, and do not infer enforcement from the workflow's existence.

   **The default is the safe branch: if you cannot determine that such a gate exists AND blocks merge, treat it as ABSENT and run the full battery.** The relaxation is the privileged branch and is never reached by assumption or by falling off the end of the ladder. In this repository both steps resolve YES — the required `test` context is pinned in [scripts/required-checks.txt](../../../../scripts/required-checks.txt) and canonicalised in [the canonical required-status-checks JSON](../../../../scripts/ci-required-ruleset-canonical-required-status-checks.json) — which is why the shard gate above is the prescription here.

   **Reading a `test-all.sh` run (applies at BOTH positions — this gate and the `/ship` Phase 4 full-battery checkpoint).**

   The **seven** passages below are about *interpreting* a run, not about *when to launch one*. Moving the full battery to ship did not weaken any of them; `/ship` Phase 4 links here rather than restating them, so this is the single source. None of them may be dropped or paraphrased — the coverage-NOTE polarity strings in particular are matched verbatim by the reader grep at the `rc` bullet above. Several say "the exit gate" in the singular; read that as *whichever position you launched the run from*, since the full battery now runs at ship.

   **The exit gate only describes the tree you launched it against — confirm clean, then do not edit under it, and read the TERMINAL marker.** Three distinct false signals, all measured in one session (#6750/PR #7032): (a) launching `test-all.sh` against a **dirty** tree and continuing to edit produced 25 failures that were a mid-refactor snapshot of the author's own making, and were diagnosed as real — `git status --porcelain` must be empty before launch, and if an edit cannot wait, kill the run rather than reinterpreting its output; (b) a wait condition matched an intermediate per-suite `Total:` line instead of the terminal `=== N/M suites passed ===` marker, reporting completion mid-run — match the runner's LAST emitted line, never a per-stage line that merely looks summary-shaped; (c) redirecting the log into a directory that did not exist made the redirect fail, so the suite never ran and the wrapper's `TEST_ALL_EXIT=1` read as a suite failure — `mkdir -p` the destination in the same command that writes it, and confirm the log exists AND carries the terminal marker before interpreting any exit code. Note (c) is the converse of the `rc=$?` caveat below: a runner can report failure it never earned as easily as success it never earned. **Why:** #6750 — three misread signals cost two full re-runs. See [[2026-07-29-a-per-producer-fix-left-seven-siblings-live-and-four-misread-signals]].

      **A QUEUED run counts as RUNNING, and a DIAGNOSTIC can arm a dormant defect.** Two extensions of the rule above, both measured in one session (#1327). (a) Since the advisory lock, a launched `test-all.sh` may sit at `[contention] LOCK_WAITING:` for up to an hour before it executes anything — so "it hasn't started" is not a window to edit in; it can acquire mid-edit, and the run you eventually read describes a tree that no longer exists. Treat launched-and-queued as running: kill it and relaunch rather than editing under it. (b) Relaxing a safety setting to DIAGNOSE something can make a previously-failing escape succeed. Forcing `commit.gpgsign=false` to attribute a `SOLEUR_SIGN_UNAVAILABLE` cluster let a fixture's unguarded `cd` (`set -uo pipefail`, no `-e`) commit into the REAL branch — 4 commits on top of real work, which had been failing harmlessly for exactly as long as signing was broken. That fixture also sets `GIT_COMMITTER_DATE`, which spoofs the reflog, so `git reflog --date=iso` cannot surface it by time ordering. After relaxing any safety setting, re-check `git log` and `git status` before shipping. **Why:** #1327 — see `knowledge-base/project/learnings/2026-08-20-my-mutation-battery-sampled-the-axes-i-already-believed-in.md` and #7652.

**"My edit is unrelated to the running suite" is how the exit gate gets invalidated.** The rule
above says confirm-clean-then-do-not-edit; the reason it gets broken is that the edits feel
unrelated. They rarely are: the runner under measurement is frequently ITSELF a registered suite
that invokes the file being edited, and bash reads a script incrementally by byte offset, so an
in-place edit can change what a running invocation executes. The failure does not look like a
conflict — it looks like a plausible test result. **Why:** #7376 — editing
`run-registered-suites.sh` during a pinned measurement run produced `81 passed, 12 failed` with
every RED reporting `rc=?`, because the log dir had been removed mid-run; the 12 REDs read exactly
like a flake reproduction and were entirely self-inflicted. **If an edit cannot wait, kill the
run** — a discarded run costs minutes, a misattributed one costs the whole investigation. Same
rule for measurement loops and agent panels: they contend for one machine, so sequence them.

**Sibling-worktree contention produces a FALSE RED — the runner now self-identifies it.** Parallel worktrees are this repo's documented workflow, so two sessions can run `test-all.sh` simultaneously. The contended resource is NOT a colliding path — it is CAPACITY: every suite's `mktemp` lands in the same machine-global, RAM-backed 4 GiB `/tmp` tmpfs, so a second run competes for the memory the first is holding, and the failure both implicated suites document in-repo is a TIMEOUT (`skill-security-scan.test.ts` #4096; `vitest.config.ts` #3817/#4128), never a path collision. **The previously-recorded cause — `skill-security-scan`'s `.scan-meta.json` plus the semgrep bootstrap as "the known pair" — was refuted by measurement (#6789):** `run-scan.sh` PID-scopes `.scan-meta.json` and has since its original commit (so it cannot collide across worktrees; its real defect was an *unbounded meta_dir leak*, now age-reaped), and the semgrep bootstrap is unreachable from any suite `test-all.sh` runs. You no longer run `ps` by hand: `test-all.sh` prints a contention preamble (`/tmp` headroom, sibling runs resolved to their worktrees via `/proc/<pid>/cwd`, machine load) and fires a named `SIBLING_RUN_DETECTED` / `SIBLING_SUITE_DETECTED` / `LOW_TMP_HEADROOM` banner when one of those conditions holds (`SIBLING_SUITE_DETECTED` is the same capacity contention one level down: a sibling worktree running a single suite directly rather than the whole runner), and an advisory git-common-dir lock serializes concurrent runs (proceeding with a `LOCK_CONTENDED_PROCEEDING` banner on timeout, never aborting). When a banner fires, still confirm three ways (isolated re-run, the corresponding CI gate's status, a clean full re-run once the sibling exits) rather than accepting "flake"; and never delete another session's `/tmp` artifacts to reclaim space — `/tmp` is a shared tmpfs, so write large logs to `/var/tmp` instead (`tmpfs-guard.sh`'s cron reaper bounds abandoned-scratch growth only for entries above its size floor; the **count-shaped** class — tens of thousands of few-hundred-byte artifacts, which is what actually filled `/tmp` on 2026-07-27 — is **not reclaimed by anything today**. The pressure tier that would have reclaimed it was measured against the real `/tmp` and **removed before merge** (it cleared 12,240 entries, ~1,500 of them authored work), so the guard *detects and reports* the count shape and deletes nothing. #7004 rebaselined that report onto growth above a downward-ratcheting watermark, so it alarms on a NEW leak rather than on the standing backlog. Do not treat the reaper as a general backstop). **Why:** #6726 — 4 `skill-security-scan` failures were pure contention (isolated re-run 22/0, CI green throughout); #6789 re-derived the actual cause and shipped the self-identification above.

   **A long run can be reaped by the HARNESS, and that is not a suite result — read the rc file, never the notification.** Under contention the exit gate is frequently unreachable, and the three ways it fails are all indistinguishable from a suite failure if you read only the task notification: a background Bash task returns `status: killed` or `exit code 144` (SIGUSR1) after ~9-15 min, a foreground call trips the 600 s ceiling and is *moved* to background, and a `Monitor` script watching for the result gets reaped on the same clock. None of those are your diff. Distinguish mechanically, and it is a **three**-way split, not two: a real failure leaves `[FAIL]` lines and a terminal `=== N/M suites passed ===`; a reap leaves **zero** `[FAIL]`, no terminal marker, and no rc file; and a **suite** that died while the runner survived leaves `[KILLED]` lines, a terminal marker **and** an rc file carrying `3` — that third one is unresolved rather than reaped or failed (§`rc` is the verdict, above), so it is yours to re-run in isolation, not to dismiss as harness noise. To actually finish one, detach it from the harness process group — `setsid nohup env TMPDIR=/var/tmp bash -c '<runner> > log 2>&1; echo $? > rc' </dev/null &` — keep any `Monitor` under ~9 min so it exits on its own timeout, and shard with `TEST_GROUP=` (`all|webplat|bun|scripts`) so each piece completes; a run queued on the advisory lock ends its output at the `[contention] LOCK_WAITING:` line and then produces *nothing* until it acquires or gives up, so `grep -q 'LOCK_WAITING' "$log"` on a log that has stopped growing means the lock, not a hang. **Why:** #7172 — three reaped runs (459-470 suites `[ok]`, 0 `[FAIL]` each) while five sibling worktrees ran the same runner at load 55 on 16 cores.

   **Doppler-env false-positive caveat (`TEST_GROUP=webplat`).** Running the webplat shard under `doppler run -c dev` injects feature flags + live vendor creds that CI does not, which can flip untouched files into code paths their unit mocks don't cover (e.g. a delegation flag routes `team-membership-resolver.ts` into a `byok_delegations` query → `unmocked table`) — the `vi.unstubAllEnvs`-can't-clear-inherited-env class. Before treating a webplat failure as a regression, re-run the failing file **without** Doppler (CI-equivalent); if it passes there and `gh run list --workflow=ci.yml --branch main` is green, it's a pre-existing env-only flake, not your diff. **Why:** #4660 (filed #4663). **Inverse (Doppler masks a CI-only FAILURE):** a new webplat test that imports any `server/inngest/*` module (or anything transitively loading `server/inngest/client.ts`) throws an `INNGEST_SIGNING_KEY missing at startup` error at module-eval unless guarded by a hoisted `vi.hoisted(...)` block that sets the `NEXT_PHASE` env to `phase-production-build` BEFORE the import (mirror the sibling `cron-compound-promote.test.ts`). Running the file under `doppler run` injects the key → it passes locally while CI's `test-webplat` (no inngest env) errors at **collection** → the required `test` context reds. Always run a new inngest-importing test CI-equivalent (no Doppler) before trusting green. **Why:** #6103 — the ADR-092 recursion test passed 4/4 under Doppler and would have wedged the merge; caught by review. See `knowledge-base/project/learnings/security-issues/2026-07-06-body-hashing-guardrail-gate-fail-open-classes.md`.

   **Legal-doc edits have TWO independent mirror gates — a new heading needs the Eleventy mirror in the same PR.** When the diff adds a `## `/`### ` heading to a canonical `docs/legal/*.md`, `apps/web-platform/test/legal-doc-consistency.test.ts` (in the `webplat` shard — include it in the Phase 2 touched-shard gate whenever the diff adds a legal-doc heading) FAILS on source↔mirror section-heading-sequence drift unless the SAME heading is added to `plugins/soleur/docs/pages/legal/<doc>.md`. This is a DIFFERENT, stricter gate than `apps/web-platform/scripts/check-tc-document-sha.sh` (whose mirror *body-equivalence* step is T&C-only and explicitly defers non-T&C docs) — passing the SHA guard does NOT imply heading parity. Prose added *inside* an existing section needs no mirror heading; a NEW section does. Catch it by running the `webplat` shard at the Phase 2 gate (and `rc=$?` + grep the log — a backgrounded runner can report exit 0 with a real failure). **Why:** #5370 — a new gdpr-policy `### 3.12` passed the SHA guard but turned the full suite red until the mirror heading was synced. See [[2026-06-15-two-legal-mirror-gates-and-always-build-mcp-registered-list-desync]]. <!-- markdownlint-disable-line MD038 -->

   **Feature-branch-CWD blind spot for `.claude/hooks/*.test.sh` (run once from a simulated `main` CWD).** A hook test whose outcome depends on a CWD/branch-resolved gate (`block-commit-on-main` via `git rev-parse --abbrev-ref HEAD`, `git worktree list` counts, `symbolic-ref HEAD`) passes on the `feat-*`/`fix-*` worktree every local run uses, but post-merge CI runs on the merged `main` commit and can fail ONLY there (`block-commit-on-main` denies a `git commit`-shaped fixture input → masks the gate under test). The local 104/104 is NOT authoritative for these. When the diff touches `.claude/hooks/*.test.sh`, run the suite once from a throwaway committed-`main` CWD (`d=$(mktemp -d); git -C "$d" init -q -b main && git -C "$d" -c user.email=t@t -c user.name=t commit -q --allow-empty -m i; (cd "$d" && bash <repo>/.claude/hooks/<file>.test.sh)`). Fix-side: isolate the test from the orthogonal gate by running the hook from a non-git/pinned-branch CWD, not the ambient process CWD. **Why:** #5192 — guardrails AC1/AC3/AC4 passed 104/104 locally, turned main red, fixed in #5209. See [[2026-06-12-hook-test-passes-on-worktree-fails-on-main-cwd]].

### Phase 2.5: Research Validation Loop (knowledge-base deliverables only)

Rule source: AGENTS.md — migrated 2026-04-21 (PR #2754). When a research sprint produces recommendations, run the cascade-validate loop [id: wg-when-a-research-sprint-produces] [skill-enforced: work Phase 2.5]. **"Findings written" is NOT done — "findings applied, validated, and all documents reflect the final state" is done.** The full body of that rule lives here; AGENTS.md retains a one-line pointer preserving the `[id: ...]` tag.

**Trigger:** This phase runs when the plan's deliverables are knowledge-base research artifacts (findings, analysis, audits, research briefs) that produce recommendations targeting other existing documents. Skip for code-only plans.

**Detection:** After Phase 2 completes, scan the outputs for recommendation patterns — "should rewrite," "needs updating," "add to," "change X in Y.md," or any finding that names a specific target file. If found, enter the loop.

**The loop:**

```text
while (recommendations exist that haven't been applied):
  1. CASCADE: Apply all recommendations to their target artifacts
     - Rewrite questions in interview guides
     - Update framings in brand guide
     - Add alternatives to pricing strategy
     - Any finding that names a file → edit that file
  2. VALIDATE: Re-run the same research methodology against updated artifacts
     - Use the same personas/parameters as the original run
     - Produce a before/after comparison (original → current)
  3. CHECK: Did the validation surface NEW weak spots or recommendations?
     - If yes → apply fixes, loop back to step 2
     - If no (at synthetic ceiling) → exit loop
  4. UPDATE BRIEF: Update the research brief with final validated results
     - Executive summary reflects current state, not original findings
     - Recommendations marked as "Applied" with results
     - Add Cascade Status section tracking all changes to all files
  5. SUMMARIZE: Present founder summary
     - Key findings table
     - All files changed table (file, what changed, before/after metrics)
     - Remaining limitations (structural, not fixable)
```

**Exit condition:** The loop exits when a validation round produces no new actionable recommendations — only structural limitations that can't be fixed by rewording (e.g., a persona's archetype inherently produces flat responses to a specific question type).

**Max iterations:** 3 rounds. If the third round still produces actionable recommendations, present them to the user rather than looping indefinitely. Synthetic-on-synthetic validation has diminishing returns.

**Why this matters:** Without this loop, research sprints produce findings that sit in briefs without updating the documents they target. The founder has to manually ask "was any action taken?" after each round. This loop makes cascade + validate + re-cascade automatic.

### Phase 3: Quality Check

1. **Run Core Quality Checks**

   Always run before submitting:

   ```bash
   # Run the suites covering the touched files -- but the FULL battery when the
   # project has no CI-enforced merge gate; see **Full-suite fallback** in Phase 2
   # section 9, and take that branch when in doubt.
   # Examples: bin/rails test, npm test, pytest, go test, etc.

   # Run linting (per CLAUDE.md)
   # Use linting-agent before pushing to origin
   ```

   - **For `apps/web-platform`, `npm run lint` runs ESLint 9 against a flat config (`apps/web-platform/eslint.config.mjs`) — it IS a functioning gate as of #1327, and lint findings ARE merge-gating, though not by the route the job names suggest.** It replaced `next lint`, which Next 16 removes and which never ran here (no config → interactive prompt → exit 1). The `lint-webplat` job in `ci.yml` is deliberately NOT a required check — but do not read that as "lint cannot block a merge". `apps/web-platform/test/eslint-config.test.ts` runs inside the required `test` context, and it asserts BOTH that `eslint` exits 0 (so any error-severity finding blocks) AND a per-rule ratchet (so any rule whose count GROWS, or any rule with no baseline entry, blocks). The ratchet is one-sided on purpose: driving a count DOWN never reds it, because a two-sided pin made fixing a warning fail a required check on ~13% of PRs. When it reds, read the message — it names the rule that moved — then either fix the code or re-pin `BASELINE_FINDINGS` + `BASELINE_BY_RULE` downward using the derivation command in that file. Do NOT blanket-disable a rule class to reach zero. `tsc --noEmit` + `vitest run` remain the authoritative merge-gating checks; promoting `lint-webplat` itself to required is tracked separately. See `knowledge-base/project/learnings/2026-06-05-web-platform-lint-gate-is-non-functional-tsc-vitest-are-authoritative.md` and its 2026-08-19 addendum.

   - **Run the project's pinned TypeScript binary in the app package — `cd <app-package> && ./node_modules/.bin/tsc --noEmit` (or `bun x tsc --noEmit`), never bare `npx tsc`.** `npx tsc` resolves against its own cache and can silently install a wrong/major-jumped (or typo-squatted) `typescript`, producing false type errors unrelated to your change — the same supply-chain/version-drift failure class as the pinned-`./node_modules/.bin/vitest` rule above (PR #3186). Vitest type-checks test files lazily, so TS errors in tests pass the suite locally but fail CI; a standalone pass with the *project's* compiler catches them at the work-phase gate instead of deferring to review.
   - **A running `next dev` server poisons local `tsc --noEmit`:** dev-server-generated `.next/types` can fail TS2344 (`OmitWithTag`) on pre-existing non-route exports in layout/route files your diff never touched. Before trusting a typecheck failure that points into `.next/types`, stop the dev server and `rm -rf .next`, then re-run — clean checkouts (CI) are unaffected. See `knowledge-base/project/learnings/integration-issues/2026-06-09-qa-seed-schema-drift-and-playwright-admin-session.md`.
   - **When extracting enforcement logic (auth, CSRF, validation) from route files into a shared helper, update negative-space tests in the same commit.** Route-level detection must prove helper invocation AND failure early-return — not just import presence. Add direct assertions on the helper file for every invariant that moved into it. See `knowledge-base/project/learnings/best-practices/2026-04-15-negative-space-tests-must-follow-extracted-logic.md`.
   - **When adding git operations that contact remotes in Next.js API routes, include the credential helper pattern from `session-sync.ts`** (search `credential.helper`). Bare `git pull`/`git push`/`git fetch` fail silently on private repos. See `knowledge-base/project/learnings/integration-issues/kb-upload-missing-credential-helper-20260413.md`.
   - **When creating a new `apps/web-platform/app/api/**` route authenticated by anything OTHER than a Supabase session cookie (shared secret, HMAC, SDK signature — any non-browser caller: cron, webhook, operator/agent curl), add its exact path to `PUBLIC_PATHS` in `apps/web-platform/lib/routes.ts` in the SAME commit (narrow exact-match, never broaden to a parent prefix) + a `middleware.test.ts` membership assertion.** Otherwise Supabase middleware 307→/login the cookie-less caller before the route's own auth gate runs — the route is unreachable in prod. Route unit tests call `POST(request)` directly and CANNOT catch this (#4017, #4587, kb-drift-ingest precedent). See `knowledge-base/project/learnings/integration-issues/2026-06-01-new-internal-api-route-needs-public-paths-registration.md`.

2. **Consider Reviewer Agents** (Optional)

   Use for complex, risky, or large changes:

   - **code-simplicity-reviewer**: Check for unnecessary complexity
   - **kieran-rails-reviewer**: Verify Rails conventions (Rails projects)
   - **performance-oracle**: Check for performance issues
   - **security-sentinel**: Scan for security vulnerabilities

   Run reviewers in parallel with Task tool:

   ```text
   Task(code-simplicity-reviewer): "Review changes for simplicity"
   Task(kieran-rails-reviewer): "Check Rails conventions"
   ```

   Present findings to user and address critical issues.

3. **Final Validation**
   - All TodoWrite tasks marked completed
   - All tests pass
   - Linting passes
   - Code follows existing patterns
   - Figma designs match (if applicable)
   - No console errors or warnings

### Phase 4: Handoff

Implementation is complete. Before handing off, run the **Playwright-first audit**, then determine invocation mode.

#### Playwright-First Audit

Scan any "next steps", "setup instructions", or "to use this" text you are about to output. For each step that involves a browser action (account creation, credential generation, settings configuration, form submission, vendor support tickets, OAuth flow, portal navigation):

1. **Classify:** Is this step automatable via Playwright MCP, or is it genuinely manual (CAPTCHA, interactive OAuth consent, hardware MFA token, payment-card entry)?
2. **If automatable:** Do not list it as a manual step. Either execute it now via Playwright MCP, or note it as "automatable via Playwright — will execute next."
3. **If genuinely manual:** Drive the flow via Playwright up to the manual gate (e.g., navigate to the OAuth consent screen), then hand off only that single interaction to the user.

If you catch yourself writing phrases like "set up X in the browser", "go to the portal and...", "manually configure...", "paste this ticket body into the support form", or "the operator pastes + submits" — stop and attempt Playwright first. This audit is mandatory; skipping it is a deviation.

**Attempt-evidence is mandatory before ANY "operator-only" / "manual" / "not automatable" classification (HARD GATE).** A browser step may be labeled operator-only ONLY after a real Playwright MCP attempt that reached the actual gate — never from an a-priori assertion. Phrases like "MFA-gated", "no API path", "requires dashboard access", or "operator must do this in the browser" are predictions, NOT observations; on their own they are non-compliant. **This applies even when an upstream PLAN or ADR pre-declares the step operator-gated (e.g. `automation-status: UNVERIFIED`, or an `Automation: not feasible because <X>` line whose `<X>` is an a-priori "no creation API / vendor limit" assertion): a plan/ADR claim is NOT a substitute for your own Playwright attempt — treat any plan-declared operator-gated browser/vendor-dashboard step as UNVERIFIED and attempt it before honoring the handoff.** A vendor dashboard mint runs under an authenticated session and is presumptively automatable (#5480 — the plan + ADR-065 asserted the Resend key mint "operator-gated, no API"; a Playwright attempt reached the authenticated dashboard with a working create form and NO human gate; see `knowledge-base/project/learnings/workflow-patterns/2026-06-17-vendor-dashboard-mint-presumed-playwright-automatable.md`). The classification MUST be accompanied by an evidence line in this exact shape:

```
playwright-attempt: navigated <URL>; reached <specific gate observed>; <why it blocks autonomy>
```

where `<specific gate observed>` is a concrete, named gate the run actually hit — one of: `CAPTCHA/Turnstile challenge`, `email-OTP`, `SMS-OTP`, `authenticator-TOTP`, `WebAuthn/passkey/Touch-ID prompt`, `push-MFA (Duo/Okta/Authy)`, `payment-card iframe`, `hardware-token tap`, or `tool-instability: <observed symptom>` (e.g., "browser context closed mid-form ≥N times; SPA form state did not survive reconnect"). "I assume it needs MFA" is not a gate observation; "navigated to the password screen, entered creds, hit a WebAuthn passkey prompt the browser cannot synthesize" is. **`api-probe-403` alone never qualifies** — a 403 from a *narrowly-scoped* token (e.g. a Terraform token that lacks token-management permission) does not prove the dashboard path is operator-only; you must still attempt the browser. Exhaust the credential space first (a Global API Key or a token with the write scope may exist in Doppler) before concluding no API path exists.

**Two distinct dispositions once attempt-evidence exists:**

- **`operator-only` (a true human gate):** the observed gate is one only a human can clear (CAPTCHA, OTP/TOTP, passkey, push-MFA, payment-card, hardware token). Drive Playwright up to that single interaction, hand off ONLY that interaction, then resume autonomously. This is the legitimate handoff.
- **`attempted-blocked-on-tool` (a tool/environment failure):** the gate is mechanically automatable but the tool could not complete it (repeated browser-context crashes, an MCP server that is down, a headless run where the interactively-authenticated MCP is absent). This is NOT operator-only — record it distinctly so it is fixed/retried rather than permanently handed to the operator. Capture the exact resume state (URL, the precise remaining clicks, any partially-completed form) so the next stable session finishes it without re-deriving. Do NOT file a `deferred-automation` operator issue for this class — file a `type/chore` issue tagged `tooling`/`flaky` describing the instability, with the resume recipe.

The `playwright-attempt:` line is what the ship operator-step gate and the Post-Merge Self-Audit consume to distinguish a real gate from an un-attempted deferral. See `knowledge-base/project/learnings/workflow-patterns/2026-06-10-playwright-attempt-evidence-before-operator-only.md`.

**Vendor support ticket submissions are Playwright-driveable** — they are NOT operator-handoff by default. Most vendor support surfaces today are Intercom / Zendesk / HelpScout chat widgets (`help.<vendor>.io`, `support.<vendor>.com`, `<vendor>.zendesk.com`) where the AI assistant routes to a human team. The full submission flow — opening the widget, accepting cookies, starting a conversation, sending the ticket body, requesting human escalation if the AI gives a stock policy answer — runs entirely under Playwright. The only legitimate manual gates are:

- **Email-OTP verification** when the vendor sends a one-time passcode to the operator's inbox before routing to a human reviewer.
- **SMS-OTP** — same shape as email-OTP but delivered to the operator's phone (e.g., banks, telcos, account-recovery flows). Same handoff: "check your messages for the code, tell me the digits."
- **Authenticator-app TOTP** — operator reads a rolling 6-digit code from Authy / 1Password / Google Authenticator / Microsoft Authenticator. Playwright cannot reach the authenticator source.
- **WebAuthn / passkey / U2F browser prompts** — the OS or browser surfaces a native dialog (passkey selection, Touch ID, Windows Hello) that Playwright cannot synthesize. Distinct from hardware MFA: passkeys are software-resident and increasingly the default on Google / GitHub / Stripe / Apple.
- **Push-based MFA** (Duo Push, Okta Verify, Authy Push, Microsoft Authenticator notification) — operator approves on a separate device; no DOM interaction available to Playwright.
- **Payment-card entry** in Stripe / similar widgets (cross-origin iframe sandbox; even if Playwright could reach in, card entry is the explicit operator decision-and-ack point).
- **CAPTCHA / "I am not a robot"** challenges (intentional bot-detection).
- **Hardware MFA token tap** — physical YubiKey / Titan / Solokey touch (operator-side device).

Never quote OTP digits, TOTP codes, or any other ephemeral authentication value in a committed file — capture only the fact-of-verification + UTC timestamp. The specific code is dead immediately, but quoting it normalizes "paste secrets into the runbook" as a pattern and the next vendor's code may NOT be single-session (some flows reuse codes within a TTL window).

Drive the flow up to one of these gates, hand off the single interaction (e.g., "check your inbox for the OTP and tell me the code"), then resume. Never list "operator pastes + submits" as a step — that's a Playwright-first-audit violation. Vendor support tickets typically do not return a numeric ticket ID; capture (a) the submission UTC timestamp, (b) the AI-classifier auto-title (often surfaced in the messages list), and (c) any human-team routing label as the audit baseline. The conversation thread on the vendor side IS the canonical ticket; async response arrives via email to the operator. **Why:** PR #3946 PR-γ §17 Sentry refund + forensics tickets — the original plan listed them as "NOT Playwright-driveable" (operator handoff to paste-and-submit); after operator pushback, both tickets were driven via Sentry's Intercom widget at `help.sentry.io` with only the email-OTP step handed off. See learning `knowledge-base/project/learnings/2026-05-17-vendor-support-tickets-are-playwright-driveable.md`.

#### Phase 4 Entry-Guard

Before emitting `## Work Phase Complete` (one-shot mode) or chaining into the post-implementation pipeline (direct mode), assert at least one commit exists beyond `origin/<branch>`. An empty diff hands review agents nothing to analyze and produces no signal. Run BEFORE the Invocation Mode branch so both paths are covered.

**Procedure (distinct exit codes signal distinct operator actions):**

1. Probe the commit count:

   ```bash
   BRANCH=$(git rev-parse --abbrev-ref HEAD)
   if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then
     echo "[work-phase-4-guard] detached HEAD — checkout a feature branch before chaining to review." >&2
     exit 1
   fi
   N=$(git rev-list "origin/${BRANCH}..HEAD" --count 2>/dev/null || echo 0)
   ```

2. If `N == 0`, **stop and run Phase 2 step 3** (stage logical-unit files, write conventional commit message). Do not chain through this block as a single bash invocation — the commit is an explicit action the agent must perform between probes:

   ```bash
   if [[ "$N" == "0" ]]; then
     echo "[work-phase-4-guard] no commits beyond origin/${BRANCH} — pause and run Phase 2 step 3 incremental commit before continuing." >&2
     exit 2  # PAUSE — orchestrator should re-enter Phase 4 after the commit lands
   fi
   ```

3. After the incremental commit lands, re-enter the Phase 4 entry-guard. If `N == 0` on the second probe (commit failed silently or no diff exists), HALT:

   ```bash
   if [[ "$N" == "0" ]]; then
     echo "[work-phase-4-guard] empty diff vs origin/${BRANCH} after Phase 2 step 3 — investigate before continuing." >&2
     exit 1  # HALT — do NOT emit "## Work Phase Complete"
   fi
   ```

**Form rationale.** `git rev-parse --abbrev-ref HEAD` matches `ship/SKILL.md:619` precedent and returns the literal `HEAD` on detached state (vs. `git branch --show-current` which returns empty), so the detached-HEAD guard catches both shapes. `git rev-list ... --count` returns a clean integer ready for `[[ "$N" == "0" ]]`; the `wc -l` shape requires a `tr -d` strip and is whitespace-padded. Precedent: `plugins/soleur/skills/ship/SKILL.md:619`, `.claude/hooks/ship-unpushed-commits-gate.sh`. **Distinct exit codes** (`2 = pause-and-commit`, `1 = halt-and-investigate`) let one-shot orchestrators distinguish the two recovery paths rather than treating both as opaque non-zero failures.

#### Post-Merge Section Self-Audit (HARD GATE)

After drafting the PR body and BEFORE `gh pr ready` / `gh pr merge --auto`, scan every line under headings matching `^##\s+(Post-?merge|Operator|Follow-?ups?)` (case-insensitive). For each bullet, classify and resolve **before** marking ready:

| Pattern | Action |
|---|---|
| Doppler/env-var verification | Inline-execute via `doppler secrets get <KEY> -p soleur -c <env> --plain`; if missing, set from a known source via `doppler secrets set` or update the handler to read the existing canonical name. |
| `Within Nh of merge: file <issue>` | File the issue NOW via `gh issue create` using the template the bullet describes; replace the bullet with `Done: #<num>`. |
| `gh issue close` / `gh issue comment` on existing issues | Run NOW via `gh` CLI. |
| Sentry / Better Stack / monitor verification | Replace with the monitor's own auto-page mechanism (`failure_issue_threshold = 1` is the verification — no operator gaze required per `hr-no-dashboard-eyeball-pull-data-yourself`). If active verification is still wanted, create a one-time scheduled workflow via `/soleur:schedule create --once --at <date>` with a self-disabling `verify-and-close-or-file-issue` body. |
| Genuinely operator-only (CAPTCHA, SSO consent, payment-card entry, hardware MFA, K-bis-style first-onboarding) | **Requires a `playwright-attempt:` evidence line** (see Phase 4 Playwright-First Audit) proving a real attempt reached a true human gate — an a-priori "MFA-gated"/"dashboard-only" assertion does NOT qualify. With evidence: file a `type/chore` issue carrying the literal `deferred-automation` sentinel — via **`--body-file`, never `--body "…\n…"`** (double-quoted `\n` is a literal backslash-n, so `Mandated-By:` lands mid-line and the net-issue-flow exemption's anchored match cannot see it): write the body to a `mktemp` file containing `deferred-automation backlog item; re-evaluate when: <criterion>; playwright-attempt: <evidence>`, a blank line, then `Mandated-By: wg-block-pr-ready-on-undeferred-operator-steps` on its own line; `gh issue create --label type/chore --label deferred-automation --title <...> --body-file <path>`. The literal `deferred-automation` string must be in the issue BODY — `ship-operator-step-gate.sh` greps the body for it and reads `type/chore` from the labels; the `deferred-automation` label itself is read by no gate and is kept for human triage only. Then add `Tracks #N` to the bullet in the PR body: the exemption requires that companion, and without it the filing this rule MANDATES is blocked by net-issue-flow with no exit but the blanket override. |
| Automatable but the tool failed (`attempted-blocked-on-tool`: browser crashes, MCP down, headless-absent MCP) | NOT operator-only — file a `type/chore` issue tagged `tooling`/`flaky` (NOT `deferred-automation`) with the `playwright-attempt:` evidence + exact resume recipe (URL, remaining clicks, partial form state). Retry in a stable session; do not permanently hand to the operator. |
| Anything else | Inline-execute. Default-deny on "operator should later …" phrasing. |

After resolution, re-scan; the section MUST contain zero unaccompanied operator/manual bullets. The `ship-operator-step-gate.sh` PreToolUse hook enforces this mechanically at `gh pr ready` / `gh pr merge --auto` — the gate's deny message lists each undeferred match. Override via `SOLEUR_SKIP_OPERATOR_STEP_GATE=1` is reserved for the rare attestation case.

**Why:** PR #4227 (TR9 PR-3) shipped with a "Post-merge" section listing four operator items (Doppler secrets check, T+90 min Sentry verify, T+24h auto-resolve verify, file follow-up issue within 48h) — all four were inline-automatable; the agent had hard rules forbidding the deferral (`hr-exhaust-all-automated-options-before`, `hr-never-label-any-step-as-manual-without`, `wg-block-pr-ready-on-undeferred-operator-steps`) and still wrote the bullets. The gate existed at `/ship` Phase 5.5 but the agent reached `gh pr ready` directly. This self-audit + the hook close both halves of that bypass. See `knowledge-base/project/learnings/best-practices/2026-05-21-post-merge-section-self-audit.md`.

#### Follow-up Filing Net-Flow Gate (HARD GATE)

`/work` files follow-up issues HERE (Post-Merge Self-Audit, deferral tracking, discovered-bug capture) — which is BEFORE `/ship` Phase 5.5's Net-Issue-Flow Surfacing runs, and is bypassed entirely when `/ship` is hand-rolled. So the cost-of-filing + net-flow discipline (PR #4452) must ALSO fire at this filing site, not only at ship.

Before issuing ANY `gh issue create` for a follow-up in this phase, run the gate:

0. **The `code-simplicity-reviewer` CONCUR gate applies HERE too, before the filing — not only to review-time filings.** `review/SKILL.md` §5 already requires it for every `--label deferred-scope-out` issue and calls filing-first-co-signing-second a protocol violation; the gap is that a plan can prescribe filings in THIS phase without restating the gate that governs them. Running it after the fact inverts its purpose — it becomes ratification rather than admission control, because closing an already-filed issue costs more friction than declining to file one. Also confirm the deferral target can actually reach the defect: deferring instances to a future linter/guard whose SCOPE cannot see them (an `if:`-scoped rule filed against a defect living at `with.status`) parks them forever. **Why:** #7138 — two Phase-7 filings preceded the gate; it then DISSENTed on one, splitting out two false-GREEN heartbeat monitors that had to be fixed inline.

1. **Cost-of-filing, per candidate filing** (mirrors `review/SKILL.md` §CONCUR): if the deferred work is **≤100 changed lines AND ≤4 files**, do it inline (fold into THIS PR if unmerged; otherwise it is genuinely a follow-up). Only file when the work is genuinely larger, a separate work-stream/Non-Goal, an operator-only step, or a **discovered defect in a different subsystem** (the last MUST stay its own issue — never bury a possible-P1 bug in a consolidated tracker).
2. **Consolidate deferred-FEATURE follow-ups into ONE tracker.** Multiple `deferred-scope-out` follow-ups from the same PR (ADR + future-feature + sibling-upstream …) collapse into a single "**\<feature\> (#N): post-MVP follow-ups**" issue with a checklist. Discovered bugs stay separate.
3. **Surface the net count BEFORE filing.** Compute and print: `Closing: <count of Closes #N in PR body> / Filing: <new issues> / Net: <signed>`. If `Net > 0`, state one sentence per filing on why it could not be inlined or consolidated. Net-positive backlog growth from a single feature PR is the smell this gate exists to catch.

This is the `/work`-side mirror of `/ship` Phase 5.5 Net-Issue-Flow Surfacing — together they cover both the filing site (here) and the merge boundary (ship). **Why:** PR #4580 (#4579) filed **4** follow-ups for **1** closed issue (net +3) during this self-audit; the agent hand-rolled `/ship` so Phase 5.5's surfacing never fired, and there was no filing-site gate. Three were consolidatable into one tracker (#4613); the net should have been +1. See `knowledge-base/project/learnings/workflow-patterns/2026-05-29-net-issue-flow-gate-at-filing-site-not-just-ship.md`.

#### Invocation Mode

**If invoked by one-shot** (the conversation contains `soleur:one-shot` skill output earlier): Output exactly `## Work Phase Complete` and then **immediately invoke** `skill: soleur:review` (step 4 of the one-shot sequence). Do NOT end your turn after outputting the marker — you ARE the orchestrator, so you must continue executing one-shot steps 4 through 10 in order. The marker is a progress signal, not a stopping point.

**If invoked directly by the user** (no one-shot orchestrator): Continue through the post-implementation pipeline automatically. Do NOT stop and wait — the earlier learning "Workflow Completion is Not Task Completion" applies. Run these steps in order, forwarding `--headless` if `HEADLESS_MODE=true`:

1. `skill: soleur:review` (or `skill: soleur:review --headless` if headless) — catch issues before shipping
2. `skill: soleur:resolve-todo-parallel` — resolve any review findings (no `--headless` needed; this skill has no interactive prompts)
2.5. **Structural-UI visual gate (#4834 / ADR-049).** If the diff (`git diff --name-only origin/main...HEAD` — the branch-vs-main merge-base diff; do NOT use `origin/<branch>...HEAD`, which only sees unpushed commits and returns 0 files once the branch is pushed) touches `apps/web-platform/app/(dashboard)/**`, `apps/web-platform/components/dashboard/**`, or any `layout.tsx`, run `skill: soleur:qa` (or `--headless`) BEFORE shipping — its auth-seeded headless Playwright nav-states gate catches the CSS-layout regressions jsdom structurally cannot (the #4810 class). This is the step whose absence let direct `/work` skip the browser check that one-shot runs at its step 5.5 — wiring it here closes that asymmetry. Do NOT fire on leaf-component or content-only `.tsx` diffs. This is a scope boundary, not a stopping point: do not announce or return control here — continue executing the next step.
3. `skill: soleur:compound` (or `skill: soleur:compound --headless` if headless) — capture learnings before committing
3.5. Display: "Tip: After shipping, run `/clear` to reclaim context headroom for the next task."
4. `skill: soleur:ship` (or `skill: soleur:ship --headless` if headless) — commit, push, create PR, merge

---

## Key Principles

### Start Fast, Execute Faster

- Get clarification once at the start, then execute
- Don't wait for perfect understanding - ask questions and move
- The goal is to **finish the feature**, not create perfect process

### The Plan is Your Guide

- Work documents should reference similar code and patterns
- Load those references and follow them
- Don't reinvent - match what exists

### Test As You Go

- Run tests after each change, not at the end
- Fix failures immediately
- Continuous testing prevents big surprises

### Quality is Built In

- Follow existing patterns
- Write tests for new code
- Run linting before pushing
- Use reviewer agents for complex/risky changes only

### Review Before You Ship

- Use `skill: soleur:review` after completing implementation
- Catches issues before they reach PR reviewers
- Faster feedback than waiting for human review
- Builds confidence that your code is solid

### Compound Your Learnings

- Use `skill: soleur:compound` before creating a PR
- Document debugging breakthroughs, non-obvious patterns, and framework gotchas
- Even "simple" implementations can yield valuable insights
- Future-you and teammates will thank present-you

### Ship Complete Features

- Mark all tasks completed before moving on
- Don't leave features 80% done
- A finished feature that ships beats a perfect feature that doesn't

## Quality Checklist

Before entering Phase 4, verify these Phase 2-3 items are complete:

- [ ] All clarifying questions asked and answered
- [ ] All TodoWrite tasks marked completed
- [ ] Tests pass (run project's test command)
- [ ] New source files have corresponding test files
- [ ] Linting passes (use linting-agent)
- [ ] Code follows existing patterns
- [ ] Figma designs match implementation (if applicable)

After Phase 4 handoff (one-shot only), the same agent continues executing one-shot steps 4-10 (`/review`, `/qa`, `/compound`, `/ship`, `/test-browser`, `/feature-video`).

## When to Use Reviewer Agents

**Don't use by default.** Use reviewer agents only when:

- Large refactor affecting many files (10+)
- Security-sensitive changes (authentication, permissions, data access)
- Performance-critical code paths
- Complex algorithms or business logic
- User explicitly requests thorough review

For most features: tests + linting + following patterns is sufficient.

## Common Pitfalls to Avoid

- **Scope every mechanical edit to lines the BRANCH added** (`git diff origin/main...HEAD`), and
  assert the pre-existing count is unchanged afterwards. **Why:** #7810 — a remediation keyed on
  `assert_fixture_dir "$work"` deleted 13 PRE-EXISTING guards from #7709's burn-down, surfacing only
  because an unrelated count jumped 0 to 43.
- **Changing a function that decides what gets SCANNED? Diff its behaviour corpus-wide in BOTH
  directions and require the unsafe direction to be empty.** **Why:** #7810 — a heredoc repair meant
  to unhide code HID 1864 more lines, one file going 0 to 910 skipped. The shipped version asserts
  "files skipping MORE than before must be 0".

- **Analysis paralysis** - Don't overthink, read the plan and execute
- **Skipping clarifying questions** - Ask now, not after building wrong thing
- **Ignoring plan references** - The plan has links for a reason
- **Testing at the end** - Test continuously or suffer later
- **Forgetting TodoWrite** - Track progress or lose track of what's done
- **80% done syndrome** - Finish the feature, don't move on early
- **Over-reviewing simple changes** - Save reviewer agents for complex work
- **Silent plan omissions** - When dropping a conditional plan item, document why in the commit or plan
- **Research without cascade-validate loop** - For knowledge-base research deliverables, Phase 2.5 enforces: cascade findings into source artifacts → re-run validation → cascade again if new weak spots emerge → update brief with final results → present founder summary. "Findings written" is not "done" — "findings applied, validated, and all documents reflect the final state" is done. See Phase 2.5.
- **Missing founder summary** - After completing research, analysis, or audit work, present a concise summary: key findings table + all files changed table (file, what changed, before/after metrics if applicable). The founder needs to review what changed, not just what was discovered.
- **Incomplete replace_all** - After any `replace_all` Edit operation, grep the file to verify zero remaining matches before proceeding to the next task. `replace_all` can miss occurrences with different surrounding context (whitespace, indentation).
- **Encoded-blob value sweep** - When removing a value from a file that contains base64, hex, JSON-string-escape, or URL-encoded forms (JWT fixtures, encoded config snapshots, request payloads), source-form `grep` is insufficient. After substitution, decode each blob and grep the **decoded** form for the removed value. **Why:** PR #3054 — `replace_all "ifsccnjhymdmidffkzhl"` returned 0 source hits but `JWT_LOG_INJECT_U2028`'s base64 payload still encoded the dev Supabase ref; the secret scanner would have re-fired. See `knowledge-base/project/learnings/security-issues/2026-04-29-jwt-fixture-reminting-decode-verify.md`.
- **Synthesized secret-SHAPE fixtures trip GitHub Push Protection — split them across concatenation.** A fake value with a REAL token shape (`sk_live_…`, `ghp_…`, `sk-ant-…`, `AKIA…`) still matches GitHub's secret-scanning regex and blocks the push (`GH013 … Push cannot contain secrets`) even though it is synthetic per `cq-test-fixtures-synthesized-only`. Build sentinel fixtures via concatenation so no contiguous token literal exists in source while the runtime value keeps the redactor-matching shape: `const STRIPE = "sk_" + "live_0123…"`. Push scans every commit in range, so a working-tree fix is insufficient — purge the literal from history (no `rebase -i` in this env: `git reset --soft <pre-feature-base>`, re-`git add` the fixed files, recommit; verify `git diff --cached | grep -E '<token-regex>'` is empty first). **Why:** PR #5042 — a synthesized `sk_live_…` debug-redaction fixture blocked the push. See `knowledge-base/project/learnings/2026-06-08-debug-mode-stream-redaction-and-pushprotection.md`.
- **Local verification without Doppler** - For env-var-reading apps, use a single Bash call: `cd <abs-path> && doppler run -p soleur -c dev -- npm run <script>` (for `apps/web-platform`, `cd apps/web-platform && doppler run -p soleur -c dev -- npm run dev`). Prevents: (a) skipping `doppler run` (missing secrets), (b) invoking transitive binaries under `doppler run` (not on PATH), (c) relying on ambient CWD from a prior call (fragile — CWD persists, but an intervening `cd` can silently redirect it). If port 3000 is already bound by another dev server (the user may have one running), start on an alternate port via `PORT=3099 doppler run ... npm run dev` rather than killing the existing process. (ex-`cq-for-local-verification-of-apps-doppler`; #2350 hit all three failure modes in sequence; PR #3199 added the alt-port fall-through after the stale `./scripts/dev.sh` reference broke startup)
- **Closes-after-apply deferral missed in commit messages** - When a plan's `## Risks` (or `## Sharp Edges`) section names an explicit Closes-after-apply deferral (issue stays open until a post-merge PM step proves green — workflow first-run, terraform apply, deploy probe, etc.), commit messages AND PR body MUST default to `Ref #N`, not `Closes #N`, regardless of whether the commit body's `Closes` placement is technically `wg-use-closes-n-in-pr-body-not-title-to`-legal. Auto-close fires at merge time, decoupled from whether the proof artifact actually lands green. Detection: grep the plan for `Closes-after-apply`, `manual close after`, `Ref #N` + `close manually`, `type: ops-remediation`, or any explicit per-PM closure-link instruction. On match, emit `Ref #N` + 1-line WARN. The author manually `gh issue close N --comment "<run URL>"` post-PM. **Why:** PR #3551 — initial commit message used `Closes #3060` against plan §R6's `Ref #3060 + manual close after PM1 confirms first green run` directive; caught pre-push via self-audit, amended. See `knowledge-base/project/learnings/2026-05-11-plan-r6-closes-after-apply-deferral-pattern.md`.
- **Meta-content describing close behavior auto-closes the issue on merge, and hand-rolled merges bypass the scanner that catches it.** GitHub's close-keyword parser is word-boundary based, so the hyphen in `auto-closes #N` matches `\bcloses\b #N` (negated `does not close #N` matches too); on a squash merge it reads the **branch commit body**, not just the PR description. When a commit/PR legitimately *describes* close behavior (a follow-through script's docstring, "the sweeper auto-closes #N"), it fires anyway. Two guards: (1) any merge path that skips `/ship` (admin-merge, hand-rolled `gh pr merge`, GitHub UI) MUST still run `plugins/soleur/skills/ship/scripts/auto-close-scan.sh` over `git log origin/main..HEAD --format=%B`; (2) write close-behavior prose without the bare `<keyword> #N` adjacency — "auto-resolves issue #N", "the sweeper will close issue #N". File *contents* are safe (GitHub parses messages + PR bodies, not diffs). **Why:** #5689 — the #5717 squash-commit body "sweeper auto-closes #5689" auto-closed the still-open soak-gated issue; the admin-merge bypassed `/ship` Phase 6's auto-close scan. See `knowledge-base/project/learnings/2026-06-29-auto-closes-meta-content-in-commit-body-trips-github-autoclose-on-hand-rolled-merge.md`.
- **Parallel `gh issue create` scrambles ID-to-title mapping** - When a /work task files N related GitHub issues (e.g., a deferral-issues batch), `gh issue create ... &` + `wait` returns URLs in completion order, not start order. The first `gh` job to FINISH gets `#N`, the next gets `#N+1`, etc. — independently of which title started first. Worse, a transient GraphQL error on one parallel job is easy to misattribute to the wrong title. **Either serialize the calls** (~1.5–3s each is cheap for ≤5 issues), **or write each result to a name-keyed file under a per-run scratch dir** (`d=$(mktemp -d -t gh-issues.XXXXXXXX); echo "$url" > "$d/issue-$short_name.url"`) so the title→ID mapping is explicit. Key the dir per-run, not per-issue-name: a bare name-keyed path in a shared scratch namespace is a pure function of the issue name, so a sibling session filing a like-named issue overwrites the mapping this recipe exists to keep straight. **Always run `gh issue view <N> --json title` reconciliation before citing IDs in any artifact** (agent body, README, SKILL.md, plan). The cost of catching wrong IDs at PR review is ~30 minutes of recovery (close duplicate, file missing, edit artifacts, force-push); the cost of post-creation reconciliation is N seconds. **Why:** PR #4288 — 5 deferral issues filed in parallel; 3 of 5 IDs ended up inverted in the agent body, 1 was dropped (GraphQL error), 1 was a duplicate retry. See `knowledge-base/project/learnings/2026-05-22-parallel-gh-issue-create-scrambles-id-mapping-and-review-agent-producer-consumer-symmetry.md`.
- **Never heredoc an issue-body into the SAME Bash command as a hook-gated `gh issue create`** - A PreToolUse hook denial (e.g. the `--milestone`-required gate) rejects the ENTIRE Bash tool call, so a preceding `cat > /tmp/body.md <<EOF … EOF` in the same command never runs — the corrected retry then fails `no such file`. Write the body with the **Write tool** (or a separate Bash step) FIRST, then run `gh issue create --body-file <path> --milestone <m>` as its own command. **Why:** filing #4730 — first `gh issue create` was denied for a missing `--milestone`, taking its inline heredoc down with it. See `knowledge-base/project/learnings/bug-fixes/2026-06-01-best-effort-cron-monitor-liveness-not-success-and-offhost-visible-warn.md`.
- **A green "full suite" that never ran the suites gating your directory, and a background wrapper whose exit code is not the command's** — two independent ways to read a pass that did not happen. (a) [scripts/test-all.sh](../../../../scripts/test-all.sh) covers `apps/web-platform/infra/` only via the nested `run-registered-suites.sh`, and only when `want_infra` holds AND the diff touches it — so in CI's `webplat`/`bun`/`scripts` shards its `rc=0` is NOT evidence for an infra change. Its epilogue now states which happened; read it rather than assuming either way. When a diff changes a LITERAL, `git grep` for suites asserting it and run every suite registered in the workflow that gates the changed directory — a sibling can pin your expression byte-for-byte and go RED while your own suites stay green. Prefer re-keying such a sibling onto the invariant's SHAPE and leaving the exact-string pin in ONE place, rather than replicating the literal across two files with no parity test. (b) For `run_in_background` of the form `cmd > log 2>&1; echo $? > rc`, the harness notification reports the **trailing echo / wrapper**, not the command — "completed (exit code 0)" fires while the suite is still running. Verify with the explicit rc FILE **plus** a `pgrep` **plus** log-size growth over a ≥20s window; and after a foreground timeout, kill the surviving child before relaunching or two concurrent suites race on one worktree. **Why:** #6588/PR #6716 — an infra sibling (`workspaces-luks-header.test.sh` H17) went red unnoticed behind a green `test-all.sh`, and the bg notification lied twice in one session.
- **A background-task completion notification reports the LAST command's exit, not the command you care about — and `test-all.sh` is not the gate for every directory.** For `cmd > log 2>&1; echo $? > rc`, the harness notification reads the trailing `echo` (always 0), so "completed (exit code 0)" is compatible with a suite that exited 1: read the **rc file** and grep the log's own summary line before reporting green. Twinned trap: [scripts/test-all.sh](../../../../scripts/test-all.sh) covers `apps/web-platform/infra/` CONDITIONALLY (nested runner, gated on `want_infra` and on the diff; its preamble and epilogue both state which happened), and an ad-hoc `for f in infra/*.test.sh` loop is not the CI-registered set either — use `apps/web-platform/infra/run-registered-suites.sh`, which DERIVES its list from `infra-validation.yml` and reports unregistered orphans. **Why:** #6969 — a notification-reported 0 masked a real op-contract regression (225/226), and the infra half of the diff was being validated by the wrong runner. See `knowledge-base/project/learnings/2026-07-26-an-existence-assertion-that-ran-before-the-file-existed-bricked-every-boot.md`.
- **A shell function called as `x=$(fn)` silently loses every effect except stdout — and a regression test that re-declares the correct idiom inline cannot see the regression it was written for.** Command substitution runs `fn` in a SUBSHELL, so an array append (`ARR+=(…)`), a caller-visible assignment, or an `exit`/`die` inside it is discarded: the parent's cleanup trap owns nothing, or the parent continues past a FATAL and exits 0. A function is safe to call as `$( )` only if its ONLY contract is stdout; the moment it also mutates state or aborts, either return through stdout and check `$?` in the parent, or call it directly and let it set globals — never both. Awareness does not protect: in #6986 this class appeared FOUR times in one session, three of them in code written to fix it, and only the loud two (`unbound variable`) self-reported. The companion trap is the test: a cleanup regression test whose fixture is a heredoc re-declaring the good idiom asserts a known-good snippet is known-good — reverting the real file to the leaky form leaked 34 orphans while it printed PASS. Drive THE REAL FILE (a sentinel `TMPDIR` + a recursion-breaking env guard), and mutation-prove it on a sandbox copy: baseline GREEN, mutant RED, mutation confirmed landed via `diff -q` against a pristine backup. Related: never `pkill -f <pattern>` where the pattern appears in the pkill command line itself — it matches and kills the invoking shell. Use `bash "${CLAUDE_PLUGIN_ROOT}/scripts/lib/proc.sh" list_runs <pattern>` to see who owns each match and `… kill_mine <pattern>` to signal only this worktree's; both resolve ownership through `/proc/<pid>/cwd` and exclude self, ancestry and own process group, which a bracket pattern cannot do. **Why:** #6986 — 3,766 files leaked past a CI guard built for exactly this defect. See `knowledge-base/project/learnings/2026-07-27-the-subshell-bug-i-was-fixing-bit-me-three-more-times.md`.
- **A `ps | grep` hit is not YOUR process, and a lock QUEUE is not a stall — resolve ownership and read the preamble before killing anything.** Two ways a long gate gets misdiagnosed on a machine running parallel worktrees. (a) **Ownership:** `ps -ef | grep test-all.sh | wc -l` counts every session on the box — resolve `/proc/PID/cwd` for each hit before concluding your own run is alive (or dead); in #7162 six matches belonged to THREE other sessions (another worktree's infra rehearsal, a `/var/tmp/mutbat.*` battery, a third session id). (b) **Queue vs hang:** `test-all.sh` parks after emitting `[contention] LOCK_WAITING: '<name>' — waiting up to <N>s for the advisory lock.` while a sibling holds the lock, so a no-progress stall detector fires on a run that is merely waiting — `grep -q 'LOCK_WAITING' "$log"` before killing and relaunching, or you serialize yourself behind your own duplicate. (Do NOT key this on a byte count: this guidance read "~1206 bytes" until #7484 added the marker, and a byte-count heuristic silently stops matching the moment anything upstream of the wait changes its output by a character.) Then run the gate under `setsid nohup` (background-duration limits killed it 4× with exit 144 = signal 16, and a foreground 10-min timeout once) and read the **rc file**, never the completion notification. **Why:** #7162 — four killed runs before the suite completed. See `knowledge-base/project/learnings/workflow-issues/2026-08-03-blanket-renumber-rewrote-other-work-and-a-count-certified-it.md`.
- **`VAR=$(<file) 2>/dev/null` does not suppress — bash expands before it redirects for an assignment-only command.** A simple command consisting *only* of assignments applies its redirections after the right-hand side is expanded, so a missing file still prints `No such file or directory` to the ORIGINAL stderr — and in a test harness that captures the subject's output (`cmd 2>&1`), that noise lands in the captured text every run, corrupting exactly the diagnostics you read when something fails. Brace it: `{ VAR=$(<file); } 2>/dev/null || VAR=0`. The trap is that this shape is usually introduced as an *optimization* over a correct `VAR=$(cat file 2>/dev/null || echo 0)` (dropping a fork on a hot path), so it reads as strictly better while being a regression. Same family: `x=$(cmd) 2>/dev/null` for any `cmd`. Sibling trap in the same class — a redirect that binds to the wrong thing: `VAR=$(tr a b < "$f" 2>/dev/null)` sends *`tr`'s* stderr to `/dev/null`, not the input redirection, so a missing `$f` makes the assignment non-zero and, under `set -euo pipefail`, aborts the WHOLE suite rather than failing one test — guard the read (`[[ -s "$f" ]] || VAR="<missing>"`) instead. **Why:** #6665 — both shipped into a test harness and were caught by two independent review agents. See `knowledge-base/project/learnings/2026-07-28-the-property-my-pr-existed-to-buy-was-pinned-by-nothing.md`.
- **Emitting a forward-looking sentence as the last thing in a turn.** "Continuing to compound → ship" at the end of a response is not a handoff, it is an abandoned pipeline — the operator has to ask whether it happened. A phase-complete marker (`## Work Phase Complete`, `## Review Phase Complete`) is a CHECKPOINT: the next tool call in the SAME response must be the successor skill invocation. Stating an intention is not performing it. **Why:** #6588/PR #6716 — review completed, the turn ended on "continuing", and the operator had to prompt for the rest of the pipeline.
- **Relaunching a long-running background bash before verifying it died** - When a `run_in_background: true` task seems unresponsive, do NOT relaunch until ALL three checks confirm death: (1) broad `ps -ef \| grep -E '<substring>' \| grep -v grep` (never `pgrep -fa 'pattern$'` — anchored patterns miss processes wrapped in `doppler run -- bash ...`), (2) cache/output file size stopped growing over a 30+ second window, (3) the harness's `<task-notification>` has fired with a definitive `status` field. Log file `mtime` is NOT a liveness signal — long-running scripts buffer output between API calls. Relaunching prematurely concurrent-runs against the same API key and wastes paid spend. **Why:** PR #4156 — bench 1 was running fine the whole ~75 min, but `pgrep` with anchored pattern + stale log mtime led to two redundant bench launches (extra ~$2-3 Anthropic spend). See `knowledge-base/project/learnings/workflow-issues/2026-05-20-long-running-bench-verify-process-before-relaunch.md`. **The inverse is as costly: STOPPING one means killing the tree, not the PIDs you launched.** `run-registered-suites.sh` fans out via `xargs -P 6`, so killing its wrapper and runner leaves the workers orphaned and running — measured at 46 minutes still spawning containers, on a machine the operator had twice flagged as contended. Resolve the root's descendants (`pstree -p <root>`), confirm each `/proc/<pid>/cwd` is YOUR worktree before killing, and re-probe afterwards: a kill you did not verify is a kill you did not make.
