import { BranchStateGuardConfig } from '@webpieces/rules-config'; import type { FileContext, Violation } from '../types'; import { FileRuleBase } from '../rule-base'; import { FixHint } from '../fix-hint'; /** * Blocks READS while the checked-out branch is a stale place to read from. TWO states: * * A. on `main`, and local main is BEHIND origin/main * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved * past it and a squash merge means its HEAD is not even an ancestor of main) * * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons, * plans and writes against code that no longer exists upstream. Blocking the write is too late — * the bad premise is already in context. So the block lands on the read. (feature-branch-guard * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the * two share one recovery message via MergedBranchMessage.) * * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure, * `git pull --ff-only`, is not a fast-forward on a dirty tree — true, but that is a fact about the * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty * tree, so the block no longer has to be suppressed to keep the printed cure runnable. State B's valve * never had an argument at all — its cure was always the branch form. * * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch. * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop. * * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`, * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash. * So there is no command allowlist to maintain and no way to lock the agent out of its own fix. * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which * returns null when the branch IS main — the two guards are complementary, not stacked.) * * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting, * for a whole session, while the logs read "read-stale-guard handled". stale-main-bash-guard is the * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard * blocks — `main`, KNOWN BEHIND `origin/main` by the ancestry test below — it default-denies Bash and * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay * simple and Read-only. * * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data * is far worse than one that misses; every unknown resolves to "allow". Note the dual, which is what * the deleted dirty valve violated: never fail open on data you DID establish. A dirty tree is not an * unknown — it is a known state with a known cure. The three deliberate escape valves: * * 1. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by * the detached refresher and is arbitrarily old, so `local !== origin` stays * true for a while AFTER a successful pull, which would spin the agent forever. * Instead: is the cached origin/main an ANCESTOR of local main? If local main * already contains it, we are not behind. That flips the instant the pull lands, * with no refresher round-trip. This is the single most important line here. * 2. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core; * this closes the read half of that same escape hatch. * 3. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local * main at all (fresh clone / worktree) → allow. * * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also * what makes reads keep the shared main-sync cache warm for feature-branch-guard. */ export declare class ReadStaleGuardRule extends FileRuleBase { constructor(config: BranchStateGuardConfig); private readonly freshness; readonly description = "Block reads on a branch that is stale to read from \u2014 a `main` behind origin/main, or a feature branch whose PR is already merged."; readonly files: string[]; readonly defaultOptions: { hangTimeoutMinutes: number; }; readonly fixHint: FixHint; check(ctx: FileContext): readonly Violation[]; private checkStaleMain; /** * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every * plan built from it is built on code origin/main has moved past. * * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow, * which is the fail-open direction for free. * * NO DIRTY-TREE VALVE. `git checkout -b origin/main` carries uncommitted changes onto the * fresh branch, so the work comes with you and there is nothing to rescue by reading. When it does * NOT (an overlapping change landed in main, so git refuses the switch), `git stash` is on the L2 * skip list and is never blocked: stash → branch → pop. The valve that used to sit here was drift * from the documented design, not a decision — this docblock described the strict behaviour for * releases while the code failed open. */ private checkMergedBranch; private mergedMessage; private isConfigFile; private behindCount; private staleMainMessage; private cacheSummary; /** * The guard could not ESTABLISH the state it judges on, so it judged nothing. * * A sibling of allow() rather than a reason string passed to it, because the difference has to * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'` * suffix on the free-text reason, which meant an abstention and a real approval were the same * verdict and the abstentions could not be counted — so nobody could tell whether these guards * were protecting anything or quietly standing down. Never block on data you could not * establish; but say out loud, in a field, that you did not establish it. */ private failOpen; private allow; private block; private logDecision; /** * The current branch, WITHOUT spawning git on the common path. * * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead — * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch * (the overwhelmingly common case) that file read is the ONLY work this guard does before * short-circuiting, so reads stay effectively free. * * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively: * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere * - detached HEAD → the file holds a raw sha, not a `ref:` line * - anything unreadable/unexpected * The fallback is correct in all those cases; it is just slower, and they are rare. */ private currentBranch; private branchFromGitHead; }