@webpieces/ai-hook-rules 0.4.684 → 0.4.686

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.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/src/core/l2-doc.js +35 -21
  3. package/src/core/l2-doc.js.map +1 -1
  4. package/src/core/l2-rows.d.ts +8 -5
  5. package/src/core/l2-rows.js +26 -17
  6. package/src/core/l2-rows.js.map +1 -1
  7. package/src/core/rules/feature-branch-guard.d.ts +21 -1
  8. package/src/core/rules/feature-branch-guard.js +21 -1
  9. package/src/core/rules/feature-branch-guard.js.map +1 -1
  10. package/src/core/rules/main-freshness.d.ts +35 -0
  11. package/src/core/rules/main-freshness.js +53 -0
  12. package/src/core/rules/main-freshness.js.map +1 -0
  13. package/src/core/rules/merged-branch-bash-guard.js +1 -1
  14. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  15. package/src/core/rules/merged-branch-message.js +2 -1
  16. package/src/core/rules/merged-branch-message.js.map +1 -1
  17. package/src/core/rules/read-stale-guard.d.ts +5 -3
  18. package/src/core/rules/read-stale-guard.js +10 -24
  19. package/src/core/rules/read-stale-guard.js.map +1 -1
  20. package/src/core/rules/recovery-allowlist.d.ts +32 -1
  21. package/src/core/rules/recovery-allowlist.js +79 -21
  22. package/src/core/rules/recovery-allowlist.js.map +1 -1
  23. package/src/core/rules/shell-segment-scan.d.ts +10 -1
  24. package/src/core/rules/shell-segment-scan.js +9 -1
  25. package/src/core/rules/shell-segment-scan.js.map +1 -1
  26. package/src/core/rules/stale-main-bash-guard.d.ts +73 -47
  27. package/src/core/rules/stale-main-bash-guard.js +109 -83
  28. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  29. package/src/core/rules/stale-main-message.d.ts +4 -3
  30. package/src/core/rules/stale-main-message.js +4 -3
  31. package/src/core/rules/stale-main-message.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.pipedInto && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n // `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n private redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
1
+ {"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.pipedInto && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n /**\n * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n *\n * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the\n * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but\n * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what\n * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy\n * gets wrong.\n */\n redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
@@ -43,41 +43,54 @@ import { FixHint } from '../fix-hint';
43
43
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
44
44
  * `git checkout -- <file>`, and any other branch.
45
45
  *
46
- * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────
46
+ * ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ────────────────────────────────
47
47
  *
48
- * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only
49
- * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so —
50
- * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.
48
+ * The finding is not "you are on `main`" it is **"what you would read here is out of date"**. So the
49
+ * ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only
50
+ * when local `main` is known to be BEHIND `origin/main`. Unknown allow. Current allow.
51
51
  *
52
- * FRESHNESS IS THE WRONG QUESTION *for the block*, though it is the reason the block costs nothing.
53
- * `main` is not a place to WORK even when it is perfectly current, so the cure is not `git pull` but
54
- * a new branch; gating the block on the cache meant a current `main` was treated as a fine place to
55
- * run a build, an installer or a codegen step. And when `main` IS behind, the other half applies
56
- * reads taken here are out of date, so there is no state in which staying put is the better move.
57
- * That is what the deny text says out loud, so "I was only reading" stops being an argument.
52
+ * WHY, when this guard spent a release judging the branch alone: because the branch alone denies
53
+ * everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly
54
+ * where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-checkout-clean-main` the
55
+ * command this repo tells it to run and the next `curl`, `gh pr close` or test run is refused by a
56
+ * guard whose own name says STALE. The tool that got it there could not be the cure for being there,
57
+ * and the refusal had nothing to do with staleness, which is the confusion reported from the field.
58
58
  *
59
- * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it
60
- * for the NEXT call, so the FIRST call of every session has none — and in a multi-worktree repo
61
- * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly
62
- * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's
63
- * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is
64
- * `B` being brought into line with `E`, which is the table's own rule, not a new policy.
59
+ * THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right
60
+ * about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place
61
+ * whatever `main`'s freshness unreviewable, and unrevertable as a unit so feature-branch-guard
62
+ * stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms
63
+ * nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there.
64
+ * Same tree, different hazard, so one precondition was never right for both.
65
65
  *
66
- * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and
67
- * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a
68
- * `>` redirect commands whose stated purpose is something else and whose effect is to modify
69
- * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same
70
- * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.
66
+ * THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks "does local `main`
67
+ * already contain the cached `origin/main`?", so the block lifts the instant a pull lands rather than
68
+ * waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses one
69
+ * implementation, so the Read and Bash halves of one state can never disagree about whether the pull
70
+ * took.
71
71
  *
72
- * BLOCKED anything on `main` that is not on the skip list builds, tests, installers,
73
- * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
74
- * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>
75
- * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,
76
- * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.
72
+ * FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable:
73
+ * no cache (the first call of every session), a cache for another branch, an empty `originMain`
74
+ * (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is
75
+ * fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no
76
+ * synchronous `git fetch` is ever run on the blocking path.
77
77
  *
78
- * FAIL-OPEN is preserved where it still means anything: branch undeterminable allow. The cache
79
- * valves (`no-sync-cache`, `origin-main-unknown`) are gone from THIS guard because it no longer reads
80
- * the cache. There is no dirty-tree valve here and none in read-stale-guard either: the cure is
78
+ * THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape
79
+ * merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read
80
+ * BLOCKLIST could not replace it enumerating readers catches `cat` and `grep`, and structurally
81
+ * cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands
82
+ * whose stated purpose is something else and whose effect is to modify tracked files. What changed is
83
+ * WHEN that polarity applies, not the polarity.
84
+ *
85
+ * BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests,
86
+ * installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
87
+ * ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the
88
+ * blocked state, everything that gets you OUT or tells you where you are:
89
+ * `git checkout -b <new> origin/main`, `git switch`, `git pull`/`fetch`,
90
+ * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this
91
+ * tree), `curl`/`wget`, every `wp-*` bin, installs.
92
+ *
93
+ * There is no dirty-tree valve here and none in read-stale-guard either: the cure is
81
94
  * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
82
95
  * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
83
96
  */
@@ -87,12 +100,26 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
87
100
  private readonly recovery;
88
101
  private readonly switches;
89
102
  private readonly recoveryList;
103
+ private readonly freshness;
90
104
  readonly description: string;
91
105
  readonly defaultOptions: {
92
106
  hangTimeoutMinutes: number;
93
107
  };
94
108
  readonly fixHint: FixHint;
95
109
  check(ctx: BashContext): readonly Violation[];
110
+ /**
111
+ * ROWS 6/7 — block ONLY when local `main` is KNOWN to be behind `origin/main`.
112
+ *
113
+ * Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and
114
+ * that is on purpose — the Read and the Bash halves of one state must not disagree about whether
115
+ * `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one
116
+ * file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the
117
+ * row 4 skip list already applied above.
118
+ *
119
+ * Every exit that is not "established BEHIND" is an ALLOW, and the not-established ones are
120
+ * `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.
121
+ */
122
+ private checkFreshness;
96
123
  /**
97
124
  * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same
98
125
  * command, or null. The pull is looked for across the WHOLE command, not the matched segment,
@@ -101,28 +128,27 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
101
128
  */
102
129
  private bareCheckoutOfMain;
103
130
  /**
104
- * The row 5 deny. Deliberately SHORT.
131
+ * The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what
132
+ * the guard's name promised all along.
105
133
  *
106
- * The oldest version opened by reporting how many commits behind `main` was, which invited
107
- * exactly the wrong cure an agent that reads "behind" reaches for `git pull`, ends up on a
108
- * CURRENT `main`, and is still on `main`. The finding is the branch, so that is said first.
134
+ * The two versions before this one are both instructive. The oldest opened with how many commits
135
+ * behind `main` was, which invited the wrong cure: an agent that reads "behind" reaches for a pull,
136
+ * lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said
137
+ * `main` is not a place to work "whether or not it is current" — true of a WRITE, but this guard
138
+ * does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable.
109
139
  *
110
- * The version after that swung too far the other way: a flat "`main` is not a place to work",
111
- * printed in answer to a `grep`. That reads as overreach precisely because it is not true of
112
- * READING an agent that has just landed a PR and is orienting itself on `main` is doing the
113
- * right thing, and the Read tool is deliberately left open for it (read-stale-guard closes it
114
- * only once `main` falls BEHIND). So the text now says three things the flat version could not:
140
+ * So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you
141
+ * read here is out of date; Bash is default-deny in this state rather than a list of readers,
142
+ * because a command's stated purpose never says whether it also WRITES; and the branch cure fetches,
143
+ * so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN
144
+ * stays legitimate on a CURRENT `main` nothing here fires at all.
115
145
  *
116
- * 1. reading `main` to PLAN is legitimate, and Bash is default-deny here only because a
117
- * command's stated purpose never says whether it also WRITES (row 5 use case 10);
118
- * 2. the FEATURE BRANCH is the unit of work the positive form of the rule;
119
- * 3. STALENESS, which is the strongest argument and used to be missing entirely: if local
120
- * `main` is behind, the reads are out of date too, so "I am only reading" is not a reason
121
- * to skip the cure. The cure FETCHES, so it makes the reads true as well as moving you off
122
- * `main` — which is why judging on the branch alone, before freshness is known, is the
123
- * correct call rather than a crude approximation.
146
+ * ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b`
147
+ * carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard
148
+ * prints it, because a Read really can be cured by staying put. A Bash session cannot — the next
149
+ * command is as likely to write as to read.)
124
150
  */
125
- private onMainMessage;
151
+ private staleMainMessage;
126
152
  private pairingMessage;
127
153
  /**
128
154
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.
@@ -16,6 +16,7 @@ const command_scan_1 = require("../command-scan");
16
16
  const tree_recovery_1 = require("./tree-recovery");
17
17
  const branch_switch_scan_1 = require("./branch-switch-scan");
18
18
  const recovery_allowlist_1 = require("./recovery-allowlist");
19
+ const main_freshness_1 = require("./main-freshness");
19
20
  /**
20
21
  * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:
21
22
  * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that
@@ -57,41 +58,54 @@ const recovery_allowlist_1 = require("./recovery-allowlist");
57
58
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
58
59
  * `git checkout -- <file>`, and any other branch.
59
60
  *
60
- * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────
61
+ * ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ────────────────────────────────
61
62
  *
62
- * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only
63
- * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so —
64
- * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.
63
+ * The finding is not "you are on `main`" it is **"what you would read here is out of date"**. So the
64
+ * ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only
65
+ * when local `main` is known to be BEHIND `origin/main`. Unknown allow. Current allow.
65
66
  *
66
- * FRESHNESS IS THE WRONG QUESTION *for the block*, though it is the reason the block costs nothing.
67
- * `main` is not a place to WORK even when it is perfectly current, so the cure is not `git pull` but
68
- * a new branch; gating the block on the cache meant a current `main` was treated as a fine place to
69
- * run a build, an installer or a codegen step. And when `main` IS behind, the other half applies
70
- * reads taken here are out of date, so there is no state in which staying put is the better move.
71
- * That is what the deny text says out loud, so "I was only reading" stops being an argument.
67
+ * WHY, when this guard spent a release judging the branch alone: because the branch alone denies
68
+ * everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly
69
+ * where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-checkout-clean-main` the
70
+ * command this repo tells it to run and the next `curl`, `gh pr close` or test run is refused by a
71
+ * guard whose own name says STALE. The tool that got it there could not be the cure for being there,
72
+ * and the refusal had nothing to do with staleness, which is the confusion reported from the field.
72
73
  *
73
- * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it
74
- * for the NEXT call, so the FIRST call of every session has none — and in a multi-worktree repo
75
- * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly
76
- * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's
77
- * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is
78
- * `B` being brought into line with `E`, which is the table's own rule, not a new policy.
74
+ * THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right
75
+ * about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place
76
+ * whatever `main`'s freshness unreviewable, and unrevertable as a unit so feature-branch-guard
77
+ * stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms
78
+ * nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there.
79
+ * Same tree, different hazard, so one precondition was never right for both.
79
80
  *
80
- * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and
81
- * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a
82
- * `>` redirect commands whose stated purpose is something else and whose effect is to modify
83
- * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same
84
- * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.
81
+ * THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks "does local `main`
82
+ * already contain the cached `origin/main`?", so the block lifts the instant a pull lands rather than
83
+ * waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses one
84
+ * implementation, so the Read and Bash halves of one state can never disagree about whether the pull
85
+ * took.
85
86
  *
86
- * BLOCKED anything on `main` that is not on the skip list builds, tests, installers,
87
- * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
88
- * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>
89
- * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,
90
- * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.
87
+ * FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable:
88
+ * no cache (the first call of every session), a cache for another branch, an empty `originMain`
89
+ * (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is
90
+ * fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no
91
+ * synchronous `git fetch` is ever run on the blocking path.
91
92
  *
92
- * FAIL-OPEN is preserved where it still means anything: branch undeterminable allow. The cache
93
- * valves (`no-sync-cache`, `origin-main-unknown`) are gone from THIS guard because it no longer reads
94
- * the cache. There is no dirty-tree valve here and none in read-stale-guard either: the cure is
93
+ * THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape
94
+ * merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read
95
+ * BLOCKLIST could not replace it enumerating readers catches `cat` and `grep`, and structurally
96
+ * cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands
97
+ * whose stated purpose is something else and whose effect is to modify tracked files. What changed is
98
+ * WHEN that polarity applies, not the polarity.
99
+ *
100
+ * BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests,
101
+ * installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
102
+ * ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the
103
+ * blocked state, everything that gets you OUT or tells you where you are:
104
+ * `git checkout -b <new> origin/main`, `git switch`, `git pull`/`fetch`,
105
+ * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this
106
+ * tree), `curl`/`wget`, every `wp-*` bin, installs.
107
+ *
108
+ * There is no dirty-tree valve here and none in read-stale-guard either: the cure is
95
109
  * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
96
110
  * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
97
111
  */
@@ -103,10 +117,12 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
103
117
  // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states
104
118
  // cannot drift apart about what "gets you out" means. See recovery-allowlist.ts.
105
119
  recoveryList = new recovery_allowlist_1.RecoveryAllowlist(this.scanner);
120
+ // The ancestry test and the cache summary, shared with read-stale-guard — see main-freshness.ts.
121
+ freshness = new main_freshness_1.MainFreshness();
106
122
  description = 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +
107
- 'the same command), and block Bash on ' +
108
- 'main outright — allowlisting only the commands that get you off it so a session neither ' +
109
- 'lands on main nor works there, whether or not main happens to be current.';
123
+ 'the same command), and — once local main is KNOWN to be behind origin/main — block Bash ' +
124
+ 'there, allowlisting only the commands that get you off it. A main that is current, or whose ' +
125
+ 'freshness is unknown, is left alone.';
110
126
  defaultOptions = {
111
127
  hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
112
128
  };
@@ -122,7 +138,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
122
138
  new rules_config_1.Option(this.recovery.updateMainSteps('unknown').join('\n')
123
139
  + '\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),
124
140
  new rules_config_1.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.'),
125
- new rules_config_1.Option('Still allowed on main: the Read tool, so you can read main while you PLAN (read-stale-guard closes it only once main falls BEHIND origin/main, 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, gh pr view|list|status|checks, git stash, every wp-* bin, installs, and reading webpieces.config.json.'),
141
+ new rules_config_1.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.'),
126
142
  new rules_config_1.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.'),
127
143
  ]);
128
144
  check(ctx) {
@@ -145,31 +161,42 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
145
161
  if (this.recoveryList.isFullyRecovery(ctx)) {
146
162
  return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');
147
163
  }
148
- // ROW 5 — on `main`. NO CACHE IS READ ON THIS PATH, and that is the change.
149
- //
150
- // The old ladder asked the cache "is main BEHIND?" and only then blocked, and only content
151
- // READS. That made the whole Bash half of row 5 conditional on freshness, which is the wrong
152
- // question twice over:
153
- //
154
- // 1. Freshness is irrelevant to whether you should be working here. `main` is not a place to
155
- // work even when it is perfectly current the cure is the same either way, and it is not
156
- // `git pull`, it is `git checkout -b`. Row 5's cure has always said so.
157
- // 2. The cache is populated by a FIRE-AND-FORGET refresher that fills it for the NEXT call,
158
- // so on the first call of every session there is none — and in a multi-worktree repo
159
- // another tree can hold the refresh lock indefinitely. A block that needs the cache is a
160
- // block that is off exactly when a session is starting, which is when an agent is most
161
- // likely to still be standing on `main`.
162
- //
163
- // So this is now one `git rev-parse` and a text scan, both of which fire on call #1 — the same
164
- // arrangement that has always governed row 5's Write/Edit half (feature-branch-guard). `B`
165
- // tracking `E` here is the table's own rule, not a new policy.
166
- //
167
- // The polarity flips with it: on `main` this is DEFAULT-DENY plus row 4's skip list, where it
168
- // used to be default-allow plus a content-read blocklist. That is what makes it catch the
169
- // commands a blocklist structurally cannot an installer, a formatter or a codegen step that
170
- // WRITES tracked files while its stated purpose is something else. Blocking those was never
171
- // going to come from enumerating readers.
172
- return this.block(ctx, branch, 'on-main', this.onMainMessage(ctx.workspaceRoot), '-');
164
+ return this.checkFreshness(ctx, branch);
165
+ }
166
+ /**
167
+ * ROWS 6/7 block ONLY when local `main` is KNOWN to be behind `origin/main`.
168
+ *
169
+ * Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and
170
+ * that is on purpose the Read and the Bash halves of one state must not disagree about whether
171
+ * `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one
172
+ * file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the
173
+ * row 4 skip list already applied above.
174
+ *
175
+ * Every exit that is not "established BEHIND" is an ALLOW, and the not-established ones are
176
+ * `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.
177
+ */
178
+ checkFreshness(ctx, branch) {
179
+ const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot, 'main');
180
+ // The first call of every session, and every call while another worktree holds the refresh
181
+ // lock. The refresher fired above populates it for the NEXT call; nothing waits here.
182
+ if (status === null)
183
+ return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
184
+ const cache = this.freshness.summarize(status);
185
+ // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry BY KEY, so
186
+ // a mismatch means the map's key and the entry's own `branch` disagree a shape bug. Kept so
187
+ // that degrades to an allow. Unreachable in normal operation.
188
+ if (status.branch !== 'main')
189
+ return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);
190
+ // Offline / origin unresolvable, or no local main to compare against.
191
+ if (status.originMain === '')
192
+ return this.failOpen(ctx, branch, 'origin-main-unknown', cache);
193
+ // ANCESTRY, not equality — the line that makes a pull take effect immediately. See
194
+ // MainFreshness.containsOriginMain.
195
+ if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {
196
+ return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
197
+ }
198
+ // ESTABLISHED BEHIND. Now — and only now — the default-deny polarity applies.
199
+ return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);
173
200
  }
174
201
  /**
175
202
  * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same
@@ -190,35 +217,34 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
190
217
  return null;
191
218
  }
192
219
  /**
193
- * The row 5 deny. Deliberately SHORT.
220
+ * The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what
221
+ * the guard's name promised all along.
194
222
  *
195
- * The oldest version opened by reporting how many commits behind `main` was, which invited
196
- * exactly the wrong cure an agent that reads "behind" reaches for `git pull`, ends up on a
197
- * CURRENT `main`, and is still on `main`. The finding is the branch, so that is said first.
223
+ * The two versions before this one are both instructive. The oldest opened with how many commits
224
+ * behind `main` was, which invited the wrong cure: an agent that reads "behind" reaches for a pull,
225
+ * lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said
226
+ * `main` is not a place to work "whether or not it is current" — true of a WRITE, but this guard
227
+ * does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable.
198
228
  *
199
- * The version after that swung too far the other way: a flat "`main` is not a place to work",
200
- * printed in answer to a `grep`. That reads as overreach precisely because it is not true of
201
- * READING an agent that has just landed a PR and is orienting itself on `main` is doing the
202
- * right thing, and the Read tool is deliberately left open for it (read-stale-guard closes it
203
- * only once `main` falls BEHIND). So the text now says three things the flat version could not:
229
+ * So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you
230
+ * read here is out of date; Bash is default-deny in this state rather than a list of readers,
231
+ * because a command's stated purpose never says whether it also WRITES; and the branch cure fetches,
232
+ * so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN
233
+ * stays legitimate on a CURRENT `main` nothing here fires at all.
204
234
  *
205
- * 1. reading `main` to PLAN is legitimate, and Bash is default-deny here only because a
206
- * command's stated purpose never says whether it also WRITES (row 5 use case 10);
207
- * 2. the FEATURE BRANCH is the unit of work the positive form of the rule;
208
- * 3. STALENESS, which is the strongest argument and used to be missing entirely: if local
209
- * `main` is behind, the reads are out of date too, so "I am only reading" is not a reason
210
- * to skip the cure. The cure FETCHES, so it makes the reads true as well as moving you off
211
- * `main` — which is why judging on the branch alone, before freshness is known, is the
212
- * correct call rather than a crude approximation.
235
+ * ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b`
236
+ * carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard
237
+ * prints it, because a Read really can be cured by staying put. A Bash session cannot — the next
238
+ * command is as likely to write as to read.)
213
239
  */
214
- onMainMessage(workspaceRoot) {
215
- return 'Blocked: you are on `main`. Reading main to PLAN is fine the Read tool stays open '
216
- + "while main is current; Bash is default-deny here only because a command's stated "
217
- + 'purpose never says whether it also WRITES. What main is not is a place to WORK: the '
218
- + 'feature branch is the unit of work, reviewable and revertable. Judged from the branch '
219
- + 'alone, before freshness is known right either way: if main is BEHIND origin/main '
220
- + 'your reads are out of date too, so planning here is wasted as well. The cure fetches, '
221
- + 'so it makes your reads true as well as moving you off main.\n'
240
+ staleMainMessage(workspaceRoot) {
241
+ return 'Blocked: local `main` is BEHIND origin/main, so what you would read here is out of '
242
+ + 'date and a plan built on it is built on code upstream has moved past. A CURRENT main is '
243
+ + 'not blocked reading main to PLAN is fine, and this fires only once being behind is '
244
+ + 'established. Bash is default-deny in this state rather than a list of readers, because a '
245
+ + "command's stated purpose never says whether it also WRITES. The feature branch is the "
246
+ + 'unit of work in any case: reviewable, revertable and the cure fetches, so it makes '
247
+ + 'your reads true as well as moving you off main.\n'
222
248
  + `Start a branch (uncommitted work comes with you):\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;
223
249
  }
224
250
  pairingMessage(ctx) {