@webpieces/ai-hook-rules 0.4.646 → 0.4.648

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.
@@ -85,11 +85,10 @@ const recovery_allowlist_1 = require("./recovery-allowlist");
85
85
  * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.
86
86
  *
87
87
  * FAIL-OPEN is preserved where it still means anything: branch undeterminable → allow. The cache
88
- * valves (`no-sync-cache`, `origin-main-unknown`, `dirty-tree-on-main`) are gone from THIS guard
89
- * because it no longer reads the cache; read-stale-guard still opens them for the Read tool, where a
90
- * dirty tree genuinely does make the prescribed `git pull` unavailable (see the doc's "Not done").
91
- * Here the cure is `git checkout -b`, which CARRIES uncommitted work onto the new branch — so a dirty
92
- * tree traps nobody and needs no valve.
88
+ * valves (`no-sync-cache`, `origin-main-unknown`) are gone from THIS guard because it no longer reads
89
+ * the cache. There is no dirty-tree valve here and none in read-stale-guard either: the cure is
90
+ * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
91
+ * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
93
92
  */
94
93
  class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
95
94
  constructor(config) { super(config, 'stale-main-bash-guard', rules_config_1.BRANCH_STATE_GUARD_KEY); }
@@ -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":";;;AAAA,iDAAyC;AAEzC,0DAIiC;AAGjC,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAA8C;AAC9C,0CAAsC;AACtC,4DAA8D;AAC9D,4DAAqD;AACrD,kDAAwF;AACxF,oDAAuF;AACvF,sDAAkD;AAClD,kDAAiD;AACjD,mDAA+C;AAC/C,6DAAwD;AACxD,6DAAyD;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AACH,MAAa,sBAAuB,SAAQ,wBAAoC;IAC5E,YAAY,MAA8B,IAAI,KAAK,CAAC,MAAM,EAAE,uBAAuB,EAAE,qCAAsB,CAAC,CAAC,CAAC,CAAC;IAE9F,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC;IAC/B,QAAQ,GAAG,IAAI,4BAAY,EAAE,CAAC;IAC9B,QAAQ,GAAG,IAAI,qCAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/D,kGAAkG;IAClG,iFAAiF;IAChE,YAAY,GAAG,IAAI,sCAAiB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAE3D,WAAW,GAChB,6FAA6F;QAC7F,4FAA4F;QAC5F,2EAA2E,CAAC;IAC9D,cAAc,GAAG;QAC/B,kBAAkB,EAAE,2CAA4B;KACnD,CAAC;IACO,OAAO,GAAG,IAAI,kBAAO,CAC1B,kHAAkH,EAClH,gEAAgE,EAChE;QACI,yFAAyF;QACzF,0FAA0F;QAC1F,wFAAwF;QACxF,yFAAyF;QACzF,2FAA2F;QAC3F,sFAAsF;QACtF,+EAA+E;QAC/E,IAAI,iBAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;cACxD,iFAAiF,EAAE,IAAI,CAAC;QAC9F,IAAI,iBAAM,CAAC,gNAAgN,CAAC;QAC5N,IAAI,iBAAM,CAAC,yRAAyR,CAAC;QACrS,IAAI,iBAAM,CAAC,kLAAkL,CAAC;KACjM,CACJ,CAAC;IAEF,KAAK,CAAC,GAAgB;QAClB,0FAA0F;QAC1F,2FAA2F;QAC3F,4EAA4E;QAC5E,MAAM,IAAI,GAAG,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;QAC1C,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,EAAE,0BAA0B,IAAI,GAAG,EAAE,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;QACpG,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACrD,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,uBAAuB,CAAC,CAAC;QAEhF,qFAAqF;QACrF,IAAA,0CAAsB,EAAC,GAAG,CAAC,aAAa,EAAE,IAAA,iCAAa,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;QAEtE,wFAAwF;QACxF,IAAI,MAAM,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,wCAAwC,CAAC,CAAC;QAEhG,6FAA6F;QAC7F,IAAI,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,GAAG,CAAC,EAAE,CAAC;YACzC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,0CAA0C,CAAC,CAAC;QAC/E,CAAC;QAED,4EAA4E;QAC5E,EAAE;QACF,2FAA2F;QAC3F,6FAA6F;QAC7F,uBAAuB;QACvB,EAAE;QACF,+FAA+F;QAC/F,+FAA+F;QAC/F,6EAA6E;QAC7E,8FAA8F;QAC9F,0FAA0F;QAC1F,8FAA8F;QAC9F,4FAA4F;QAC5F,8CAA8C;QAC9C,EAAE;QACF,+FAA+F;QAC/F,2FAA2F;QAC3F,+DAA+D;QAC/D,EAAE;QACF,8FAA8F;QAC9F,0FAA0F;QAC1F,8FAA8F;QAC9F,4FAA4F;QAC5F,0CAA0C;QAC1C,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;IAC1F,CAAC;IAED;;;;;OAKG;IACK,kBAAkB,CAAC,GAAgB;QACvC,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YAC9D,8FAA8F;YAC9F,4EAA4E;YAC5E,mFAAmF;YACnF,0FAA0F;YAC1F,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,CAAC;gBAAE,SAAS;YAC1D,OAAO,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;QACrF,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;OAMG;IACK,aAAa,CAAC,aAAqB;QACvC,OAAO,mFAAmF;cACpF,uFAAuF;cACvF,uFAAuF;cACvF,2EAA2E;cAC3E,4DAA4D,aAAa,wEAAwE,CAAC;IAC5J,CAAC;IAEO,cAAc,CAAC,GAAgB;QACnC,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChG,0FAA0F;QAC1F,6FAA6F;QAC7F,mFAAmF;QACnF,OAAO,wFAAwF;cACzF,sFAAsF;cACtF,sEAAsE,GAAG,KAAK,CAAC;IACzF,CAAC;IAQD;;;;;;;;;OASG;IACK,QAAQ,CAAC,GAAgB,EAAE,MAAqB,EAAE,MAAc,EAAE,QAAgB,GAAG;QACzF,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QAChE,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAqB,EAAE,MAAc,EAAE,QAAgB,GAAG;QACtF,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QACtD,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAc,EAAE,MAAc,EAAE,OAAe,EAAE,KAAa;QAC1F,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QAC9D,yFAAyF;QACzF,4FAA4F;QAC5F,6CAA6C;QAC7C,MAAM,GAAG,GAAG,IAAA,0BAAW,EAAC,MAAM,CAAC,CAAC,GAAG,CAAC;QACpC,MAAM,OAAO,GAAG,IAAA,wCAAwB,EAAC,IAAA,yCAAyB,EAAC,GAAG,CAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QAC5F,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IACrE,CAAC;IAEO,QAAQ,CAAC,CAAS;QACtB,MAAM,GAAG,GAAG,GAAG,CAAC;QAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;IACvD,CAAC;IAEO,WAAW,CAAC,GAAgB,EAAE,MAAqB,EAAE,OAAgB,EAAE,MAAc,EAAE,KAAa;QACxG,IAAA,+BAAgB,EACZ,GAAG,CAAC,aAAa,EACjB,IAAI,4BAAa,CAAC,uBAAuB,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,8BAAa,EAAE,IAAA,0BAAW,EAAC,MAAM,CAAC,CAAC,CACnJ,CAAC;IACN,CAAC;IAEO,aAAa,CAAC,aAAqB;QACvC,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;gBAC/C,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;aACxE,CAAC,CAAC,IAAI,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;CACJ;AA7LD,wDA6LC","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchStateGuardConfig,\n BRANCH_STATE_GUARD_KEY,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } 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';\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, which is the\n * exact line the post-merge cleanup flow already prescribes.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────\n *\n * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only\n * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so —\n * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.\n *\n * FRESHNESS IS THE WRONG QUESTION. `main` is not a place to work even when it is perfectly current.\n * Staleness changes what you would READ; it does not change whether this is the branch to work on,\n * and the cure is not `git pull` but a new branch. Gating the block on the cache meant a current\n * `main` was treated as a fine place to run a build, an installer or a codegen step.\n *\n * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it\n * for the NEXT call, so the FIRST call of every session has none — and in a multi-worktree repo\n * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly\n * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's\n * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is\n * `B` being brought into line with `E`, which is the table's own rule, not a new policy.\n *\n * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and\n * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a\n * `>` redirect — commands whose stated purpose is something else and whose effect is to modify\n * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same\n * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.\n *\n * BLOCKED anything on `main` that is not on the skip list — builds, tests, installers,\n * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.\n * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>\n * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,\n * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.\n *\n * FAIL-OPEN is preserved where it still means anything: branch undeterminable → allow. The cache\n * valves (`no-sync-cache`, `origin-main-unknown`, `dirty-tree-on-main`) are gone from THIS guard\n * because it no longer reads the cache; read-stale-guard still opens them for the Read tool, where a\n * dirty tree genuinely does make the prescribed `git pull` unavailable (see the doc's \"Not done\").\n * Here the cure is `git checkout -b`, which CARRIES uncommitted work onto the new branch — so a dirty\n * tree traps nobody and needs no valve.\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\n readonly description =\n 'Block a bare `git checkout main` (chain the pull into the same command), and block Bash on ' +\n 'main outright — allowlisting only the commands that get you off it — so a session neither ' +\n 'lands on main nor works there, whether or not main happens to be current.';\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 pair the checkout with the pull:',\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 (`git checkout main && …`) is BLOCKED by redirect-how-to-merge-main\n // inside a linked worktree, so a preferred option naming it unconditionally hands the AI a\n // cure a sibling guard denies. The per-block message (pairingMessage) is detected and\n // prints exactly one form; this is the fallback for the hint that cannot look.\n new Option(this.recovery.updateMainSteps('unknown').join('\\n')\n + '\\nWhichever form applies, the pull must be in the SAME command as the checkout.', true),\n new Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),\n new Option('Still allowed on main: 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, gh pr view|list|status|checks, git stash, 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 // ROW 5 — on `main`. NO CACHE IS READ ON THIS PATH, and that is the change.\n //\n // The old ladder asked the cache \"is main BEHIND?\" and only then blocked, and only content\n // READS. That made the whole Bash half of row 5 conditional on freshness, which is the wrong\n // question twice over:\n //\n // 1. Freshness is irrelevant to whether you should be working here. `main` is not a place to\n // work even when it is perfectly current — the cure is the same either way, and it is not\n // `git pull`, it is `git checkout -b`. Row 5's cure has always said so.\n // 2. The cache is populated by a FIRE-AND-FORGET refresher that fills it for the NEXT call,\n // so on the first call of every session there is none — and in a multi-worktree repo\n // another tree can hold the refresh lock indefinitely. A block that needs the cache is a\n // block that is off exactly when a session is starting, which is when an agent is most\n // likely to still be standing on `main`.\n //\n // So this is now one `git rev-parse` and a text scan, both of which fire on call #1 — the same\n // arrangement that has always governed row 5's Write/Edit half (feature-branch-guard). `B`\n // tracking `E` here is the table's own rule, not a new policy.\n //\n // The polarity flips with it: on `main` this is DEFAULT-DENY plus row 4's skip list, where it\n // used to be default-allow plus a content-read blocklist. That is what makes it catch the\n // commands a blocklist structurally cannot — an installer, a formatter or a codegen step that\n // WRITES tracked files while its stated purpose is something else. Blocking those was never\n // going to come from enumerating readers.\n return this.block(ctx, branch, 'on-main', this.onMainMessage(ctx.workspaceRoot), '-');\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 row 5 deny. Deliberately SHORT, and deliberately NOT about staleness.\n *\n * The old message opened by reporting how many commits behind `main` was, which invited exactly\n * the wrong cure — an agent that reads \"behind\" reaches for `git pull`, ends up on a CURRENT\n * `main`, and is still on `main`. The finding is the branch, so that is the first thing said.\n */\n private onMainMessage(workspaceRoot: string): string {\n return 'Blocked: you are on `main`. `main` is not a place to work — whether or not it is '\n + 'current — because work here cannot be reviewed, cannot be reverted as a unit, and is '\n + 'one `git checkout` away from being lost. This is judged from the branch alone, so it '\n + 'fires on the first command of a session, before any freshness is known.\\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 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. Chain the pull into the same command:\\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 {\n BranchStateGuardConfig,\n BRANCH_STATE_GUARD_KEY,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } 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';\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, which is the\n * exact line the post-merge cleanup flow already prescribes.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────\n *\n * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only\n * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so —\n * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.\n *\n * FRESHNESS IS THE WRONG QUESTION. `main` is not a place to work even when it is perfectly current.\n * Staleness changes what you would READ; it does not change whether this is the branch to work on,\n * and the cure is not `git pull` but a new branch. Gating the block on the cache meant a current\n * `main` was treated as a fine place to run a build, an installer or a codegen step.\n *\n * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it\n * for the NEXT call, so the FIRST call of every session has none — and in a multi-worktree repo\n * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly\n * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's\n * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is\n * `B` being brought into line with `E`, which is the table's own rule, not a new policy.\n *\n * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and\n * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a\n * `>` redirect — commands whose stated purpose is something else and whose effect is to modify\n * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same\n * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.\n *\n * BLOCKED anything on `main` that is not on the skip list — builds, tests, installers,\n * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.\n * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>\n * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,\n * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.\n *\n * FAIL-OPEN is preserved where it still means anything: branch undeterminable → allow. The cache\n * valves (`no-sync-cache`, `origin-main-unknown`) are gone from THIS guard because it no longer reads\n * the cache. 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\n readonly description =\n 'Block a bare `git checkout main` (chain the pull into the same command), and block Bash on ' +\n 'main outright — allowlisting only the commands that get you off it — so a session neither ' +\n 'lands on main nor works there, whether or not main happens to be current.';\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 pair the checkout with the pull:',\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 (`git checkout main && …`) is BLOCKED by redirect-how-to-merge-main\n // inside a linked worktree, so a preferred option naming it unconditionally hands the AI a\n // cure a sibling guard denies. The per-block message (pairingMessage) is detected and\n // prints exactly one form; this is the fallback for the hint that cannot look.\n new Option(this.recovery.updateMainSteps('unknown').join('\\n')\n + '\\nWhichever form applies, the pull must be in the SAME command as the checkout.', true),\n new Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),\n new Option('Still allowed on main: 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, gh pr view|list|status|checks, git stash, 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 // ROW 5 — on `main`. NO CACHE IS READ ON THIS PATH, and that is the change.\n //\n // The old ladder asked the cache \"is main BEHIND?\" and only then blocked, and only content\n // READS. That made the whole Bash half of row 5 conditional on freshness, which is the wrong\n // question twice over:\n //\n // 1. Freshness is irrelevant to whether you should be working here. `main` is not a place to\n // work even when it is perfectly current — the cure is the same either way, and it is not\n // `git pull`, it is `git checkout -b`. Row 5's cure has always said so.\n // 2. The cache is populated by a FIRE-AND-FORGET refresher that fills it for the NEXT call,\n // so on the first call of every session there is none — and in a multi-worktree repo\n // another tree can hold the refresh lock indefinitely. A block that needs the cache is a\n // block that is off exactly when a session is starting, which is when an agent is most\n // likely to still be standing on `main`.\n //\n // So this is now one `git rev-parse` and a text scan, both of which fire on call #1 — the same\n // arrangement that has always governed row 5's Write/Edit half (feature-branch-guard). `B`\n // tracking `E` here is the table's own rule, not a new policy.\n //\n // The polarity flips with it: on `main` this is DEFAULT-DENY plus row 4's skip list, where it\n // used to be default-allow plus a content-read blocklist. That is what makes it catch the\n // commands a blocklist structurally cannot — an installer, a formatter or a codegen step that\n // WRITES tracked files while its stated purpose is something else. Blocking those was never\n // going to come from enumerating readers.\n return this.block(ctx, branch, 'on-main', this.onMainMessage(ctx.workspaceRoot), '-');\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 row 5 deny. Deliberately SHORT, and deliberately NOT about staleness.\n *\n * The old message opened by reporting how many commits behind `main` was, which invited exactly\n * the wrong cure — an agent that reads \"behind\" reaches for `git pull`, ends up on a CURRENT\n * `main`, and is still on `main`. The finding is the branch, so that is the first thing said.\n */\n private onMainMessage(workspaceRoot: string): string {\n return 'Blocked: you are on `main`. `main` is not a place to work — whether or not it is '\n + 'current — because work here cannot be reviewed, cannot be reverted as a unit, and is '\n + 'one `git checkout` away from being lost. This is judged from the branch alone, so it '\n + 'fires on the first command of a session, before any freshness is known.\\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 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. Chain the pull into the same command:\\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,18 +1,22 @@
1
1
  /**
2
- * The "you are on a stale main" text, shared by the TWO guards that detect the state from the same
3
- * cached signal (`MainSyncStatus.localMain` vs `originMain`):
2
+ * The "you are on a stale main" text. ONE consumer today:
4
3
  *
5
4
  * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}
6
- * - stale-main-bash-guard blocks content-read Bash → {@link StaleMainMessage.forBash}
5
+ *
6
+ * It had a second consumer, `forBash`, for the days when stale-main-bash-guard blocked CONTENT reads
7
+ * on a stale main. That guard now blocks at row 5 on the branch alone — being on `main` is the whole
8
+ * finding, and staleness does not enter into it — so it carries its own message and this variant was
9
+ * deleted rather than left as an unreachable second spelling.
7
10
  *
8
11
  * One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the
9
- * AI follows literally, so two drifting copies mean two behaviors for one repo state. Only the
10
- * "what is still allowed" tail differs, because the two guards block different tools.
12
+ * AI follows literally, so two drifting copies mean two behaviours for one repo state.
11
13
  *
12
- * The cure is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which
13
- * is the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either
14
- * fast-forwards (the case here, since the block only fires when the tree is clean and behind) or
15
- * fails loudly without touching anything.
14
+ * Cure 1 is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which is
15
+ * the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either
16
+ * fast-forwards or fails loudly without touching anything. It used to be the ONLY cure printed, on the
17
+ * strength of "the block only fires when the tree is clean and behind" — which stopped being true when
18
+ * the dirty valve was deleted, and was the reason that valve existed. Cure 2 (`git checkout -b`) is
19
+ * what makes the message correct on a dirty tree, so both are printed and each is labelled.
16
20
  */
17
21
  export declare class StaleMainMessage {
18
22
  private readonly treeRoot;
@@ -27,12 +31,20 @@ export declare class StaleMainMessage {
27
31
  * never mutate a tree other than the one the command targeted, and naming it is how you ensure it.
28
32
  */
29
33
  constructor(treeRoot?: string);
30
- private common;
31
- forReads(behindCount: string): string;
32
34
  /**
33
- * The Bash variant. stale-main-bash-guard blocks only CONTENT reads, so the message has to say
34
- * which shell is still open — an agent that reads "Bash blocked" and believes the whole shell is
35
- * gone will not run the cure, which is itself a Bash command.
35
+ * The diagnosis + BOTH cures.
36
+ *
37
+ * Two cures rather than one, and that is what let the dirty valve be deleted. The guard used to
38
+ * fail open on a dirty tree because the only cure it printed was `git pull --ff-only`, which is
39
+ * not a clean fast-forward when there are local modifications — so the block was suppressed to
40
+ * avoid prescribing something that could not run.
41
+ *
42
+ * The row always had a second cure (`git checkout -b <new> origin/main`), and that one works
43
+ * DIRTY: it carries uncommitted changes onto the new branch and lands you on current code, which
44
+ * is the whole objective. Printing it unconditionally means the message is correct in both tree
45
+ * states, so nothing has to detect dirtiness — no extra `git status --porcelain` on the block
46
+ * path, and no state in which the printed cure is unrunnable.
36
47
  */
37
- forBash(behindCount: string): string;
48
+ private common;
49
+ forReads(behindCount: string): string;
38
50
  }
@@ -3,20 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StaleMainMessage = void 0;
4
4
  const rules_config_1 = require("@webpieces/rules-config");
5
5
  /**
6
- * The "you are on a stale main" text, shared by the TWO guards that detect the state from the same
7
- * cached signal (`MainSyncStatus.localMain` vs `originMain`):
6
+ * The "you are on a stale main" text. ONE consumer today:
8
7
  *
9
8
  * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}
10
- * - stale-main-bash-guard blocks content-read Bash → {@link StaleMainMessage.forBash}
9
+ *
10
+ * It had a second consumer, `forBash`, for the days when stale-main-bash-guard blocked CONTENT reads
11
+ * on a stale main. That guard now blocks at row 5 on the branch alone — being on `main` is the whole
12
+ * finding, and staleness does not enter into it — so it carries its own message and this variant was
13
+ * deleted rather than left as an unreachable second spelling.
11
14
  *
12
15
  * One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the
13
- * AI follows literally, so two drifting copies mean two behaviors for one repo state. Only the
14
- * "what is still allowed" tail differs, because the two guards block different tools.
16
+ * AI follows literally, so two drifting copies mean two behaviours for one repo state.
15
17
  *
16
- * The cure is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which
17
- * is the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either
18
- * fast-forwards (the case here, since the block only fires when the tree is clean and behind) or
19
- * fails loudly without touching anything.
18
+ * Cure 1 is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which is
19
+ * the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either
20
+ * fast-forwards or fails loudly without touching anything. It used to be the ONLY cure printed, on the
21
+ * strength of "the block only fires when the tree is clean and behind" — which stopped being true when
22
+ * the dirty valve was deleted, and was the reason that valve existed. Cure 2 (`git checkout -b`) is
23
+ * what makes the message correct on a dirty tree, so both are printed and each is labelled.
20
24
  */
21
25
  class StaleMainMessage {
22
26
  treeRoot;
@@ -33,20 +37,41 @@ class StaleMainMessage {
33
37
  constructor(treeRoot = '') {
34
38
  this.treeRoot = treeRoot;
35
39
  }
36
- // The diagnosis + cure. Identical for both guards — the part that must never drift.
40
+ /**
41
+ * The diagnosis + BOTH cures.
42
+ *
43
+ * Two cures rather than one, and that is what let the dirty valve be deleted. The guard used to
44
+ * fail open on a dirty tree because the only cure it printed was `git pull --ff-only`, which is
45
+ * not a clean fast-forward when there are local modifications — so the block was suppressed to
46
+ * avoid prescribing something that could not run.
47
+ *
48
+ * The row always had a second cure (`git checkout -b <new> origin/main`), and that one works
49
+ * DIRTY: it carries uncommitted changes onto the new branch and lands you on current code, which
50
+ * is the whole objective. Printing it unconditionally means the message is correct in both tree
51
+ * states, so nothing has to detect dirtiness — no extra `git status --porcelain` on the block
52
+ * path, and no state in which the printed cure is unrunnable.
53
+ */
37
54
  common(behindCount) {
38
55
  const pull = 'git pull --ff-only origin main';
56
+ const branch = 'git checkout -b <new-branch> origin/main';
39
57
  return [
40
58
  `You are on main and main is ${behindCount} commit(s) behind origin/main.`,
41
59
  ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch main)`] : []),
42
60
  'Anything you read here is STALE, and every plan built from it is built on code that no',
43
61
  'longer exists upstream.',
44
62
  '',
45
- 'Run exactly this, then retry:',
46
- ` ${this.treeRoot !== '' ? (0, rules_config_1.atRoot)(this.treeRoot, pull) : pull}`,
63
+ 'Run ONE of these, then retry:',
64
+ ` 1. ${this.treeRoot !== '' ? (0, rules_config_1.atRoot)(this.treeRoot, pull) : pull}`,
65
+ ' Updates main in place. CLEAN TREE ONLY — with local modifications this is not a',
66
+ ' fast-forward and git will refuse it.',
67
+ ` 2. ${this.treeRoot !== '' ? (0, rules_config_1.atRoot)(this.treeRoot, branch) : branch}`,
68
+ ' Works with UNCOMMITTED CHANGES — they come with you onto the new branch, and you',
69
+ ' land on current code. Prefer this one if you have edits in flight, or if 1 refused.',
47
70
  '',
48
- 'If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD holds a',
71
+ 'If 1 fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD holds a',
49
72
  'duplicate entry — clear it with `git fetch --prune origin main`, then pull again.',
73
+ 'If 2 refuses because origin/main changed the same files you edited, run `git stash`',
74
+ '(never blocked), then 2 again, then `git stash pop`.',
50
75
  ];
51
76
  }
52
77
  forReads(behindCount) {
@@ -59,26 +84,6 @@ class StaleMainMessage {
59
84
  ' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',
60
85
  ]).join('\n');
61
86
  }
62
- /**
63
- * The Bash variant. stale-main-bash-guard blocks only CONTENT reads, so the message has to say
64
- * which shell is still open — an agent that reads "Bash blocked" and believes the whole shell is
65
- * gone will not run the cure, which is itself a Bash command.
66
- */
67
- forBash(behindCount) {
68
- return this.common(behindCount).concat([
69
- '',
70
- 'This command was blocked because it reads FILE CONTENT out of the stale tree (cat/grep/',
71
- 'ls/find/sed/awk/git grep/git show <rev>:<path>). That is how stale bytes get into your',
72
- 'context and quietly poison everything you conclude — the incident behind this guard had',
73
- 'an agent describing a CI workflow set that was missing a whole workflow added upstream.',
74
- '',
75
- 'Still allowed right now (the cure is one of these):',
76
- ' - git pull/fetch, installs, upgrades, builds, tests, every other non-reading command',
77
- ' - git/gh METADATA: status, log, diff, show <rev>, branch, rev-list, gh pr list|view',
78
- ' - reads against the CURRENT upstream tree: git show origin/main:<path>, git grep <pat> origin/main',
79
- ' - all Write/Edit, and reading webpieces.config.json (set stale-main-bash-guard mode OFF to disable)',
80
- ]).join('\n');
81
- }
82
87
  }
83
88
  exports.StaleMainMessage = StaleMainMessage;
84
89
  //# sourceMappingURL=stale-main-message.js.map
@@ -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;;;;;;;;;;;;;;;GAeG;AACH,MAAa,gBAAgB;IAWI;IAV7B;;;;;;;;;OASG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;IAAG,CAAC;IAEtD,oFAAoF;IAC5E,MAAM,CAAC,WAAmB;QAC9B,MAAM,IAAI,GAAG,gCAAgC,CAAC;QAC9C,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,KAAK,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,IAAA,qBAAM,EAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE;YAChE,EAAE;YACF,yFAAyF;YACzF,mFAAmF;SACtF,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;IAED;;;;OAIG;IACH,OAAO,CAAC,WAAmB;QACvB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,yFAAyF;YACzF,wFAAwF;YACxF,yFAAyF;YACzF,yFAAyF;YACzF,EAAE;YACF,qDAAqD;YACrD,wFAAwF;YACxF,uFAAuF;YACvF,sGAAsG;YACtG,uGAAuG;SAC1G,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;CACJ;AA7DD,4CA6DC","sourcesContent":["import { atRoot } from '@webpieces/rules-config';\n\n/**\n * The \"you are on a stale main\" text, shared by the TWO guards that detect the state from the same\n * cached signal (`MainSyncStatus.localMain` vs `originMain`):\n *\n * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}\n * - stale-main-bash-guard blocks content-read Bash {@link StaleMainMessage.forBash}\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 behaviors for one repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The cure is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which\n * is the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either\n * fast-forwards (the case here, since the block only fires when the tree is clean and behind) or\n * fails loudly without touching anything.\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 the cure is rendered as `cd <treeRoot> && git pull …`, 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 // The diagnosis + cure. Identical for both guards — the part that must never drift.\n private common(behindCount: string): string[] {\n const pull = 'git pull --ff-only 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 exactly this, then retry:',\n ` ${this.treeRoot !== '' ? atRoot(this.treeRoot, pull) : pull}`,\n '',\n 'If that 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 pull again.',\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 * The Bash variant. stale-main-bash-guard blocks only CONTENT reads, so the message has to say\n * which shell is still open — an agent that reads \"Bash blocked\" and believes the whole shell is\n * gone will not run the cure, which is itself a Bash command.\n */\n forBash(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'This command was blocked because it reads FILE CONTENT out of the stale tree (cat/grep/',\n 'ls/find/sed/awk/git grep/git show <rev>:<path>). That is how stale bytes get into your',\n 'context and quietly poison everything you conclude — the incident behind this guard had',\n 'an agent describing a CI workflow set that was missing a whole workflow added upstream.',\n '',\n 'Still allowed right now (the cure is one of these):',\n ' - git pull/fetch, installs, upgrades, builds, tests, every other non-reading command',\n ' - git/gh METADATA: status, log, diff, show <rev>, branch, rev-list, gh pr list|view',\n ' - reads against the CURRENT upstream tree: git show origin/main:<path>, git grep <pat> origin/main',\n ' - all Write/Edit, and reading webpieces.config.json (set stale-main-bash-guard mode OFF to disable)',\n ]).join('\\n');\n }\n}\n"]}
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;;;;;;;;;;;;;;;;;;;GAmBG;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,IAAI,GAAG,gCAAgC,CAAC;QAC9C,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,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE;YACnE,sFAAsF;YACtF,2CAA2C;YAC3C,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,mFAAmF;YACnF,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;AA9DD,4CA8DC","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 now blocks at row 5 on the branch alone — being on `main` is the whole\n * finding, and staleness does not enter into it — so it carries its own message and this variant was\n * deleted rather than left as an unreachable 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 `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which is\n * the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either\n * fast-forwards or fails loudly without touching anything. It used to be the ONLY cure printed, on the\n * strength of \"the block only fires when the tree is clean and behind\" — which stopped being true when\n * the dirty valve was deleted, and was the reason that valve existed. Cure 2 (`git checkout -b`) is\n * what makes the message correct on a dirty tree, so both are printed and each is labelled.\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 the cure is rendered as `cd <treeRoot> && git pull …`, 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 `git pull --ff-only`, 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 pull = 'git pull --ff-only origin 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, pull) : pull}`,\n ' Updates main in place. CLEAN TREE ONLY — with local modifications this is not a',\n ' fast-forward and git will refuse it.',\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 pull 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"]}