@webpieces/ai-hook-rules 0.4.724 → 0.4.725
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/bin/l0-allowlist.d.ts +1 -1
- package/src/bin/l0-allowlist.js +3 -3
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/shim-drift-fix.js +1 -1
- package/src/bin/shim-drift-fix.js.map +1 -1
- package/src/core/l0-matrix.js +1 -1
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l2-doc.js +4 -4
- package/src/core/l2-doc.js.map +1 -1
- package/src/core/l2-rows.d.ts +1 -1
- package/src/core/l2-rows.js +9 -9
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/branch-switch-scan.d.ts +1 -1
- package/src/core/rules/branch-switch-scan.js +1 -1
- package/src/core/rules/branch-switch-scan.js.map +1 -1
- package/src/core/rules/cure-prefix-scan.d.ts +1 -1
- package/src/core/rules/cure-prefix-scan.js +2 -2
- package/src/core/rules/cure-prefix-scan.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +1 -1
- package/src/core/rules/feature-branch-guard.js +1 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.js +1 -1
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.js +2 -2
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +1 -1
- package/src/core/rules/read-stale-guard.js +3 -3
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/redirect-how-to-merge-main.js +1 -1
- package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +3 -3
- package/src/core/rules/stale-main-bash-guard.js +6 -6
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +2 -2
- package/src/core/rules/stale-main-message.js +3 -3
- package/src/core/rules/stale-main-message.js.map +1 -1
- package/src/core/rules/tree-recovery.d.ts +3 -3
- package/src/core/rules/tree-recovery.js +6 -6
- package/src/core/rules/tree-recovery.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitchScan } from './branch-switch-scan';\nimport { RecoveryAllowlist } from './recovery-allowlist';\nimport { MainFreshness } from './main-freshness';\nimport { CurePrefixScan, CurePrefix } from './cure-prefix-scan';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces — and\n * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the\n * orphan-directory sweep welded on. The message prescribes the one command; the raw pair\n * stays legal because it is plain git and because it is the L0 recovery cure, where\n * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ────────────────────────────────\n *\n * The finding is not \"you are on `main`\" — it is **\"what you would read here is out of date\"**. So the\n * ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only\n * when local `main` is known to be BEHIND `origin/main`. Unknown → allow. Current → allow.\n *\n * WHY, when this guard spent a release judging the branch alone: because the branch alone denies\n * everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly\n * where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-checkout-clean-main` — the\n * command this repo tells it to run — and the next `curl`, `gh pr close` or test run is refused by a\n * guard whose own name says STALE. The tool that got it there could not be the cure for being there,\n * and the refusal had nothing to do with staleness, which is the confusion reported from the field.\n *\n * THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right\n * about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place\n * whatever `main`'s freshness — unreviewable, and unrevertable as a unit — so feature-branch-guard\n * stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms\n * nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there.\n * Same tree, different hazard, so one precondition was never right for both.\n *\n * THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks \"does local `main`\n * already contain the cached `origin/main`?\", so the block lifts the instant a pull lands rather than\n * waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses — one\n * implementation, so the Read and Bash halves of one state can never disagree about whether the pull\n * took.\n *\n * FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable:\n * no cache (the first call of every session), a cache for another branch, an empty `originMain`\n * (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is\n * fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no\n * synchronous `git fetch` is ever run on the blocking path.\n *\n * THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape\n * merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read\n * BLOCKLIST could not replace it — enumerating readers catches `cat` and `grep`, and structurally\n * cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands\n * whose stated purpose is something else and whose effect is to modify tracked files. What changed is\n * WHEN that polarity applies, not the polarity.\n *\n * BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests,\n * installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.\n * ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the\n * blocked state, everything that gets you OUT or tells you where you are:\n * `git checkout -b <new> origin/main`, `git switch`, `git pull`/`fetch`,\n * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this\n * tree), `curl`/`wget`, every `wp-*` bin, installs.\n *\n * ── ROWS 12/13: the cure may be COMPOSED with the work, but only with `&&` ───────────────────────\n *\n * `pnpm wp-checkout-clean-main && cat src/app.ts` is allowed and `pnpm wp-checkout-clean-main ; cat\n * src/app.ts` is not, and the difference is the shell's rather than this guard's: `&&` short-circuits,\n * so the work cannot run when the cure failed — the exact property the block is here to guarantee. `;`\n * discards the exit code and runs the work anyway. See cure-prefix-scan.ts for the measured shapes.\n *\n * There is no dirty-tree valve here and none in read-stale-guard either: the cure is\n * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody\n * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'stale-main-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n private readonly switches = new BranchSwitchScan(this.scanner);\n // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states\n // cannot drift apart about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n // The ancestry test and the cache summary, shared with read-stale-guard — see main-freshness.ts.\n private readonly freshness = new MainFreshness();\n // ROWS 12/13 — `<cure> && <work>` vs `<cure> ; <work>`. See cure-prefix-scan.ts.\n private readonly curePrefix = new CurePrefixScan(this.scanner);\n\n readonly description =\n 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +\n 'the same command), and — once local main is KNOWN to be behind origin/main — block Bash ' +\n 'there, allowlisting only the commands that get you off it. A main that is current, or whose ' +\n 'freshness is unknown, is left alone.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.',\n 'Get onto a feature branch, or go to main with the one command that pulls it too:',\n [\n // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no\n // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for\n // \"we cannot detect the tree\": both forms, each labelled. That matters here because the\n // primary-clone form goes to `main`, and a linked worktree has no `main` to go to — it is\n // checked out in the primary clone — so a preferred option naming it unconditionally hands\n // the AI a cure that cannot work where it is standing. The per-block message\n // (pairingMessage) is detected and prints exactly one form; this is the fallback for the\n // hint that cannot look.\n new Option(this.recovery.updateMainSteps('unknown').join('\\n')\n + '\\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),\n new Option('Already on main: pnpm wp-checkout-clean-main (then re-run) — it pulls main and takes the trash out in the one command this repo prescribes. You may chain your command onto it with && (pnpm wp-checkout-clean-main && <your command>), which is skipped if the pull fails; a ; instead runs your command anyway and is refused.'),\n new Option('Still allowed: every BASH command, on a main that is current or whose freshness is not known — this guard only closes once local main is known to be BEHIND origin/main. (Write/Edit on main is a different policy and is blocked by feature-branch-guard however current main is.) In that state you keep the Read tool while main is current (read-stale-guard closes it when main falls behind, because stale reads are worthless) plus everything that gets you OUT or tells you where you are: git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n // PREVENTIVE half, FIRST and unconditional. Deliberately ahead of every fail-open bailout\n // below: those all ask \"is the main we are ON stale?\", and this asks about the main we are\n // about to MOVE TO — a different branch, and one no cache can describe yet.\n const bare = this.bareCheckoutOfMain(ctx);\n if (bare !== null) {\n return this.block(ctx, 'any', `bare checkout of main (${bare})`, this.pairingMessage(ctx), '-');\n }\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main (state B is another guard)');\n\n // ROW 4 — the skip list, ahead of the block, so no command that gets you OUT is ever denied.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');\n }\n\n return this.checkFreshness(ctx, branch);\n }\n\n /**\n * ROWS 6/7 — block ONLY when local `main` is KNOWN to be behind `origin/main`.\n *\n * Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and\n * that is on purpose — the Read and the Bash halves of one state must not disagree about whether\n * `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one\n * file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the\n * row 4 skip list already applied above.\n *\n * Every exit that is not \"established BEHIND\" is an ALLOW, and the not-established ones are\n * `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.\n */\n private checkFreshness(ctx: BashContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n // The first call of every session, and every call while another worktree holds the refresh\n // lock. The refresher fired above populates it for the NEXT call; nothing waits here.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.freshness.summarize(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry BY KEY, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // ANCESTRY, not equality — the line that makes a pull take effect immediately. See\n // MainFreshness.containsOriginMain.\n if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // ESTABLISHED BEHIND. Now — and only now — the default-deny polarity applies.\n return this.judgeComposition(ctx, branch, cache);\n }\n\n /**\n * ROWS 12/13 — `<cure> && <work>` is allowed; `<cure> ; <work>` is not.\n *\n * The distinction is the shell's, not this guard's invention. `&&` short-circuits, so the work\n * cannot run when the cure exits non-zero — which is precisely the property the block exists to\n * guarantee, already enforced by the interpreter. Refusing it bought nothing and cost a round\n * trip, and the fleet audit files that as a TOOLING defect.\n *\n * `;` discards the exit code and runs the work regardless, and it was measured with\n * `>/dev/null 2>&1` on the cure in 7 of 9 observed cases — so the failure was invisible as well as\n * ignored. The two-step is genuinely safer there: the NEXT tool call is a fresh evaluation that\n * recomputes `localMain` against `originMain`, so a pull that failed re-blocks. An allowed `;`\n * compound never gets that second look.\n */\n private judgeComposition(ctx: BashContext, branch: string, cache: string): readonly Violation[] {\n const prefix = this.curePrefix.classify(ctx.command);\n if (prefix.kind === 'short-circuits') {\n return this.allow(ctx, branch, 'cure-prefixed, && short-circuits the work', cache);\n }\n if (prefix.kind === 'runs-anyway') {\n return this.block(ctx, branch, 'cure-prefixed, work runs anyway', this.compositionMessage(prefix), cache);\n }\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n // BranchSwitchScan answers \"which branch does this land on\" for both guards, flag-tolerantly:\n // `git checkout -q main` lands on main exactly as the bare form does, while\n // `git checkout -b x origin/main` (creates), `git checkout -- main` (pathspec) and\n // `git checkout <sha>` do not. See branch-switch-scan.ts for why that lives in one place.\n if (!this.switches.landsOnExistingMain(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n /**\n * The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what\n * the guard's name promised all along.\n *\n * The two versions before this one are both instructive. The oldest opened with how many commits\n * behind `main` was, which invited the wrong cure: an agent that reads \"behind\" reaches for a pull,\n * lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said\n * `main` is not a place to work \"whether or not it is current\" — true of a WRITE, but this guard\n * does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable.\n *\n * So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you\n * read here is out of date; Bash is default-deny in this state rather than a list of readers,\n * because a command's stated purpose never says whether it also WRITES; and the branch cure fetches,\n * so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN\n * stays legitimate — on a CURRENT `main` nothing here fires at all.\n *\n * ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b`\n * carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard\n * prints it, because a Read really can be cured by staying put. A Bash session cannot — the next\n * command is as likely to write as to read.)\n */\n private staleMainMessage(workspaceRoot: string): string {\n return 'Blocked: local `main` is BEHIND origin/main, so what you would read here is out of '\n + 'date and a plan built on it is built on code upstream has moved past. A CURRENT main is '\n + 'not blocked — reading main to PLAN is fine, and this fires only once being behind is '\n + 'established. Bash is default-deny in this state rather than a list of readers, because a '\n + \"command's stated purpose never says whether it also WRITES. The feature branch is the \"\n + 'unit of work in any case: reviewable, revertable — and the cure fetches, so it makes '\n + 'your reads true as well as moving you off main.\\n'\n + `Start a branch (uncommitted work comes with you):\\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;\n }\n\n /**\n * ROW 13's deny. It NAMES the operator the agent typed, because the fix is a one-character edit\n * and an agent told only \"use `&&`\" has to diff the two spellings itself to find where.\n */\n private compositionMessage(prefix: CurePrefix): string {\n return `Your cure is joined with \\`${prefix.operator}\\` — the work runs even if the pull fails. `\n + 'Use `&&` so it is skipped:\\n'\n + '\\n pnpm wp-checkout-clean-main && <your command>\\n\\n'\n + 'Or run the cure alone and re-issue your command in the next call.';\n }\n\n private pairingMessage(ctx: BashContext): string {\n const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\\n');\n // Deliberately SHORT. The incident that bought this guard (a main 157 commits behind; the\n // downgrade the reverted shim then prescribed) is maintainer material and lives in the class\n // docblock above — the reader of THIS text needs only what changes what they type.\n return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had — '\n + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '\n + 'reports the drift BACKWARDS. Go to main with the one command that also pulls it:\\n' + steps;\n }\n\n\n\n\n\n\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row, the same way an L0 block does. The doc is written\n // LAZILY here rather than up front: only a blocked agent needs it, and this is the one path\n // that knows the row it should be opened at.\n const row = matrixL2Row(reason).row;\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('stale-main-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitchScan } from './branch-switch-scan';\nimport { RecoveryAllowlist } from './recovery-allowlist';\nimport { MainFreshness } from './main-freshness';\nimport { CurePrefixScan, CurePrefix } from './cure-prefix-scan';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces — and\n * `pnpm wp-sync-main`, which IS that pairing with the cleanup and the\n * orphan-directory sweep welded on. The message prescribes the one command; the raw pair\n * stays legal because it is plain git and because it is the L0 recovery cure, where\n * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ────────────────────────────────\n *\n * The finding is not \"you are on `main`\" — it is **\"what you would read here is out of date\"**. So the\n * ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only\n * when local `main` is known to be BEHIND `origin/main`. Unknown → allow. Current → allow.\n *\n * WHY, when this guard spent a release judging the branch alone: because the branch alone denies\n * everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly\n * where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-sync-main` — the\n * command this repo tells it to run — and the next `curl`, `gh pr close` or test run is refused by a\n * guard whose own name says STALE. The tool that got it there could not be the cure for being there,\n * and the refusal had nothing to do with staleness, which is the confusion reported from the field.\n *\n * THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right\n * about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place\n * whatever `main`'s freshness — unreviewable, and unrevertable as a unit — so feature-branch-guard\n * stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms\n * nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there.\n * Same tree, different hazard, so one precondition was never right for both.\n *\n * THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks \"does local `main`\n * already contain the cached `origin/main`?\", so the block lifts the instant a pull lands rather than\n * waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses — one\n * implementation, so the Read and Bash halves of one state can never disagree about whether the pull\n * took.\n *\n * FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable:\n * no cache (the first call of every session), a cache for another branch, an empty `originMain`\n * (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is\n * fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no\n * synchronous `git fetch` is ever run on the blocking path.\n *\n * THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape\n * merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read\n * BLOCKLIST could not replace it — enumerating readers catches `cat` and `grep`, and structurally\n * cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands\n * whose stated purpose is something else and whose effect is to modify tracked files. What changed is\n * WHEN that polarity applies, not the polarity.\n *\n * BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests,\n * installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.\n * ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the\n * blocked state, everything that gets you OUT or tells you where you are:\n * `git checkout -b <new> origin/main`, `git switch`, `git pull`/`fetch`,\n * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this\n * tree), `curl`/`wget`, every `wp-*` bin, installs.\n *\n * ── ROWS 12/13: the cure may be COMPOSED with the work, but only with `&&` ───────────────────────\n *\n * `pnpm wp-sync-main && cat src/app.ts` is allowed and `pnpm wp-sync-main ; cat\n * src/app.ts` is not, and the difference is the shell's rather than this guard's: `&&` short-circuits,\n * so the work cannot run when the cure failed — the exact property the block is here to guarantee. `;`\n * discards the exit code and runs the work anyway. See cure-prefix-scan.ts for the measured shapes.\n *\n * There is no dirty-tree valve here and none in read-stale-guard either: the cure is\n * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody\n * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'stale-main-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n private readonly switches = new BranchSwitchScan(this.scanner);\n // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states\n // cannot drift apart about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n // The ancestry test and the cache summary, shared with read-stale-guard — see main-freshness.ts.\n private readonly freshness = new MainFreshness();\n // ROWS 12/13 — `<cure> && <work>` vs `<cure> ; <work>`. See cure-prefix-scan.ts.\n private readonly curePrefix = new CurePrefixScan(this.scanner);\n\n readonly description =\n 'Block a bare `git checkout main` (use `pnpm wp-sync-main`, or chain the pull into ' +\n 'the same command), and — once local main is KNOWN to be behind origin/main — block Bash ' +\n 'there, allowlisting only the commands that get you off it. A main that is current, or whose ' +\n 'freshness is unknown, is left alone.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.',\n 'Get onto a feature branch, or go to main with the one command that pulls it too:',\n [\n // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no\n // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for\n // \"we cannot detect the tree\": both forms, each labelled. That matters here because the\n // primary-clone form goes to `main`, and a linked worktree has no `main` to go to — it is\n // checked out in the primary clone — so a preferred option naming it unconditionally hands\n // the AI a cure that cannot work where it is standing. The per-block message\n // (pairingMessage) is detected and prints exactly one form; this is the fallback for the\n // hint that cannot look.\n new Option(this.recovery.updateMainSteps('unknown').join('\\n')\n + '\\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),\n new Option('Already on main: pnpm wp-sync-main (then re-run) — it pulls main and takes the trash out in the one command this repo prescribes. You may chain your command onto it with && (pnpm wp-sync-main && <your command>), which is skipped if the pull fails; a ; instead runs your command anyway and is refused.'),\n new Option('Still allowed: every BASH command, on a main that is current or whose freshness is not known — this guard only closes once local main is known to be BEHIND origin/main. (Write/Edit on main is a different policy and is blocked by feature-branch-guard however current main is.) In that state you keep the Read tool while main is current (read-stale-guard closes it when main falls behind, because stale reads are worthless) plus everything that gets you OUT or tells you where you are: git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n // PREVENTIVE half, FIRST and unconditional. Deliberately ahead of every fail-open bailout\n // below: those all ask \"is the main we are ON stale?\", and this asks about the main we are\n // about to MOVE TO — a different branch, and one no cache can describe yet.\n const bare = this.bareCheckoutOfMain(ctx);\n if (bare !== null) {\n return this.block(ctx, 'any', `bare checkout of main (${bare})`, this.pairingMessage(ctx), '-');\n }\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main (state B is another guard)');\n\n // ROW 4 — the skip list, ahead of the block, so no command that gets you OUT is ever denied.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');\n }\n\n return this.checkFreshness(ctx, branch);\n }\n\n /**\n * ROWS 6/7 — block ONLY when local `main` is KNOWN to be behind `origin/main`.\n *\n * Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and\n * that is on purpose — the Read and the Bash halves of one state must not disagree about whether\n * `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one\n * file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the\n * row 4 skip list already applied above.\n *\n * Every exit that is not \"established BEHIND\" is an ALLOW, and the not-established ones are\n * `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.\n */\n private checkFreshness(ctx: BashContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n // The first call of every session, and every call while another worktree holds the refresh\n // lock. The refresher fired above populates it for the NEXT call; nothing waits here.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.freshness.summarize(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry BY KEY, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // ANCESTRY, not equality — the line that makes a pull take effect immediately. See\n // MainFreshness.containsOriginMain.\n if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // ESTABLISHED BEHIND. Now — and only now — the default-deny polarity applies.\n return this.judgeComposition(ctx, branch, cache);\n }\n\n /**\n * ROWS 12/13 — `<cure> && <work>` is allowed; `<cure> ; <work>` is not.\n *\n * The distinction is the shell's, not this guard's invention. `&&` short-circuits, so the work\n * cannot run when the cure exits non-zero — which is precisely the property the block exists to\n * guarantee, already enforced by the interpreter. Refusing it bought nothing and cost a round\n * trip, and the fleet audit files that as a TOOLING defect.\n *\n * `;` discards the exit code and runs the work regardless, and it was measured with\n * `>/dev/null 2>&1` on the cure in 7 of 9 observed cases — so the failure was invisible as well as\n * ignored. The two-step is genuinely safer there: the NEXT tool call is a fresh evaluation that\n * recomputes `localMain` against `originMain`, so a pull that failed re-blocks. An allowed `;`\n * compound never gets that second look.\n */\n private judgeComposition(ctx: BashContext, branch: string, cache: string): readonly Violation[] {\n const prefix = this.curePrefix.classify(ctx.command);\n if (prefix.kind === 'short-circuits') {\n return this.allow(ctx, branch, 'cure-prefixed, && short-circuits the work', cache);\n }\n if (prefix.kind === 'runs-anyway') {\n return this.block(ctx, branch, 'cure-prefixed, work runs anyway', this.compositionMessage(prefix), cache);\n }\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n // BranchSwitchScan answers \"which branch does this land on\" for both guards, flag-tolerantly:\n // `git checkout -q main` lands on main exactly as the bare form does, while\n // `git checkout -b x origin/main` (creates), `git checkout -- main` (pathspec) and\n // `git checkout <sha>` do not. See branch-switch-scan.ts for why that lives in one place.\n if (!this.switches.landsOnExistingMain(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n /**\n * The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what\n * the guard's name promised all along.\n *\n * The two versions before this one are both instructive. The oldest opened with how many commits\n * behind `main` was, which invited the wrong cure: an agent that reads \"behind\" reaches for a pull,\n * lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said\n * `main` is not a place to work \"whether or not it is current\" — true of a WRITE, but this guard\n * does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable.\n *\n * So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you\n * read here is out of date; Bash is default-deny in this state rather than a list of readers,\n * because a command's stated purpose never says whether it also WRITES; and the branch cure fetches,\n * so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN\n * stays legitimate — on a CURRENT `main` nothing here fires at all.\n *\n * ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b`\n * carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard\n * prints it, because a Read really can be cured by staying put. A Bash session cannot — the next\n * command is as likely to write as to read.)\n */\n private staleMainMessage(workspaceRoot: string): string {\n return 'Blocked: local `main` is BEHIND origin/main, so what you would read here is out of '\n + 'date and a plan built on it is built on code upstream has moved past. A CURRENT main is '\n + 'not blocked — reading main to PLAN is fine, and this fires only once being behind is '\n + 'established. Bash is default-deny in this state rather than a list of readers, because a '\n + \"command's stated purpose never says whether it also WRITES. The feature branch is the \"\n + 'unit of work in any case: reviewable, revertable — and the cure fetches, so it makes '\n + 'your reads true as well as moving you off main.\\n'\n + `Start a branch (uncommitted work comes with you):\\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;\n }\n\n /**\n * ROW 13's deny. It NAMES the operator the agent typed, because the fix is a one-character edit\n * and an agent told only \"use `&&`\" has to diff the two spellings itself to find where.\n */\n private compositionMessage(prefix: CurePrefix): string {\n return `Your cure is joined with \\`${prefix.operator}\\` — the work runs even if the pull fails. `\n + 'Use `&&` so it is skipped:\\n'\n + '\\n pnpm wp-sync-main && <your command>\\n\\n'\n + 'Or run the cure alone and re-issue your command in the next call.';\n }\n\n private pairingMessage(ctx: BashContext): string {\n const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\\n');\n // Deliberately SHORT. The incident that bought this guard (a main 157 commits behind; the\n // downgrade the reverted shim then prescribed) is maintainer material and lives in the class\n // docblock above — the reader of THIS text needs only what changes what they type.\n return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had — '\n + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '\n + 'reports the drift BACKWARDS. Go to main with the one command that also pulls it:\\n' + steps;\n }\n\n\n\n\n\n\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row, the same way an L0 block does. The doc is written\n // LAZILY here rather than up front: only a blocked agent needs it, and this is the one path\n // that knows the row it should be opened at.\n const row = matrixL2Row(reason).row;\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('stale-main-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the
|
|
13
13
|
* AI follows literally, so two drifting copies mean two behaviours for one repo state.
|
|
14
14
|
*
|
|
15
|
-
* Cure 1 is `pnpm wp-
|
|
15
|
+
* Cure 1 is `pnpm wp-sync-main`, the ONE command this repo names for "make local `main`
|
|
16
16
|
* current" — and it is now spelled that way here because it was not, which is the defect this file's
|
|
17
17
|
* last change fixed. Fleet-wide, this rule handed agents FOUR different refresh-main cures across 238
|
|
18
18
|
* prescriptions and the sanctioned one appeared in 6 of them; CLAUDE.md meanwhile names
|
|
19
|
-
* `pnpm wp-
|
|
19
|
+
* `pnpm wp-sync-main` and explicitly forbids hand-rolling the `git checkout main && git pull`
|
|
20
20
|
* pair, because that is the same command minus the orphan-directory sweep. Agents caught between the
|
|
21
21
|
* two authorities improvised hybrids — four distinct spellings observed — each costing a blocked round
|
|
22
22
|
* trip. A cure is an instruction the AI follows LITERALLY, so there is exactly one spelling of it.
|
|
@@ -16,11 +16,11 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
16
16
|
* One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the
|
|
17
17
|
* AI follows literally, so two drifting copies mean two behaviours for one repo state.
|
|
18
18
|
*
|
|
19
|
-
* Cure 1 is `pnpm wp-
|
|
19
|
+
* Cure 1 is `pnpm wp-sync-main`, the ONE command this repo names for "make local `main`
|
|
20
20
|
* current" — and it is now spelled that way here because it was not, which is the defect this file's
|
|
21
21
|
* last change fixed. Fleet-wide, this rule handed agents FOUR different refresh-main cures across 238
|
|
22
22
|
* prescriptions and the sanctioned one appeared in 6 of them; CLAUDE.md meanwhile names
|
|
23
|
-
* `pnpm wp-
|
|
23
|
+
* `pnpm wp-sync-main` and explicitly forbids hand-rolling the `git checkout main && git pull`
|
|
24
24
|
* pair, because that is the same command minus the orphan-directory sweep. Agents caught between the
|
|
25
25
|
* two authorities improvised hybrids — four distinct spellings observed — each costing a blocked round
|
|
26
26
|
* trip. A cure is an instruction the AI follows LITERALLY, so there is exactly one spelling of it.
|
|
@@ -64,7 +64,7 @@ class StaleMainMessage {
|
|
|
64
64
|
* path, and no state in which the printed cure is unrunnable.
|
|
65
65
|
*/
|
|
66
66
|
common(behindCount) {
|
|
67
|
-
const refresh = 'pnpm wp-
|
|
67
|
+
const refresh = 'pnpm wp-sync-main';
|
|
68
68
|
const branch = 'git checkout -b <new-branch> origin/main';
|
|
69
69
|
return [
|
|
70
70
|
`You are on main and main is ${behindCount} commit(s) behind origin/main.`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stale-main-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-message.ts"],"names":[],"mappings":";;;AAAA,0DAAiD;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAa,gBAAgB;IAWI;IAV7B;;;;;;;;;OASG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;IAAG,CAAC;IAEtD;;;;;;;;;;;;;OAaG;IACK,MAAM,CAAC,WAAmB;QAC9B,MAAM,OAAO,GAAG,
|
|
1
|
+
{"version":3,"file":"stale-main-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-message.ts"],"names":[],"mappings":";;;AAAA,0DAAiD;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAa,gBAAgB;IAWI;IAV7B;;;;;;;;;OASG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;IAAG,CAAC;IAEtD;;;;;;;;;;;;;OAaG;IACK,MAAM,CAAC,WAAmB;QAC9B,MAAM,OAAO,GAAG,mBAAmB,CAAC;QACpC,MAAM,MAAM,GAAG,0CAA0C,CAAC;QAC1D,OAAO;YACH,+BAA+B,WAAW,gCAAgC;YAC1E,GAAG,CAAC,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,sBAAsB,IAAI,CAAC,QAAQ,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACvF,wFAAwF;YACxF,yBAAyB;YACzB,EAAE;YACF,+BAA+B;YAC/B,QAAQ,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,IAAA,qBAAM,EAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE;YACzE,yFAAyF;YACzF,iFAAiF;YACjF,uCAAuC;YACvC,QAAQ,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,IAAA,qBAAM,EAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE;YACvE,uFAAuF;YACvF,0FAA0F;YAC1F,EAAE;YACF,sFAAsF;YACtF,oFAAoF;YACpF,qFAAqF;YACrF,sDAAsD;SACzD,CAAC;IACN,CAAC;IAED,QAAQ,CAAC,WAAmB;QACxB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,uCAAuC;YACvC,2FAA2F;YAC3F,mDAAmD;YACnD,oEAAoE;YACpE,0FAA0F;SAC7F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;CAEJ;AA/DD,4CA+DC","sourcesContent":["import { atRoot } from '@webpieces/rules-config';\n\n/**\n * The \"you are on a stale main\" text. ONE consumer today:\n *\n * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}\n *\n * It had a second consumer, `forBash`, for the days when stale-main-bash-guard blocked CONTENT reads\n * on a stale main. That guard judges the SAME state again today (rows 6/7 — `main`, known behind), but\n * its verdict shape is different: default-deny plus the row 4 skip list rather than a per-file call,\n * and its cure is the branch form alone because a Bash session cannot be cured by staying put. So it\n * carries its own message and this variant was deleted rather than left as a second spelling.\n *\n * One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the\n * AI follows literally, so two drifting copies mean two behaviours for one repo state.\n *\n * Cure 1 is `pnpm wp-sync-main`, the ONE command this repo names for \"make local `main`\n * current\" — and it is now spelled that way here because it was not, which is the defect this file's\n * last change fixed. Fleet-wide, this rule handed agents FOUR different refresh-main cures across 238\n * prescriptions and the sanctioned one appeared in 6 of them; CLAUDE.md meanwhile names\n * `pnpm wp-sync-main` and explicitly forbids hand-rolling the `git checkout main && git pull`\n * pair, because that is the same command minus the orphan-directory sweep. Agents caught between the\n * two authorities improvised hybrids — four distinct spellings observed — each costing a blocked round\n * trip. A cure is an instruction the AI follows LITERALLY, so there is exactly one spelling of it.\n *\n * It still fast-forwards rather than merging (the command pulls `--ff-only`), which is what keeps this\n * message clear of the merge redirect-how-to-merge-main exists to prevent, and it is still CLEAN-TREE\n * ONLY. Cure 2 (`git checkout -b`) is what makes the message correct on a dirty tree, so both are\n * printed and each is labelled.\n *\n * NOTE the intents stay SEPARATE. Cure 2 is not a spelling of cure 1: refreshing `main` in place and\n * branching off fresh are different moves with different tree-state requirements, and collapsing them\n * would hand a dirty tree a cure it cannot run.\n */\nexport class StaleMainMessage {\n /**\n * `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried\n * a leading `cd`). Pass it and each cure is rendered as `cd <treeRoot> && <cure>`, naming the\n * directory outright.\n *\n * WHY that matters here specifically: in the field this guard told an agent working in a worktree\n * to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the\n * PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must\n * never mutate a tree other than the one the command targeted, and naming it is how you ensure it.\n */\n constructor(private readonly treeRoot: string = '') {}\n\n /**\n * The diagnosis + BOTH cures.\n *\n * Two cures rather than one, and that is what let the dirty valve be deleted. The guard used to\n * fail open on a dirty tree because the only cure it printed was the in-place pull, which is\n * not a clean fast-forward when there are local modifications — so the block was suppressed to\n * avoid prescribing something that could not run.\n *\n * The row always had a second cure (`git checkout -b <new> origin/main`), and that one works\n * DIRTY: it carries uncommitted changes onto the new branch and lands you on current code, which\n * is the whole objective. Printing it unconditionally means the message is correct in both tree\n * states, so nothing has to detect dirtiness — no extra `git status --porcelain` on the block\n * path, and no state in which the printed cure is unrunnable.\n */\n private common(behindCount: string): string[] {\n const refresh = 'pnpm wp-sync-main';\n const branch = 'git checkout -b <new-branch> origin/main';\n return [\n `You are on main and main is ${behindCount} commit(s) behind origin/main.`,\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch main)`] : []),\n 'Anything you read here is STALE, and every plan built from it is built on code that no',\n 'longer exists upstream.',\n '',\n 'Run ONE of these, then retry:',\n ` 1. ${this.treeRoot !== '' ? atRoot(this.treeRoot, refresh) : refresh}`,\n ' Updates main in place — checkout, pull, reap dead branches/worktrees, sweep orphan',\n ' directories. CLEAN TREE ONLY: with tracked modifications the pull is not a',\n ' fast-forward and it will refuse.',\n ` 2. ${this.treeRoot !== '' ? atRoot(this.treeRoot, branch) : branch}`,\n ' Works with UNCOMMITTED CHANGES — they come with you onto the new branch, and you',\n ' land on current code. Prefer this one if you have edits in flight, or if 1 refused.',\n '',\n 'If 1 fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD holds a',\n 'duplicate entry — clear it with `git fetch --prune origin main`, then run 1 again.',\n 'If 2 refuses because origin/main changed the same files you edited, run `git stash`',\n '(never blocked), then 2 again, then `git stash pop`.',\n ];\n }\n\n forReads(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'Still allowed while this block is up:',\n ' - Bash that does not read repo files: builds, tests, installs, the pull itself, and all',\n ' git/gh METADATA (status|log|diff|show|branch)',\n ' - All Write/Edit (feature-branch-guard governs those separately)',\n ' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',\n ]).join('\\n');\n }\n\n}\n"]}
|
|
@@ -51,7 +51,7 @@ export declare class TreeRecovery {
|
|
|
51
51
|
* worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
|
|
52
52
|
* branch delete must come LAST because git refuses to delete a branch a worktree still holds.
|
|
53
53
|
*
|
|
54
|
-
* The BRANCH form is ONE command, `pnpm wp-
|
|
54
|
+
* The BRANCH form is ONE command, `pnpm wp-sync-main`, and both halves of that matter.
|
|
55
55
|
*
|
|
56
56
|
* It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as
|
|
57
57
|
* destructive and stops to ask permission, so the branch survives the turn and local branches pile
|
|
@@ -63,7 +63,7 @@ export declare class TreeRecovery {
|
|
|
63
63
|
* even though that pair is still perfectly legal to type. The one command is checkout + pull +
|
|
64
64
|
* cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so
|
|
65
65
|
* printing both is two spellings of one intention where one silently does less, and the corpses the
|
|
66
|
-
* sweep exists to collect simply never got collected. See
|
|
66
|
+
* sweep exists to collect simply never got collected. See SyncMainCommand's docblock for
|
|
67
67
|
* why going to main is the right moment to sweep.
|
|
68
68
|
*
|
|
69
69
|
* The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in
|
|
@@ -81,7 +81,7 @@ export declare class TreeRecovery {
|
|
|
81
81
|
* the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
|
|
82
82
|
* you need to then branch off `origin/main`.
|
|
83
83
|
*
|
|
84
|
-
* The primary-clone form is `pnpm wp-
|
|
84
|
+
* The primary-clone form is `pnpm wp-sync-main`, not the `git checkout main && git pull
|
|
85
85
|
* origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair
|
|
86
86
|
* is that command minus the cleanup and the sweep, and an agent handed both types whichever it read
|
|
87
87
|
* last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`
|
|
@@ -60,7 +60,7 @@ class TreeRecovery {
|
|
|
60
60
|
}
|
|
61
61
|
// The primary clone. NO "never `git checkout main`" here: that is a WORKTREE-only truth, and
|
|
62
62
|
// printing it in the primary clone forbids the shortest exit off a merged branch
|
|
63
|
-
// (`pnpm wp-
|
|
63
|
+
// (`pnpm wp-sync-main` — the one command form of the git pair a human had to hand an
|
|
64
64
|
// agent that had wedged itself following this very message). Branching off origin/main is still
|
|
65
65
|
// what we RECOMMEND, because it works from any tree; it is no longer dressed up as the only
|
|
66
66
|
// legal move.
|
|
@@ -80,7 +80,7 @@ class TreeRecovery {
|
|
|
80
80
|
* worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
|
|
81
81
|
* branch delete must come LAST because git refuses to delete a branch a worktree still holds.
|
|
82
82
|
*
|
|
83
|
-
* The BRANCH form is ONE command, `pnpm wp-
|
|
83
|
+
* The BRANCH form is ONE command, `pnpm wp-sync-main`, and both halves of that matter.
|
|
84
84
|
*
|
|
85
85
|
* It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as
|
|
86
86
|
* destructive and stops to ask permission, so the branch survives the turn and local branches pile
|
|
@@ -92,7 +92,7 @@ class TreeRecovery {
|
|
|
92
92
|
* even though that pair is still perfectly legal to type. The one command is checkout + pull +
|
|
93
93
|
* cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so
|
|
94
94
|
* printing both is two spellings of one intention where one silently does less, and the corpses the
|
|
95
|
-
* sweep exists to collect simply never got collected. See
|
|
95
|
+
* sweep exists to collect simply never got collected. See SyncMainCommand's docblock for
|
|
96
96
|
* why going to main is the right moment to sweep.
|
|
97
97
|
*
|
|
98
98
|
* The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in
|
|
@@ -105,7 +105,7 @@ class TreeRecovery {
|
|
|
105
105
|
* delete ordering is the part that has to be exactly right.
|
|
106
106
|
*/
|
|
107
107
|
cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
|
|
108
|
-
const branchForm = ` ${this.at('pnpm wp-
|
|
108
|
+
const branchForm = ` ${this.at('pnpm wp-sync-main')}`;
|
|
109
109
|
const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
|
|
110
110
|
if (kind === 'worktree') {
|
|
111
111
|
return [
|
|
@@ -131,7 +131,7 @@ class TreeRecovery {
|
|
|
131
131
|
* the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
|
|
132
132
|
* you need to then branch off `origin/main`.
|
|
133
133
|
*
|
|
134
|
-
* The primary-clone form is `pnpm wp-
|
|
134
|
+
* The primary-clone form is `pnpm wp-sync-main`, not the `git checkout main && git pull
|
|
135
135
|
* origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair
|
|
136
136
|
* is that command minus the cleanup and the sweep, and an agent handed both types whichever it read
|
|
137
137
|
* last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`
|
|
@@ -139,7 +139,7 @@ class TreeRecovery {
|
|
|
139
139
|
* the job.
|
|
140
140
|
*/
|
|
141
141
|
updateMainSteps(kind) {
|
|
142
|
-
const branchForm = ` ${this.at('pnpm wp-
|
|
142
|
+
const branchForm = ` ${this.at('pnpm wp-sync-main')}`;
|
|
143
143
|
const worktreeForm = ` ${this.at('git fetch origin main')} (then work off origin/main)`;
|
|
144
144
|
if (kind === 'worktree') {
|
|
145
145
|
return [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":"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","sourcesContent":["import { WorktreeService, atRoot } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /**\n * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as\n * `cd <treeRoot> && <command>`.\n *\n * WHY: the harness RESETS a cwd that left the workspace, so an agent working in a linked\n * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore\n * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone\n * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is\n * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`\n * cannot change what a command does to a repo, so the guards accept it.\n *\n * Empty (the default) renders the bare commands, for callers with no root to name.\n */\n constructor(private readonly treeRoot: string = '') {}\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n // Render one command in the form that survives a tool call: `cd '<root>' && <command>`. The\n // formatting (single quotes included, so a repo path with a space still runs) is atRoot's, shared\n // with every other remedy builder — see its header.\n private at(command: string): string {\n return this.treeRoot === '' ? command : atRoot(this.treeRoot, command);\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,\n ];\n const worktreeForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git worktree add ../${dir} -b ${newBranchName} origin/main`)}`,\n ];\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree (`git checkout main` fatals here — main is checked out in',\n 'the primary clone). Start the new work in its own worktree:',\n ...worktreeForm,\n ];\n }\n // The primary clone. NO \"never `git checkout main`\" here: that is a WORKTREE-only truth, and\n // printing it in the primary clone forbids the shortest exit off a merged branch\n // (`pnpm wp-checkout-clean-main` — the one command form of the git pair a human had to hand an\n // agent that had wedged itself following this very message). Branching off origin/main is still\n // what we RECOMMEND, because it works from any tree; it is no longer dressed up as the only\n // legal move.\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...branchForm];\n }\n return [\n 'Start fresh off origin/main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ...branchForm.map((line: string): string => ` ${line}`),\n ' - in a linked worktree (`git checkout main` fatals there):',\n ...worktreeForm.map((line: string): string => ` ${line}`),\n ];\n }\n\n /**\n * Reap the tree you just finished with. The worktree order is load-bearing: prune clears\n * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the\n * branch delete must come LAST because git refuses to delete a branch a worktree still holds.\n *\n * The BRANCH form is ONE command, `pnpm wp-checkout-clean-main`, and both halves of that matter.\n *\n * It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as\n * destructive and stops to ask permission, so the branch survives the turn and local branches pile\n * up — the exact failure this whole cleanup path exists to prevent. The cleanup it runs deletes only\n * provably-dead branches (and reaps every OTHER dead one at the same time), so it is safe to\n * allowlist and never needs a judgement call.\n *\n * And it is not spelled `git checkout main && git pull origin main && pnpm wp-cleanup` any more,\n * even though that pair is still perfectly legal to type. The one command is checkout + pull +\n * cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so\n * printing both is two spellings of one intention where one silently does less, and the corpses the\n * sweep exists to collect simply never got collected. See CheckoutCleanMainCommand's docblock for\n * why going to main is the right moment to sweep.\n *\n * The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in\n * `src/bin/l0-allowlist.ts`), because in an L0 block `node_modules` is exactly what is in doubt and\n * a `pnpm wp-*` bin cannot be relied on to run. Two layers, two spellings, for a reason that is not\n * back-compat: this is the WORKFLOW layer, where the bin is known to work.\n *\n * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches\n * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →\n * delete ordering is the part that has to be exactly right.\n */\n cleanupSteps(kind: TreeKind, branch: string, worktreePath: string = '<worktree-dir>'): string[] {\n const branchForm = ` ${this.at('pnpm wp-checkout-clean-main')}`;\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n *\n * The primary-clone form is `pnpm wp-checkout-clean-main`, not the `git checkout main && git pull\n * origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair\n * is that command minus the cleanup and the sweep, and an agent handed both types whichever it read\n * last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`\n * bin cannot be trusted — but this is the workflow layer and it prescribes the one that finishes\n * the job.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ` ${this.at('pnpm wp-checkout-clean-main')}`;\n const worktreeForm = ` ${this.at('git fetch origin main')} (then work off origin/main)`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (there is no `main` here to check out — it lives in the primary clone):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":"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","sourcesContent":["import { WorktreeService, atRoot } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /**\n * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as\n * `cd <treeRoot> && <command>`.\n *\n * WHY: the harness RESETS a cwd that left the workspace, so an agent working in a linked\n * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore\n * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone\n * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is\n * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`\n * cannot change what a command does to a repo, so the guards accept it.\n *\n * Empty (the default) renders the bare commands, for callers with no root to name.\n */\n constructor(private readonly treeRoot: string = '') {}\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n // Render one command in the form that survives a tool call: `cd '<root>' && <command>`. The\n // formatting (single quotes included, so a repo path with a space still runs) is atRoot's, shared\n // with every other remedy builder — see its header.\n private at(command: string): string {\n return this.treeRoot === '' ? command : atRoot(this.treeRoot, command);\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,\n ];\n const worktreeForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git worktree add ../${dir} -b ${newBranchName} origin/main`)}`,\n ];\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree (`git checkout main` fatals here — main is checked out in',\n 'the primary clone). Start the new work in its own worktree:',\n ...worktreeForm,\n ];\n }\n // The primary clone. NO \"never `git checkout main`\" here: that is a WORKTREE-only truth, and\n // printing it in the primary clone forbids the shortest exit off a merged branch\n // (`pnpm wp-sync-main` — the one command form of the git pair a human had to hand an\n // agent that had wedged itself following this very message). Branching off origin/main is still\n // what we RECOMMEND, because it works from any tree; it is no longer dressed up as the only\n // legal move.\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...branchForm];\n }\n return [\n 'Start fresh off origin/main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ...branchForm.map((line: string): string => ` ${line}`),\n ' - in a linked worktree (`git checkout main` fatals there):',\n ...worktreeForm.map((line: string): string => ` ${line}`),\n ];\n }\n\n /**\n * Reap the tree you just finished with. The worktree order is load-bearing: prune clears\n * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the\n * branch delete must come LAST because git refuses to delete a branch a worktree still holds.\n *\n * The BRANCH form is ONE command, `pnpm wp-sync-main`, and both halves of that matter.\n *\n * It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as\n * destructive and stops to ask permission, so the branch survives the turn and local branches pile\n * up — the exact failure this whole cleanup path exists to prevent. The cleanup it runs deletes only\n * provably-dead branches (and reaps every OTHER dead one at the same time), so it is safe to\n * allowlist and never needs a judgement call.\n *\n * And it is not spelled `git checkout main && git pull origin main && pnpm wp-cleanup` any more,\n * even though that pair is still perfectly legal to type. The one command is checkout + pull +\n * cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so\n * printing both is two spellings of one intention where one silently does less, and the corpses the\n * sweep exists to collect simply never got collected. See SyncMainCommand's docblock for\n * why going to main is the right moment to sweep.\n *\n * The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in\n * `src/bin/l0-allowlist.ts`), because in an L0 block `node_modules` is exactly what is in doubt and\n * a `pnpm wp-*` bin cannot be relied on to run. Two layers, two spellings, for a reason that is not\n * back-compat: this is the WORKFLOW layer, where the bin is known to work.\n *\n * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches\n * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →\n * delete ordering is the part that has to be exactly right.\n */\n cleanupSteps(kind: TreeKind, branch: string, worktreePath: string = '<worktree-dir>'): string[] {\n const branchForm = ` ${this.at('pnpm wp-sync-main')}`;\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n *\n * The primary-clone form is `pnpm wp-sync-main`, not the `git checkout main && git pull\n * origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair\n * is that command minus the cleanup and the sweep, and an agent handed both types whichever it read\n * last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`\n * bin cannot be trusted — but this is the workflow layer and it prescribes the one that finishes\n * the job.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ` ${this.at('pnpm wp-sync-main')}`;\n const worktreeForm = ` ${this.at('git fetch origin main')} (then work off origin/main)`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (there is no `main` here to check out — it lives in the primary clone):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
|