@webpieces/ai-hook-rules 0.4.645 → 0.4.646

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.
@@ -40,40 +40,49 @@ import { FixHint } from '../fix-hint';
40
40
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
41
41
  * `git checkout -- <file>`, and any other branch.
42
42
  *
43
- * ── REACTIVE: content-reading Bash on a stale `main` ─────────────────────────────────────────────
43
+ * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────
44
44
  *
45
- * read-stale-guard blocks the Read tool when local `main` is behind origin/main but it looks at
46
- * nothing else, deliberately: "every cure is a Bash command, so Bash is the escape hatch never
47
- * wedge it." That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the
48
- * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069
49
- * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session
50
- * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI
51
- * workflow set that was missing a 186-line workflow which existed upstream. The logs read
52
- * "read-stale-guard handled", which is worse than no guard: it looks covered.
45
+ * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only
46
+ * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so
47
+ * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.
53
48
  *
54
- * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,
55
- * `git pull`, git METADATA (log/diff/show/status) all still run. What is blocked is a command that
56
- * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a
57
- * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same
58
- * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is
59
- * one command away and there is no reason to stop anything else.
49
+ * FRESHNESS IS THE WRONG QUESTION. `main` is not a place to work even when it is perfectly current.
50
+ * Staleness changes what you would READ; it does not change whether this is the branch to work on,
51
+ * and the cure is not `git pull` but a new branch. Gating the block on the cache meant a current
52
+ * `main` was treated as a fine place to run a build, an installer or a codegen step.
60
53
  *
61
- * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the
62
- * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.
54
+ * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it
55
+ * for the NEXT call, so the FIRST call of every session has none and in a multi-worktree repo
56
+ * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly
57
+ * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's
58
+ * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is
59
+ * `B` being brought into line with `E`, which is the table's own rule, not a new policy.
63
60
  *
64
- * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:
65
- * - branch undeterminable / not on `main` / no cache / cache for another branch → allow
66
- * - `originMain` unknown (offline) allow
67
- * - origin/main already an ancestor of HEAD (ancestry, NOT equality) allow the instant the pull lands
68
- * - DIRTY tree allow: the pull is not a clean fast-forward, and resolving that means reading the
69
- * very files in conflict. Never trap the agent away from its own rescue.
70
- * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow
61
+ * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and
62
+ * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a
63
+ * `>` redirect commands whose stated purpose is something else and whose effect is to modify
64
+ * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same
65
+ * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.
66
+ *
67
+ * BLOCKED anything on `main` that is not on the skip list builds, tests, installers,
68
+ * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
69
+ * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>
70
+ * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,
71
+ * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.
72
+ *
73
+ * FAIL-OPEN is preserved where it still means anything: branch undeterminable → allow. The cache
74
+ * valves (`no-sync-cache`, `origin-main-unknown`, `dirty-tree-on-main`) are gone from THIS guard
75
+ * because it no longer reads the cache; read-stale-guard still opens them for the Read tool, where a
76
+ * dirty tree genuinely does make the prescribed `git pull` unavailable (see the doc's "Not done").
77
+ * Here the cure is `git checkout -b`, which CARRIES uncommitted work onto the new branch — so a dirty
78
+ * tree traps nobody and needs no valve.
71
79
  */
72
80
  export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {
73
81
  constructor(config: BranchStateGuardConfig);
74
82
  private readonly scanner;
75
83
  private readonly recovery;
76
84
  private readonly switches;
85
+ private readonly recoveryList;
77
86
  readonly description: string;
78
87
  readonly defaultOptions: {
79
88
  hangTimeoutMinutes: number;
@@ -87,13 +96,15 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
87
96
  * the point.
88
97
  */
89
98
  private bareCheckoutOfMain;
99
+ /**
100
+ * The row 5 deny. Deliberately SHORT, and deliberately NOT about staleness.
101
+ *
102
+ * The old message opened by reporting how many commits behind `main` was, which invited exactly
103
+ * the wrong cure — an agent that reads "behind" reaches for `git pull`, ends up on a CURRENT
104
+ * `main`, and is still on `main`. The finding is the branch, so that is the first thing said.
105
+ */
106
+ private onMainMessage;
90
107
  private pairingMessage;
91
- private staleContentRead;
92
- private contains;
93
- private isDirty;
94
- private staleMessage;
95
- private behindCount;
96
- private cacheSummary;
97
108
  /**
98
109
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.
99
110
  *
@@ -10,12 +10,12 @@ const to_error_1 = require("../to-error");
10
10
  const main_sync_refresh_1 = require("../main-sync-refresh");
11
11
  const main_sync_timeout_1 = require("../main-sync-timeout");
12
12
  const decision_log_1 = require("../decision-log");
13
+ const l2_matrix_doc_1 = require("../l2-matrix-doc");
13
14
  const l0_fault_codes_1 = require("../l0-fault-codes");
14
15
  const command_scan_1 = require("../command-scan");
15
- const stale_main_message_1 = require("./stale-main-message");
16
- const content_read_scan_1 = require("./content-read-scan");
17
16
  const tree_recovery_1 = require("./tree-recovery");
18
17
  const branch_switch_scan_1 = require("./branch-switch-scan");
18
+ const recovery_allowlist_1 = require("./recovery-allowlist");
19
19
  /**
20
20
  * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:
21
21
  * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that
@@ -54,47 +54,58 @@ const branch_switch_scan_1 = require("./branch-switch-scan");
54
54
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
55
55
  * `git checkout -- <file>`, and any other branch.
56
56
  *
57
- * ── REACTIVE: content-reading Bash on a stale `main` ─────────────────────────────────────────────
57
+ * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────
58
58
  *
59
- * read-stale-guard blocks the Read tool when local `main` is behind origin/main but it looks at
60
- * nothing else, deliberately: "every cure is a Bash command, so Bash is the escape hatch never
61
- * wedge it." That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the
62
- * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069
63
- * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session
64
- * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI
65
- * workflow set that was missing a 186-line workflow which existed upstream. The logs read
66
- * "read-stale-guard handled", which is worse than no guard: it looks covered.
59
+ * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only
60
+ * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so
61
+ * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.
67
62
  *
68
- * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,
69
- * `git pull`, git METADATA (log/diff/show/status) all still run. What is blocked is a command that
70
- * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a
71
- * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same
72
- * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is
73
- * one command away and there is no reason to stop anything else.
63
+ * FRESHNESS IS THE WRONG QUESTION. `main` is not a place to work even when it is perfectly current.
64
+ * Staleness changes what you would READ; it does not change whether this is the branch to work on,
65
+ * and the cure is not `git pull` but a new branch. Gating the block on the cache meant a current
66
+ * `main` was treated as a fine place to run a build, an installer or a codegen step.
74
67
  *
75
- * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the
76
- * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.
68
+ * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it
69
+ * for the NEXT call, so the FIRST call of every session has none and in a multi-worktree repo
70
+ * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly
71
+ * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's
72
+ * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is
73
+ * `B` being brought into line with `E`, which is the table's own rule, not a new policy.
77
74
  *
78
- * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:
79
- * - branch undeterminable / not on `main` / no cache / cache for another branch → allow
80
- * - `originMain` unknown (offline) allow
81
- * - origin/main already an ancestor of HEAD (ancestry, NOT equality) allow the instant the pull lands
82
- * - DIRTY tree allow: the pull is not a clean fast-forward, and resolving that means reading the
83
- * very files in conflict. Never trap the agent away from its own rescue.
84
- * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow
75
+ * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and
76
+ * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a
77
+ * `>` redirect commands whose stated purpose is something else and whose effect is to modify
78
+ * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same
79
+ * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.
80
+ *
81
+ * BLOCKED anything on `main` that is not on the skip list builds, tests, installers,
82
+ * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
83
+ * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>
84
+ * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,
85
+ * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.
86
+ *
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.
85
93
  */
86
94
  class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
87
95
  constructor(config) { super(config, 'stale-main-bash-guard', rules_config_1.BRANCH_STATE_GUARD_KEY); }
88
96
  scanner = new command_scan_1.CommandScanner();
89
97
  recovery = new tree_recovery_1.TreeRecovery();
90
98
  switches = new branch_switch_scan_1.BranchSwitchScan(this.scanner);
91
- description = 'Block a bare `git checkout main` (chain the pull into the same command), and block ' +
92
- 'content-reading Bash (cat/grep/ls/…) while local main is behind origin/main so a session ' +
93
- 'neither lands on a stale main nor reasons over one through the side door the Read block leaves.';
99
+ // ROW 4, the skip list the SAME instance-shape merged-branch-bash-guard uses, so the two states
100
+ // cannot drift apart about what "gets you out" means. See recovery-allowlist.ts.
101
+ recoveryList = new recovery_allowlist_1.RecoveryAllowlist(this.scanner);
102
+ description = 'Block a bare `git checkout main` (chain the pull into the same command), and block Bash on ' +
103
+ 'main outright — allowlisting only the commands that get you off it — so a session neither ' +
104
+ 'lands on main nor works there, whether or not main happens to be current.';
94
105
  defaultOptions = {
95
106
  hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
96
107
  };
97
- fixHint = new fix_hint_1.FixHint('Landing on `main` without pulling, or reading files while main is behind origin/main, both give you stale content.', 'Pair the checkout with the pull, or update main and re-run:', [
108
+ fixHint = new fix_hint_1.FixHint('Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.', 'Get onto a feature branch, or pair the checkout with the pull:', [
98
109
  // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no
99
110
  // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for
100
111
  // "we cannot detect the tree": both forms, each labelled. That matters here because the
@@ -105,7 +116,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
105
116
  new fix_hint_1.Option(this.recovery.updateMainSteps('unknown').join('\n')
106
117
  + '\nWhichever form applies, the pull must be in the SAME command as the checkout.', true),
107
118
  new fix_hint_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.'),
108
- new fix_hint_1.Option('NOT blocked: `git checkout <sha>`, `git checkout -b <x> origin/main`, `git checkout -- <file>`, any other branch. Also still allowed: builds, tests, installs, the pull itself, all git/gh METADATA (status|log|diff|show|branch), every Write/Edit, and reading webpieces.config.json.'),
119
+ new fix_hint_1.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.'),
109
120
  new fix_hint_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.'),
110
121
  ]);
111
122
  check(ctx) {
@@ -124,31 +135,35 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
124
135
  // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.
125
136
  if (branch !== 'main')
126
137
  return this.allow(ctx, branch, 'not-on-main (state B is another guard)');
127
- const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot, 'main');
128
- if (status === null)
129
- return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
130
- const cache = this.cacheSummary(status);
131
- // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so
132
- // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so
133
- // that degrades to an allow. Unreachable in normal operation.
134
- if (status.branch !== 'main')
135
- return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);
136
- // Offline / origin unresolvable — we have nothing to be stale RELATIVE TO.
137
- if (status.originMain === '')
138
- return this.failOpen(ctx, branch, 'origin-main-unknown', cache);
139
- // Ancestry, not equality: the moment the pull lands (or we are simply ahead), we are current.
140
- if (this.contains(ctx.workspaceRoot, status.originMain)) {
141
- return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
138
+ // ROW 4 — the skip list, ahead of the block, so no command that gets you OUT is ever denied.
139
+ if (this.recoveryList.isFullyRecovery(ctx)) {
140
+ return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');
142
141
  }
143
- // A dirty tree means the pull is not a clean fast-forward. Do not cut the agent off from the
144
- // files it must read to resolve that — the same valve read-stale-guard opens.
145
- if (this.isDirty(ctx.workspaceRoot)) {
146
- return this.failOpen(ctx, branch, 'dirty-tree-on-main', cache);
147
- }
148
- const reader = this.staleContentRead(ctx);
149
- if (reader === null)
150
- return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)', cache);
151
- return this.block(ctx, branch, `stale-main content read (${reader})`, this.staleMessage(ctx.workspaceRoot), cache);
142
+ // ROW 5 on `main`. NO CACHE IS READ ON THIS PATH, and that is the change.
143
+ //
144
+ // The old ladder asked the cache "is main BEHIND?" and only then blocked, and only content
145
+ // READS. That made the whole Bash half of row 5 conditional on freshness, which is the wrong
146
+ // question twice over:
147
+ //
148
+ // 1. Freshness is irrelevant to whether you should be working here. `main` is not a place to
149
+ // work even when it is perfectly current — the cure is the same either way, and it is not
150
+ // `git pull`, it is `git checkout -b`. Row 5's cure has always said so.
151
+ // 2. The cache is populated by a FIRE-AND-FORGET refresher that fills it for the NEXT call,
152
+ // so on the first call of every session there is none — and in a multi-worktree repo
153
+ // another tree can hold the refresh lock indefinitely. A block that needs the cache is a
154
+ // block that is off exactly when a session is starting, which is when an agent is most
155
+ // likely to still be standing on `main`.
156
+ //
157
+ // So this is now one `git rev-parse` and a text scan, both of which fire on call #1 — the same
158
+ // arrangement that has always governed row 5's Write/Edit half (feature-branch-guard). `B`
159
+ // tracking `E` here is the table's own rule, not a new policy.
160
+ //
161
+ // The polarity flips with it: on `main` this is DEFAULT-DENY plus row 4's skip list, where it
162
+ // used to be default-allow plus a content-read blocklist. That is what makes it catch the
163
+ // commands a blocklist structurally cannot — an installer, a formatter or a codegen step that
164
+ // WRITES tracked files while its stated purpose is something else. Blocking those was never
165
+ // going to come from enumerating readers.
166
+ return this.block(ctx, branch, 'on-main', this.onMainMessage(ctx.workspaceRoot), '-');
152
167
  }
153
168
  /**
154
169
  * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same
@@ -168,6 +183,20 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
168
183
  }
169
184
  return null;
170
185
  }
186
+ /**
187
+ * The row 5 deny. Deliberately SHORT, and deliberately NOT about staleness.
188
+ *
189
+ * The old message opened by reporting how many commits behind `main` was, which invited exactly
190
+ * the wrong cure — an agent that reads "behind" reaches for `git pull`, ends up on a CURRENT
191
+ * `main`, and is still on `main`. The finding is the branch, so that is the first thing said.
192
+ */
193
+ onMainMessage(workspaceRoot) {
194
+ return 'Blocked: you are on `main`. `main` is not a place to work — whether or not it is '
195
+ + 'current — because work here cannot be reviewed, cannot be reverted as a unit, and is '
196
+ + 'one `git checkout` away from being lost. This is judged from the branch alone, so it '
197
+ + 'fires on the first command of a session, before any freshness is known.\n'
198
+ + `Start a branch (uncommitted work comes with you):\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;
199
+ }
171
200
  pairingMessage(ctx) {
172
201
  const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\n');
173
202
  // Deliberately SHORT. The incident that bought this guard (a main 157 commits behind; the
@@ -177,65 +206,6 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
177
206
  + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '
178
207
  + 'reports the drift BACKWARDS. Chain the pull into the same command:\n' + steps;
179
208
  }
180
- // The first segment that would read stale workspace content, or null when none does. The RAW
181
- // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted
182
- // prose can only ever block LESS (see BashContext.commandCode).
183
- staleContentRead(ctx) {
184
- const scan = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);
185
- for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {
186
- const hit = scan.readsStaleContent(segment);
187
- if (hit !== null)
188
- return hit;
189
- }
190
- return null;
191
- }
192
- // Is `commit` already contained in HEAD? Exit code IS the answer, so spawnSync: 0 = ancestor,
193
- // 1 = genuinely behind, anything else = git could not tell → fail OPEN. (Mirrors read-stale-guard.)
194
- contains(workspaceRoot, commit) {
195
- const result = (0, child_process_1.spawnSync)('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {
196
- cwd: workspaceRoot,
197
- encoding: 'utf8',
198
- });
199
- if (result.status === 0)
200
- return true;
201
- if (result.status === 1)
202
- return false;
203
- return true;
204
- }
205
- isDirty(workspaceRoot) {
206
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
207
- try {
208
- const out = (0, child_process_1.execSync)('git status --porcelain', {
209
- cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
210
- });
211
- return out.trim().length > 0;
212
- }
213
- catch (err) {
214
- const error = (0, to_error_1.toError)(err);
215
- void error;
216
- return true; // cannot tell → assume dirty, the fail-OPEN direction here
217
- }
218
- }
219
- staleMessage(workspaceRoot) {
220
- return new stale_main_message_1.StaleMainMessage(workspaceRoot).forBash(this.behindCount(workspaceRoot));
221
- }
222
- behindCount(workspaceRoot) {
223
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
224
- try {
225
- const out = (0, child_process_1.execSync)('git rev-list --count HEAD..origin/main', {
226
- cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
227
- }).trim();
228
- return /^\d+$/.test(out) ? out : '?';
229
- }
230
- catch (err) {
231
- const error = (0, to_error_1.toError)(err);
232
- void error;
233
- return '?';
234
- }
235
- }
236
- cacheSummary(status) {
237
- return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;
238
- }
239
209
  /**
240
210
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.
241
211
  *
@@ -256,7 +226,12 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
256
226
  }
257
227
  block(ctx, branch, reason, message, cache) {
258
228
  this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);
259
- return [new types_1.Violation(1, this.truncate(ctx.command), message)];
229
+ // Deliver the matrix and name the row, the same way an L0 block does. The doc is written
230
+ // LAZILY here rather than up front: only a blocked agent needs it, and this is the one path
231
+ // that knows the row it should be opened at.
232
+ const row = (0, decision_log_1.matrixL2Row)(reason).row;
233
+ const pointer = (0, l2_matrix_doc_1.branchStateMatrixPointer)((0, l2_matrix_doc_1.writeBranchStateMatrixDoc)(ctx.workspaceRoot), row);
234
+ return [new types_1.Violation(1, this.truncate(ctx.command), message + pointer)];
260
235
  }
261
236
  truncate(s) {
262
237
  const MAX = 120;
@@ -1 +1 @@
1
- {"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\n\nimport {\n BranchStateGuardConfig,\n BRANCH_STATE_GUARD_KEY,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\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 { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { StaleMainMessage } from './stale-main-message';\nimport { ContentReadScan } from './content-read-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitchScan } from './branch-switch-scan';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, 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 * ── REACTIVE: content-reading Bash on a stale `main` ─────────────────────────────────────────────\n *\n * read-stale-guard blocks the Read tool when local `main` is behind origin/main — but it looks at\n * nothing else, deliberately: \"every cure is a Bash command, so Bash is the escape hatch — never\n * wedge it.\" That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the\n * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069\n * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session\n * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI\n * workflow set that was missing a 186-line workflow which existed upstream. The logs read\n * \"read-stale-guard handled\", which is worse than no guard: it looks covered.\n *\n * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,\n * `git pull`, git METADATA (log/diff/show/status) — all still run. What is blocked is a command that\n * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a\n * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same\n * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is\n * one command away and there is no reason to stop anything else.\n *\n * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the\n * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.\n *\n * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:\n * - branch undeterminable / not on `main` / no cache / cache for another branch → allow\n * - `originMain` unknown (offline) → allow\n * - origin/main already an ancestor of HEAD (ancestry, NOT equality) → allow the instant the pull lands\n * - DIRTY tree → allow: the pull is not a clean fast-forward, and resolving that means reading the\n * very files in conflict. Never trap the agent away from its own rescue.\n * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow\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\n readonly description =\n 'Block a bare `git checkout main` (chain the pull into the same command), and block ' +\n 'content-reading Bash (cat/grep/ls/…) while local main is behind origin/main — so a session ' +\n 'neither lands on a stale main nor reasons over one through the side door the Read block leaves.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or reading files while main is behind origin/main, both give you stale content.',\n 'Pair the checkout with the pull, or update main and re-run:',\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('NOT blocked: `git checkout <sha>`, `git checkout -b <x> origin/main`, `git checkout -- <file>`, any other branch. Also still allowed: builds, tests, installs, the pull itself, all git/gh METADATA (status|log|diff|show|branch), every Write/Edit, 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 const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable — we have nothing to be stale RELATIVE TO.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // Ancestry, not equality: the moment the pull lands (or we are simply ahead), we are current.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // A dirty tree means the pull is not a clean fast-forward. Do not cut the agent off from the\n // files it must read to resolve that — the same valve read-stale-guard opens.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.failOpen(ctx, branch, 'dirty-tree-on-main', cache);\n }\n\n const reader = this.staleContentRead(ctx);\n if (reader === null) return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)', cache);\n\n return this.block(ctx, branch, `stale-main content read (${reader})`, this.staleMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n // BranchSwitchScan answers \"which branch does this land on\" for both guards, flag-tolerantly:\n // `git checkout -q main` lands on main exactly as the bare form does, while\n // `git checkout -b x origin/main` (creates), `git checkout -- main` (pathspec) and\n // `git checkout <sha>` do not. See branch-switch-scan.ts for why that lives in one place.\n if (!this.switches.landsOnExistingMain(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n 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 // The first segment that would read stale workspace content, or null when none does. The RAW\n // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted\n // prose can only ever block LESS (see BashContext.commandCode).\n private staleContentRead(ctx: BashContext): string | null {\n const scan = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {\n const hit = scan.readsStaleContent(segment);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n // Is `commit` already contained in HEAD? Exit code IS the answer, so spawnSync: 0 = ancestor,\n // 1 = genuinely behind, anything else = git could not tell → fail OPEN. (Mirrors read-stale-guard.)\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true;\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return true; // cannot tell → assume dirty, the fail-OPEN direction here\n }\n }\n\n private staleMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forBash(this.behindCount(workspaceRoot));\n }\n\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;\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 return [new V(1, this.truncate(ctx.command), message)];\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`, `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"]}