@webpieces/ai-hook-rules 0.4.696 → 0.4.698

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/command-scan.d.ts +21 -9
  3. package/src/core/command-scan.js +18 -16
  4. package/src/core/command-scan.js.map +1 -1
  5. package/src/core/l2-doc.js +27 -2
  6. package/src/core/l2-doc.js.map +1 -1
  7. package/src/core/l2-rows.d.ts +1 -1
  8. package/src/core/l2-rows.js +18 -4
  9. package/src/core/l2-rows.js.map +1 -1
  10. package/src/core/read-only-inspection.js +1 -1
  11. package/src/core/read-only-inspection.js.map +1 -1
  12. package/src/core/rules/build-output-pipe-scan.js +2 -2
  13. package/src/core/rules/build-output-pipe-scan.js.map +1 -1
  14. package/src/core/rules/content-read-scan.js +1 -1
  15. package/src/core/rules/content-read-scan.js.map +1 -1
  16. package/src/core/rules/cure-prefix-scan.d.ts +64 -0
  17. package/src/core/rules/cure-prefix-scan.js +133 -0
  18. package/src/core/rules/cure-prefix-scan.js.map +1 -0
  19. package/src/core/rules/read-stale-guard.d.ts +5 -4
  20. package/src/core/rules/read-stale-guard.js +9 -8
  21. package/src/core/rules/read-stale-guard.js.map +1 -1
  22. package/src/core/rules/recovery-allowlist.js +1 -1
  23. package/src/core/rules/recovery-allowlist.js.map +1 -1
  24. package/src/core/rules/shell-segment-scan.js +1 -1
  25. package/src/core/rules/shell-segment-scan.js.map +1 -1
  26. package/src/core/rules/stale-main-bash-guard.d.ts +28 -0
  27. package/src/core/rules/stale-main-bash-guard.js +45 -1
  28. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  29. package/src/core/rules/stale-main-message.d.ts +19 -8
  30. package/src/core/rules/stale-main-message.js +25 -13
  31. package/src/core/rules/stale-main-message.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.696",
3
+ "version": "0.4.698",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.696"
28
+ "@webpieces/rules-config": "0.4.698"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"
@@ -16,17 +16,28 @@
16
16
  * not the token `merge`). No lookahead regex needed.
17
17
  */
18
18
  /**
19
- * One invoked segment of a command, plus whether a PIPE fed it.
19
+ * The shell operator that JOINED one segment to the one before it. `'none'` for the first segment.
20
20
  *
21
- * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe reads no file) from
21
+ * This used to be a single boolean, `pipedInto`, and the widening is what makes composition
22
+ * judgeable. A pipe is only one of the joins whose MEANING differs: `cure && work` short-circuits, so
23
+ * the work cannot run when the cure failed, while `cure ; work` discards the cure's exit code and
24
+ * runs the work anyway. A boolean that answered only "was it a pipe?" threw that distinction away,
25
+ * and a guard cannot refuse the dangerous join while allowing the safe one without it.
26
+ */
27
+ export type SegmentJoin = 'none' | '|' | '&&' | '||' | ';' | '&' | '\n' | '(' | ')';
28
+ /**
29
+ * One invoked segment of a command, plus the operator that joined it to its predecessor.
30
+ *
31
+ * `join === '|'` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from
22
32
  * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command
23
33
  * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly
24
- * that fact away. Data-only, so a class (per CLAUDE.md).
34
+ * that fact away. `join === '&&'` vs `';'` is the same argument one operator over — see
35
+ * {@link SegmentJoin}. Data-only, so a class (per CLAUDE.md).
25
36
  */
26
37
  export declare class CommandSegment {
27
38
  text: string;
28
- pipedInto: boolean;
29
- constructor(text: string, pipedInto: boolean);
39
+ join: SegmentJoin;
40
+ constructor(text: string, join: SegmentJoin);
30
41
  }
31
42
  export declare class CommandScanner {
32
43
  /**
@@ -42,11 +53,12 @@ export declare class CommandScanner {
42
53
  */
43
54
  commandSegments(command: string): readonly string[];
44
55
  /**
45
- * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.
46
- * Only a guard reasoning about which files a segment reads needs that; everything else uses
47
- * commandSegments, which is this method with the flag dropped.
56
+ * commandSegments, but each segment also carries the OPERATOR that joined it to its predecessor.
57
+ * Only a guard reasoning about which files a segment reads, or about whether a failed first
58
+ * segment can stop the rest, needs that; everything else uses commandSegments, which is this
59
+ * method with the join dropped.
48
60
  */
49
- segmentsWithPipes(command: string): readonly CommandSegment[];
61
+ segmentsWithJoins(command: string): readonly CommandSegment[];
50
62
  /**
51
63
  * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`
52
64
  * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that
@@ -31,19 +31,20 @@ const RUNNERS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);
31
31
  // The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.
32
32
  const RUNNER_VERBS = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);
33
33
  /**
34
- * One invoked segment of a command, plus whether a PIPE fed it.
34
+ * One invoked segment of a command, plus the operator that joined it to its predecessor.
35
35
  *
36
- * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from
36
+ * `join === '|'` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from
37
37
  * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command
38
38
  * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly
39
- * that fact away. Data-only, so a class (per CLAUDE.md).
39
+ * that fact away. `join === '&&'` vs `';'` is the same argument one operator over — see
40
+ * {@link SegmentJoin}. Data-only, so a class (per CLAUDE.md).
40
41
  */
41
42
  class CommandSegment {
42
43
  text;
43
- pipedInto;
44
- constructor(text, pipedInto) {
44
+ join;
45
+ constructor(text, join) {
45
46
  this.text = text;
46
- this.pipedInto = pipedInto;
47
+ this.join = join;
47
48
  }
48
49
  }
49
50
  exports.CommandSegment = CommandSegment;
@@ -60,18 +61,19 @@ class CommandScanner {
60
61
  * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)
61
62
  */
62
63
  commandSegments(command) {
63
- return this.segmentsWithPipes(command).map((s) => s.text);
64
+ return this.segmentsWithJoins(command).map((s) => s.text);
64
65
  }
65
66
  /**
66
- * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.
67
- * Only a guard reasoning about which files a segment reads needs that; everything else uses
68
- * commandSegments, which is this method with the flag dropped.
67
+ * commandSegments, but each segment also carries the OPERATOR that joined it to its predecessor.
68
+ * Only a guard reasoning about which files a segment reads, or about whether a failed first
69
+ * segment can stop the rest, needs that; everything else uses commandSegments, which is this
70
+ * method with the join dropped.
69
71
  */
70
- segmentsWithPipes(command) {
72
+ segmentsWithJoins(command) {
71
73
  const segments = [];
72
74
  let current = '';
73
75
  let quote = null;
74
- let piped = false; // was the separator that ENDED the previous segment a pipe?
76
+ let join = 'none'; // the operator that ENDED the previous segment
75
77
  for (let i = 0; i < command.length; i++) {
76
78
  const ch = command[i];
77
79
  if (quote !== null) {
@@ -100,17 +102,17 @@ class CommandScanner {
100
102
  const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;
101
103
  if (doubled)
102
104
  i++;
103
- segments.push(new CommandSegment(current, piped));
105
+ segments.push(new CommandSegment(current, join));
104
106
  // `|` pipes into the next segment; `||` is a separator, not a pipe.
105
- piped = ch === '|' && !doubled;
107
+ join = doubled ? (ch + ch) : ch;
106
108
  current = '';
107
109
  continue;
108
110
  }
109
111
  current += ch;
110
112
  }
111
- segments.push(new CommandSegment(current, piped));
113
+ segments.push(new CommandSegment(current, join));
112
114
  return segments
113
- .map((s) => new CommandSegment(s.text.trim(), s.pipedInto))
115
+ .map((s) => new CommandSegment(s.text.trim(), s.join))
114
116
  .filter((s) => s.text.length > 0);
115
117
  }
116
118
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\n// Package-manager wrappers that precede the real program (`pnpm exec vitest`, `npx nx`, `yarn build-all`).\nconst RUNNERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);\n\n// The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.\nconst RUNNER_VERBS: ReadonlySet<string> = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);\n\n/**\n * One invoked segment of a command, plus whether a PIPE fed it.\n *\n * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from\n * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command\n * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly\n * that fact away. Data-only, so a class (per CLAUDE.md).\n */\nexport class CommandSegment {\n text: string;\n pipedInto: boolean;\n\n constructor(text: string, pipedInto: boolean) {\n this.text = text;\n this.pipedInto = pipedInto;\n }\n}\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n return this.segmentsWithPipes(command).map((s: CommandSegment): string => s.text);\n }\n\n /**\n * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.\n * Only a guard reasoning about which files a segment reads needs that; everything else uses\n * commandSegments, which is this method with the flag dropped.\n */\n segmentsWithPipes(command: string): readonly CommandSegment[] {\n const segments: CommandSegment[] = [];\n let current = '';\n let quote: string | null = null;\n let piped = false; // was the separator that ENDED the previous segment a pipe?\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n // The `&` of a REDIRECTION (`2>&1`, `1>&2`, `&>log`) is not a separator. Splitting on it\n // tore `git fetch origin main 2>&1 | tail -5` into THREE segments — `git fetch … 2>`, `1`\n // and `tail -5` — and the bare `1` is not an allowlisted command, so every guard that\n // requires all segments to pass denied the command. That is `2>&1`, the single most common\n // decoration an agent appends.\n if (ch === '&' && command[i + 1] !== '&' && (current.trimEnd().endsWith('>') || command[i + 1] === '>')) {\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;\n if (doubled) i++;\n segments.push(new CommandSegment(current, piped));\n // `|` pipes into the next segment; `||` is a separator, not a pipe.\n piped = ch === '|' && !doubled;\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(new CommandSegment(current, piped));\n\n return segments\n .map((s: CommandSegment): CommandSegment => new CommandSegment(s.text.trim(), s.pipedInto))\n .filter((s: CommandSegment): boolean => s.text.length > 0);\n }\n\n /**\n * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`\n * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that\n * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs\n * the tokens, and re-splitting on whitespace in the guard would get quoting wrong.\n */\n words(segment: string): readonly string[] {\n return this.stripPrefixes(this.tokenize(segment));\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n return this.gitSubcommandOf(this.stripPrefixes(this.tokenize(segment)));\n }\n\n /**\n * gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan\n * strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the\n * raw segment text where `do` is the command).\n */\n gitSubcommandOf(words: readonly string[]): string | null {\n const tokens = this.stripPrefixes(words);\n const at = this.gitSubcommandIndex(tokens);\n return at === -1 ? null : tokens[at];\n }\n\n /**\n * The ARGUMENTS following `git <subcommand>` in this segment, or null when the segment does not\n * invoke that subcommand.\n *\n * `gitSubcommand` answers *which* subcommand; a guard that judges the subcommand's own flags needs\n * the tokens after it, and slicing them in the guard would mean re-deriving where the subcommand\n * sits — i.e. re-deriving the `sudo` / `env VAR=x` / `-C <path>` skipping this class exists to own.\n * `git -C /x commit -m \"msg\"` yields `['-m', 'msg']`, never `['/x', 'commit', '-m', 'msg']`.\n *\n * The tokens are QUOTE-STRIPPED (see tokenize), so a quoted argument arrives as ONE token holding\n * its literal text — newlines, backticks and all. That is what makes an argument's CONTENT\n * inspectable at all.\n */\n gitSubcommandArgs(segment: string, subcommand: string): readonly string[] | null {\n const tokens = this.stripPrefixes(this.tokenize(segment));\n const at = this.gitSubcommandIndex(tokens);\n if (at === -1 || tokens[at] !== subcommand) return null;\n return tokens.slice(at + 1);\n }\n\n /** Index of the subcommand token in already-prefix-stripped words, or -1 when git is not invoked. */\n private gitSubcommandIndex(tokens: readonly string[]): number {\n if (tokens.length === 0 || tokens[0] !== 'git') return -1;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return i;\n }\n return -1;\n }\n\n /**\n * The segment's words with the package-manager WRAPPER stripped, so `pnpm exec vitest run` and\n * `vitest run` reduce to the same words. `pnpm --silent run build-all` → `['build-all']`.\n *\n * Lives HERE rather than in a guard because two guards now need it and they must agree: one blocks a\n * whole-repo build, the other blocks piping a build's output. If they disagreed about whether\n * `npx wp-build` is `wp-build`, one of them would have a spelling-shaped side door — which is exactly\n * the failure mode the runner stripping exists to close.\n */\n runnerStrippedWords(segment: string): readonly string[] {\n let words = this.words(segment);\n while (words.length > 0 && RUNNERS.has(this.programName(words[0]))) {\n words = words.slice(1);\n // The runner's own flags (`--silent`, `-r`) sit between it and the program.\n while (words.length > 0 && words[0].startsWith('-')) words = words.slice(1);\n if (words.length > 0 && RUNNER_VERBS.has(words[0])) words = words.slice(1);\n }\n return words;\n }\n\n /** `./node_modules/.bin/nx` and `/usr/local/bin/pnpm.cmd` are the programs `nx` and `pnpm`. */\n programName(token: string): string {\n const base = token.split('/').pop() ?? token;\n return base.endsWith('.cmd') ? base.slice(0, -'.cmd'.length) : base;\n }\n\n /** True when a package-manager runner precedes the program (`pnpm …`, `npx …`). */\n viaRunner(segment: string): boolean {\n const raw = this.words(segment);\n return raw.length > 0 && RUNNERS.has(this.programName(raw[0]));\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
1
+ {"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\n// Package-manager wrappers that precede the real program (`pnpm exec vitest`, `npx nx`, `yarn build-all`).\nconst RUNNERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);\n\n// The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.\nconst RUNNER_VERBS: ReadonlySet<string> = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);\n\n/**\n * The shell operator that JOINED one segment to the one before it. `'none'` for the first segment.\n *\n * This used to be a single boolean, `pipedInto`, and the widening is what makes composition\n * judgeable. A pipe is only one of the joins whose MEANING differs: `cure && work` short-circuits, so\n * the work cannot run when the cure failed, while `cure ; work` discards the cure's exit code and\n * runs the work anyway. A boolean that answered only \"was it a pipe?\" threw that distinction away,\n * and a guard cannot refuse the dangerous join while allowing the safe one without it.\n */\nexport type SegmentJoin = 'none' | '|' | '&&' | '||' | ';' | '&' | '\\n' | '(' | ')';\n\n/**\n * One invoked segment of a command, plus the operator that joined it to its predecessor.\n *\n * `join === '|'` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from\n * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command\n * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly\n * that fact away. `join === '&&'` vs `';'` is the same argument one operator over — see\n * {@link SegmentJoin}. Data-only, so a class (per CLAUDE.md).\n */\nexport class CommandSegment {\n text: string;\n join: SegmentJoin;\n\n constructor(text: string, join: SegmentJoin) {\n this.text = text;\n this.join = join;\n }\n}\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n return this.segmentsWithJoins(command).map((s: CommandSegment): string => s.text);\n }\n\n /**\n * commandSegments, but each segment also carries the OPERATOR that joined it to its predecessor.\n * Only a guard reasoning about which files a segment reads, or about whether a failed first\n * segment can stop the rest, needs that; everything else uses commandSegments, which is this\n * method with the join dropped.\n */\n segmentsWithJoins(command: string): readonly CommandSegment[] {\n const segments: CommandSegment[] = [];\n let current = '';\n let quote: string | null = null;\n let join: SegmentJoin = 'none'; // the operator that ENDED the previous segment\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n // The `&` of a REDIRECTION (`2>&1`, `1>&2`, `&>log`) is not a separator. Splitting on it\n // tore `git fetch origin main 2>&1 | tail -5` into THREE segments — `git fetch … 2>`, `1`\n // and `tail -5` — and the bare `1` is not an allowlisted command, so every guard that\n // requires all segments to pass denied the command. That is `2>&1`, the single most common\n // decoration an agent appends.\n if (ch === '&' && command[i + 1] !== '&' && (current.trimEnd().endsWith('>') || command[i + 1] === '>')) {\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;\n if (doubled) i++;\n segments.push(new CommandSegment(current, join));\n // `|` pipes into the next segment; `||` is a separator, not a pipe.\n join = doubled ? ((ch + ch) as SegmentJoin) : (ch as SegmentJoin);\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(new CommandSegment(current, join));\n\n return segments\n .map((s: CommandSegment): CommandSegment => new CommandSegment(s.text.trim(), s.join))\n .filter((s: CommandSegment): boolean => s.text.length > 0);\n }\n\n /**\n * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`\n * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that\n * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs\n * the tokens, and re-splitting on whitespace in the guard would get quoting wrong.\n */\n words(segment: string): readonly string[] {\n return this.stripPrefixes(this.tokenize(segment));\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n return this.gitSubcommandOf(this.stripPrefixes(this.tokenize(segment)));\n }\n\n /**\n * gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan\n * strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the\n * raw segment text where `do` is the command).\n */\n gitSubcommandOf(words: readonly string[]): string | null {\n const tokens = this.stripPrefixes(words);\n const at = this.gitSubcommandIndex(tokens);\n return at === -1 ? null : tokens[at];\n }\n\n /**\n * The ARGUMENTS following `git <subcommand>` in this segment, or null when the segment does not\n * invoke that subcommand.\n *\n * `gitSubcommand` answers *which* subcommand; a guard that judges the subcommand's own flags needs\n * the tokens after it, and slicing them in the guard would mean re-deriving where the subcommand\n * sits — i.e. re-deriving the `sudo` / `env VAR=x` / `-C <path>` skipping this class exists to own.\n * `git -C /x commit -m \"msg\"` yields `['-m', 'msg']`, never `['/x', 'commit', '-m', 'msg']`.\n *\n * The tokens are QUOTE-STRIPPED (see tokenize), so a quoted argument arrives as ONE token holding\n * its literal text — newlines, backticks and all. That is what makes an argument's CONTENT\n * inspectable at all.\n */\n gitSubcommandArgs(segment: string, subcommand: string): readonly string[] | null {\n const tokens = this.stripPrefixes(this.tokenize(segment));\n const at = this.gitSubcommandIndex(tokens);\n if (at === -1 || tokens[at] !== subcommand) return null;\n return tokens.slice(at + 1);\n }\n\n /** Index of the subcommand token in already-prefix-stripped words, or -1 when git is not invoked. */\n private gitSubcommandIndex(tokens: readonly string[]): number {\n if (tokens.length === 0 || tokens[0] !== 'git') return -1;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return i;\n }\n return -1;\n }\n\n /**\n * The segment's words with the package-manager WRAPPER stripped, so `pnpm exec vitest run` and\n * `vitest run` reduce to the same words. `pnpm --silent run build-all` → `['build-all']`.\n *\n * Lives HERE rather than in a guard because two guards now need it and they must agree: one blocks a\n * whole-repo build, the other blocks piping a build's output. If they disagreed about whether\n * `npx wp-build` is `wp-build`, one of them would have a spelling-shaped side door — which is exactly\n * the failure mode the runner stripping exists to close.\n */\n runnerStrippedWords(segment: string): readonly string[] {\n let words = this.words(segment);\n while (words.length > 0 && RUNNERS.has(this.programName(words[0]))) {\n words = words.slice(1);\n // The runner's own flags (`--silent`, `-r`) sit between it and the program.\n while (words.length > 0 && words[0].startsWith('-')) words = words.slice(1);\n if (words.length > 0 && RUNNER_VERBS.has(words[0])) words = words.slice(1);\n }\n return words;\n }\n\n /** `./node_modules/.bin/nx` and `/usr/local/bin/pnpm.cmd` are the programs `nx` and `pnpm`. */\n programName(token: string): string {\n const base = token.split('/').pop() ?? token;\n return base.endsWith('.cmd') ? base.slice(0, -'.cmd'.length) : base;\n }\n\n /** True when a package-manager runner precedes the program (`pnpm …`, `npx …`). */\n viaRunner(segment: string): boolean {\n const raw = this.words(segment);\n return raw.length > 0 && RUNNERS.has(this.programName(raw[0]));\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
@@ -75,6 +75,7 @@ function renderL2Doc() {
75
75
  return [
76
76
  ...renderHead(),
77
77
  ...renderTable(),
78
+ ...renderTableNotes(),
78
79
  ...renderUseCases(),
79
80
  ...renderNotes(),
80
81
  ...renderTail(),
@@ -170,6 +171,13 @@ function renderTable() {
170
171
  'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',
171
172
  'would silently re-point every reference. L1 does the same with its row 8.',
172
173
  '',
174
+ ];
175
+ }
176
+ // The prose that explains the table's shape — split out of renderTable purely to stay inside the
177
+ // method-line budget. The join order is what makes them one section; keep them adjacent.
178
+ // webpieces-disable no-function-outside-class -- continuation of renderTable above, beside it in this module
179
+ function renderTableNotes() {
180
+ return [
173
181
  '### The one rule that explains the tool column',
174
182
  '',
175
183
  '**`B` tracks `E` everywhere except on `main`, where `B` tracks `R` instead — rows 5, 6 and 7.**',
@@ -212,13 +220,30 @@ function renderTable() {
212
220
  'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',
213
221
  'clears it.',
214
222
  '',
215
- 'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure is `git pull`,',
216
- 'which genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',
223
+ 'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure pulls, and a',
224
+ 'pull genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',
217
225
  'cure — `git checkout -b <new> origin/main` — and that one works dirty for exactly the reason above.',
218
226
  'The teeth were in the MESSAGE, which printed only the pull; it now prints both, labelled, so the',
219
227
  'cure an agent reads is always one it can run. **So there is no dirty row anywhere, and no dirty',
220
228
  'valve in the code either** — both were closed, and "Not done" is empty as a result.',
221
229
  '',
230
+ '### Rows 12 and 13 — the cure may be COMPOSED with the work, but only with `&&`',
231
+ '',
232
+ '`pnpm wp-checkout-clean-main && cat src/app.ts` is ALLOWED. `pnpm wp-checkout-clean-main ; cat',
233
+ 'src/app.ts` is REFUSED, and the refusal names the operator you typed.',
234
+ '',
235
+ 'The difference belongs to the shell, not to this guard. `&&` short-circuits: the work cannot run',
236
+ 'when the cure exits non-zero, which is exactly the property the row 6 block exists to guarantee.',
237
+ 'Refusing that bought nothing and cost a round trip. `;` discards the cure\'s exit code and runs the',
238
+ 'work regardless — measured with `>/dev/null 2>&1` on the cure in 7 of 9 observed cases, so the',
239
+ 'failure was invisible as well as ignored. The two-step is genuinely safer there, because the NEXT',
240
+ 'tool call is a fresh evaluation that recomputes `localMain` against `originMain`: a failed pull',
241
+ 're-blocks. An allowed `;` compound never gets that second look.',
242
+ '',
243
+ '`git fetch` may LEAD the prefix (`git fetch --prune origin main && git pull --ff-only origin main`',
244
+ 'is the shape agents type) but never satisfies it alone: a fetch moves the remote-tracking ref and',
245
+ 'leaves local `main` exactly as far behind, so there is nothing for the `&&` to protect.',
246
+ '',
222
247
  ];
223
248
  }
224
249
  // The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.
@@ -1 +1 @@
1
- {"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L2Row, L2NotDone, L2UseCase, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE, allL2UseCases } from './l2-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L2-branch-state.md, rendered from L2_ROWS.\n//\n// Same arrangement as l1-doc.renderL1Doc(): one join('\\n') of literal markdown lines with the ROW DATA\n// interpolated from the array. Everything that is not row data is a literal line here, because that is\n// the half a generator cannot own.\n//\n// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and\n// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and\n// carried its own warning that it could drift; it did, in three places at once (it proposed\n// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and\n// docs/plans/guard-layer-toggles.md proposed a third).\n//\n// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it\n// without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction tableRow(row: L2Row): string {\n return `| ${row.num} | \\`${row.toolCell()}\\` | ${row.state} | ${row.action.label} | ${row.cure} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction notDoneRow(entry: L2NotDone): string {\n return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;\n}\n\n/**\n * The \"Not done\" body — a table when there are gaps, a SENTENCE when there are none.\n *\n * An empty table renders as a bare header with nothing under it, which reads like a rendering bug\n * rather than like an achievement. \"Every row is honoured\" is a claim worth making in words, and it is\n * the state this section exists to drive the layer towards.\n */\n// webpieces-disable no-function-outside-class -- section builder for renderL2Doc below, in this render module\nfunction renderNotDoneBody(): string[] {\n if (NOT_DONE.length === 0) {\n return [\n '**Nothing. Every row in the table above is a row the guards actually honour today.**',\n '',\n 'That has not always been true, and the section stays here for when it stops being true again:',\n 'a row the code cannot yet honour is listed here rather than rendered as if it were live, the',\n 'same way L1 lists its unreachable `o` row. The three entries this section used to carry were',\n 'row 5\\'s Bash half (shipped, then moved: Bash on `main` is judged on FRESHNESS at rows 6/7, while',\n 'row 5 keeps the unconditional WRITE block) and the DIRTY-TREE',\n 'valves on rows 6 and 8 — both closed, because each of those rows cures with',\n '`git checkout -b <new> origin/main`, which carries uncommitted changes onto the new branch. A',\n 'dirty tree never trapped anyone; the row 6 message just printed the one cure that could not run',\n 'dirty, and the fix was to print both.',\n ];\n }\n return [\n 'Each row below describes INTENT the code has not caught up with. They are listed rather than',\n 'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',\n `them currently exits at row ${L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,\n '',\n '| row | the gap | why it has not shipped |',\n '|---|---|---|',\n ...NOT_DONE.map(notDoneRow),\n ];\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction useCaseRow(useCase: L2UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L2-branch-state.md.\n *\n * Split into sections purely to stay inside the method-line budget — the join order is what makes them\n * one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads\nexport function renderL2Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderUseCases(),\n ...renderNotes(),\n ...renderTail(),\n ].join('\\n');\n}\n\n// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L2 — branch state',\n '',\n '**Goal: may I work here, and is what I read current?**',\n '',\n '**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',\n '`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',\n 'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',\n 'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is \"read the',\n 'file, yes; `cat` the same file, no\" — the same information, opposite verdicts, chosen by nobody.',\n 'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',\n 'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',\n 'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',\n '',\n '**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',\n 'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',\n '`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',\n '`ai-hook-rules/src/core/sync-main.ts`.',\n '',\n '## The four classes, and why there are four',\n '',\n '| | Write/Edit | Read | Bash |',\n '|---|---|---|---|',\n '| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',\n '| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',\n '',\n 'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',\n 'Write is neither.',\n '',\n '**The two Bash guards used to differ in polarity, and no longer do.** merged-branch was',\n 'default-DENY + allowlist; stale-main was default-ALLOW + blocklist of content readers, so',\n '`pnpm build` was denied by one and allowed by the other for the same reason — \"you should not be',\n 'working in this tree\". A blocklist of readers structurally cannot catch an installer, a formatter',\n 'or a codegen step, so both states now use default-DENY plus one shared `RecoveryAllowlist` (the',\n 'row 4 skip list). Two skip lists drift, and the half that drifts is the half that wedges a session',\n 'on its own cure.',\n '',\n '**What is gated is WHEN that polarity applies, not the polarity.** stale-main asks the cache',\n 'whether local `main` is BEHIND (rows 6/7) and default-denies only then; a current `main` — which is',\n 'exactly where `pnpm wp-checkout-clean-main` leaves you — is not this guard\\'s business at all.',\n '',\n 'They remain separate CLASSES because the states they detect are different — one reads the branch',\n 'name, the other the cached merged flag — and because each carries its own message.',\n '',\n '## The cache',\n '',\n '`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',\n 'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',\n 'one — so the first tool call of every session sees no cache and takes row ' + String(L2_FAIL_OPEN_ROW) + '. That is intended,',\n 'and it is why the on-main write block (row 5) must not depend on the cache.',\n '',\n 'The file holds a **map of branch → status**, so every worktree\\'s guards stay armed. Before that it',\n 'held one branch\\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',\n 'rest abstained, thrashing as the lock changed hands.',\n '',\n '**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',\n 'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',\n '--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',\n 'merged flag, which is safe only because \"merged\" is monotonic.',\n '',\n '**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',\n 'one refresher writing one cache. It used to be declared four times and read four times, which,',\n 'with the refresher\\'s at-most-once-per-process latch and two config-blind callers ahead of the',\n 'guards, meant at most one of the four values could ever reach a spawn.',\n '',\n ];\n}\n\n// The legend and the table itself — this is the ROW DATA.\n// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Table — one table, ordered, first match wins',\n '',\n '**Tools:** `B` Bash · `R` Read · `E` Write/Edit',\n '',\n '| # | tools | state | act | cure |',\n '|---|---|---|---|---|',\n ...L2_ROWS.map(tableRow),\n '',\n `Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,\n `marker-file scan, and row 5 is one \\`git rev-parse\\`. Row ${L2_FAIL_OPEN_ROW} is the divider: everything below it`,\n `reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,\n `another branch, or the forge could not be reached, evaluation STOPS at row ${L2_FAIL_OPEN_ROW} and ALLOWS.`,\n '',\n `Row ${L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,\n 'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',\n 'would silently re-point every reference. L1 does the same with its row 8.',\n '',\n '### The one rule that explains the tool column',\n '',\n '**`B` tracks `E` everywhere except on `main`, where `B` tracks `R` instead — rows 5, 6 and 7.**',\n '',\n 'The hazards differ. A WRITE on `main` lands work somewhere unreviewable and unrevertable at ANY',\n 'freshness, so row 5 is `E` only and is judged from the branch alone, above the cache divider. A',\n 'READ or a BUILD on a CURRENT `main` harms nothing, and blocking it strands the agent immediately',\n 'after `pnpm wp-checkout-clean-main` — the command this repo prescribes — put it there. So `B` joins',\n '`R` on the freshness-gated pair: row 6 (behind) blocks, row 7 (current) allows, and \"cannot tell\"',\n 'fails open at row ' + String(L2_FAIL_OPEN_ROW) + ' by construction.',\n '',\n 'Inside row 6 they still differ in SHAPE: a Read names exactly one file and is evaluated precisely;',\n 'a Bash command is opaque and gets the conservative answer, default-deny plus the row 4 skip list.',\n '',\n '### Why the order of row 5 is the most load-bearing thing here',\n '',\n 'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',\n 'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',\n 'practice, because the agent discovers the problem within a command or two.',\n '',\n `Row 5 is the exception that must not be relaxed. Put \"on \\`main\\`\" BELOW row ${L2_FAIL_OPEN_ROW} and WRITES on \\`main\\``,\n 'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',\n 'where another tree can hold the refresh lock indefinitely. That is why row 5 is `E` only: the Bash',\n 'half asks a question the cache CAN answer late without harm (\"is `main` behind?\"), so it belongs',\n 'below the divider, where not knowing means allowing.',\n '',\n '### Why row 9 can block reads without trapping you',\n '',\n 'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',\n 'read to resolve the conflict. It does not, because **row 3 comes first**:',\n '',\n 'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',\n 'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',\n 'strict.',\n '',\n '### Why row 8 can block reads on a dirty tree',\n '',\n '`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',\n 'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',\n 'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',\n 'clears it.',\n '',\n 'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure is `git pull`,',\n 'which genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',\n 'cure — `git checkout -b <new> origin/main` — and that one works dirty for exactly the reason above.',\n 'The teeth were in the MESSAGE, which printed only the pull; it now prints both, labelled, so the',\n 'cure an agent reads is always one it can run. **So there is no dirty row anywhere, and no dirty',\n 'valve in the code either** — both were closed, and \"Not done\" is empty as a result.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.\n// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L2 use cases',\n '',\n 'Same row shape as L0 and L1: the **Fix** is literal or it is not a fix. Each case is attached IN',\n 'CODE to the row that judges it (`useCases` on `L2Row`), so this table cannot describe a row that no',\n 'longer exists and a row cannot quietly acquire behaviour nothing documents.',\n '',\n '**This table is how the layer LEARNS.** When a new situation comes up in a session, the change is',\n 'one more `new L2UseCase(...)` on the row that judged it — not a paragraph added here, which the',\n 'byte-lock spec would reject anyway. Every case also carries the exact `reason` string the guard',\n 'logs, and a spec pushes that back through `l2RowForReason` to assert it lands on the row it is filed',\n 'under. So a case whose row is wrong fails the build rather than misinforming a reader.',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL2UseCases().map(useCaseRow),\n '',\n 'The incidental-write case under row 6 is the one to read first: `npx expo install` on `main`, in',\n 'another repo on this toolchain, modified two tracked files and no guard fired. It is filed under row',\n '6 rather than row 5 because that is the row that judges Bash on `main` today — a command\\'s stated',\n 'purpose never says whether it also writes, which is why the shape there is default-deny plus the row',\n '4 skip list rather than a blocklist of readers anybody could have enumerated.',\n '',\n ];\n}\n\n// How the log joins to this table, and the skip list. Prose plus the reason→row contract.\n// webpieces-disable no-function-outside-class -- fourth section of renderL2Doc's string, beside it in this module\nfunction renderNotes(): string[] {\n return [\n '## How a log line joins to a row',\n '',\n 'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',\n '`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means \"this call was judged',\n 'by row 8\" and you read the state, the verdict and the cure straight off this page.',\n '',\n '**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',\n 'matching row and switches on it, so deleting an L1 row deletes a block. L2\\'s four classes each own',\n 'their own ladder (see \"The four classes\" above for why they cannot be one function), and',\n '`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',\n 'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',\n 'with no row fails the build rather than logging `row=-` forever.',\n '',\n '## The skip list (row 4)',\n '',\n 'Principle: **these get you OUT or tell you where you are.** They are not \"working here\".',\n '',\n '| group | commands |',\n '|---|---|',\n '| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',\n '| make `main` current | `pnpm wp-checkout-clean-main` *(the prescribed form — also reaps dead branches/worktrees and sweeps orphan directories)* · `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only; still allowed, and still the L0 recovery cure, where no `pnpm` bin can be trusted)* |',\n '| orient | `git status\\\\|log\\\\|diff\\\\|branch` · `gh` generally (`pr view`, `pr close`, `pr comment`, `api`, `run watch` — it talks to GitHub, not to this tree) |',\n '| talk to the network | `curl` · `wget` — a URL is not this repo. NOT the forms that write a local file: `curl -o`, `wget -O`, `gh repo clone`, `gh pr checkout`, `gh run download`, or any `> file` redirect |',\n '| park work | `git stash` |',\n '| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',\n '',\n '**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',\n '`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',\n '`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',\n '',\n '## Cannot tell — everything that lands on row ' + String(L2_FAIL_OPEN_ROW),\n '',\n '| state | expected? | treatment |',\n '|---|---|---|',\n '| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',\n '| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',\n '| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',\n '| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',\n '| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',\n '',\n '**Do NOT block to capture these cases.** `cache-absent` fires on every session\\'s first call;',\n 'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',\n 'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',\n 'avoid.',\n '',\n '`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',\n 'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',\n '\"this branch has no merged PR\" and by \"we could not ask\", and both logged a plain ALLOW — so from',\n 'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',\n 'standing down.',\n '',\n ];\n}\n\n// The gaps between the table and the code, and the code anchors.\n// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — rows the guards do not yet honour',\n '',\n ...renderNotDoneBody(),\n '',\n 'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',\n 'and the doc follows — it cannot rot into a list of things that were fixed years ago.',\n '',\n '## Incidents these guards exist because of',\n '',\n '- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',\n ' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',\n ' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',\n ' agent\\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',\n ' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',\n ' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',\n ' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',\n '- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',\n ' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',\n ' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',\n ' existed upstream. *The logs read \"read-stale-guard handled\", which is worse than no guard: it',\n ' looks covered.*',\n '- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',\n ' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',\n ' loaded and logged on that very path.**',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',\n '| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',\n '| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',\n '| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `checkFreshness`, `bareCheckoutOfMain` |',\n '| the shared freshness predicate | `ai-hook-rules/src/core/rules/main-freshness.ts` | `containsOriginMain`, `summarize` |',\n '| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',\n '| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',\n '| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',\n '| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',\n '| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',\n '',\n ];\n}\n"]}
1
+ {"version":3,"file":"l2-doc.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-doc.ts"],"names":[],"mappings":"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","sourcesContent":["import { L2Row, L2NotDone, L2UseCase, L2_ROWS, L2_FAIL_OPEN_ROW, NOT_DONE, allL2UseCases } from './l2-rows';\n\n// ---------------------------------------------------------------------------\n// guards/L2-branch-state.md, rendered from L2_ROWS.\n//\n// Same arrangement as l1-doc.renderL1Doc(): one join('\\n') of literal markdown lines with the ROW DATA\n// interpolated from the array. Everything that is not row data is a literal line here, because that is\n// the half a generator cannot own.\n//\n// A unit test (l2-matrix.spec.ts) locks guards/L2-branch-state.md byte-identical to renderL2Doc(), and\n// `pnpm guards:generate` rewrites the file. The doc that stood here before was 100% hand-written and\n// carried its own warning that it could drift; it did, in three places at once (it proposed\n// `branch-state-guard` as a future key while GUARD_MATRIX.md proposed a different name and\n// docs/plans/guard-layer-toggles.md proposed a third).\n//\n// This module, like l2-rows.ts, has no runtime imports outside this pair so the generator can load it\n// without the package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction tableRow(row: L2Row): string {\n return `| ${row.num} | \\`${row.toolCell()}\\` | ${row.state} | ${row.action.label} | ${row.cure} |`;\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction notDoneRow(entry: L2NotDone): string {\n return `| ${entry.row} | ${entry.gap} | ${entry.why} |`;\n}\n\n/**\n * The \"Not done\" body — a table when there are gaps, a SENTENCE when there are none.\n *\n * An empty table renders as a bare header with nothing under it, which reads like a rendering bug\n * rather than like an achievement. \"Every row is honoured\" is a claim worth making in words, and it is\n * the state this section exists to drive the layer towards.\n */\n// webpieces-disable no-function-outside-class -- section builder for renderL2Doc below, in this render module\nfunction renderNotDoneBody(): string[] {\n if (NOT_DONE.length === 0) {\n return [\n '**Nothing. Every row in the table above is a row the guards actually honour today.**',\n '',\n 'That has not always been true, and the section stays here for when it stops being true again:',\n 'a row the code cannot yet honour is listed here rather than rendered as if it were live, the',\n 'same way L1 lists its unreachable `o` row. The three entries this section used to carry were',\n 'row 5\\'s Bash half (shipped, then moved: Bash on `main` is judged on FRESHNESS at rows 6/7, while',\n 'row 5 keeps the unconditional WRITE block) and the DIRTY-TREE',\n 'valves on rows 6 and 8 — both closed, because each of those rows cures with',\n '`git checkout -b <new> origin/main`, which carries uncommitted changes onto the new branch. A',\n 'dirty tree never trapped anyone; the row 6 message just printed the one cure that could not run',\n 'dirty, and the fix was to print both.',\n ];\n }\n return [\n 'Each row below describes INTENT the code has not caught up with. They are listed rather than',\n 'silently rendered as if they were live, the same way L1 lists its unreachable `o` row. Every one of',\n `them currently exits at row ${L2_FAIL_OPEN_ROW} instead, so the log never claims the strict row fired.`,\n '',\n '| row | the gap | why it has not shipped |',\n '|---|---|---|',\n ...NOT_DONE.map(notDoneRow),\n ];\n}\n\n// webpieces-disable no-function-outside-class -- pure row formatter for renderL2Doc below, in this render module\nfunction useCaseRow(useCase: L2UseCase): string {\n return `| ${useCase.num} | ${useCase.symptom} | ${useCase.state} | ${useCase.verdict} | ${useCase.fix} |`;\n}\n\n/**\n * Render guards/L2-branch-state.md.\n *\n * Split into sections purely to stay inside the method-line budget — the join order is what makes them\n * one file, so keep them adjacent and keep the byte-lock test as the arbiter.\n */\n// webpieces-disable no-function-outside-class -- pure string builder over L2_ROWS, beside the array it reads\nexport function renderL2Doc(): string {\n return [\n ...renderHead(),\n ...renderTable(),\n ...renderTableNotes(),\n ...renderUseCases(),\n ...renderNotes(),\n ...renderTail(),\n ].join('\\n');\n}\n\n// webpieces-disable no-function-outside-class -- first section of renderL2Doc's string, beside it in this module\nfunction renderHead(): string[] {\n return [\n '# L2 — branch state',\n '',\n '**Goal: may I work here, and is what I read current?**',\n '',\n '**Config key: `branch-state-guard`.** ONE key for the whole policy. It used to be FOUR —',\n '`feature-branch-guard`, `read-stale-guard`, `stale-main-bash-guard`, `merged-branch-bash-guard` —',\n 'and three of them carried nothing but `mode` plus the two escape hatches. Four keys made HALF a',\n 'policy representable: `read-stale-guard: OFF` beside `merged-branch-bash-guard: ON` is \"read the',\n 'file, yes; `cat` the same file, no\" — the same information, opposite verdicts, chosen by nobody.',\n 'One key makes that unconstructible. The four class NAMES are unchanged and still appear as `rule=`',\n 'on every decision-log line, so `grep rule=stale-main-bash-guard` keeps working; only the switch',\n 'merged. The four old keys are rejected by name with this destination — see `retired-config-keys.ts`.',\n '',\n '**Code:** `ai-hook-rules/src/core/rules/{feature-branch,read-stale,stale-main-bash,merged-branch-bash}-guard.ts` ·',\n 'the rows in `ai-hook-rules/src/core/l2-rows.ts` · the shared cache in',\n '`rules-config/src/main-sync-status.ts` + `main-sync-file.ts` · the refresher in',\n '`ai-hook-rules/src/core/sync-main.ts`.',\n '',\n '## The four classes, and why there are four',\n '',\n '| | Write/Edit | Read | Bash |',\n '|---|---|---|---|',\n '| **state A** — stale `main` | `feature-branch-guard` | `read-stale-guard` | `stale-main-bash-guard` |',\n '| **state B** — merged branch | `feature-branch-guard` | `read-stale-guard` | `merged-branch-bash-guard` |',\n '',\n 'The split is TOOL WIRING, not policy. A Read names exactly one file; a Bash command is opaque; a',\n 'Write is neither.',\n '',\n '**The two Bash guards used to differ in polarity, and no longer do.** merged-branch was',\n 'default-DENY + allowlist; stale-main was default-ALLOW + blocklist of content readers, so',\n '`pnpm build` was denied by one and allowed by the other for the same reason — \"you should not be',\n 'working in this tree\". A blocklist of readers structurally cannot catch an installer, a formatter',\n 'or a codegen step, so both states now use default-DENY plus one shared `RecoveryAllowlist` (the',\n 'row 4 skip list). Two skip lists drift, and the half that drifts is the half that wedges a session',\n 'on its own cure.',\n '',\n '**What is gated is WHEN that polarity applies, not the polarity.** stale-main asks the cache',\n 'whether local `main` is BEHIND (rows 6/7) and default-denies only then; a current `main` — which is',\n 'exactly where `pnpm wp-checkout-clean-main` leaves you — is not this guard\\'s business at all.',\n '',\n 'They remain separate CLASSES because the states they detect are different — one reads the branch',\n 'name, the other the cached merged flag — and because each carries its own message.',\n '',\n '## The cache',\n '',\n '`<primary clone>/.webpieces/main-sync-status.json`, written by a **detached single-flight',\n 'refresher**. It is fire-and-forget: it populates the cache for the NEXT call, never the current',\n 'one — so the first tool call of every session sees no cache and takes row ' + String(L2_FAIL_OPEN_ROW) + '. That is intended,',\n 'and it is why the on-main write block (row 5) must not depend on the cache.',\n '',\n 'The file holds a **map of branch → status**, so every worktree\\'s guards stay armed. Before that it',\n 'held one branch\\'s snapshot, so with N worktrees at most one tree was armed at any instant and the',\n 'rest abstained, thrashing as the lock changed hands.',\n '',\n '**There is no TTL.** `timestamp` is logged and never enforced; an hours-old cache whose branch',\n 'matches is trusted to block. State A is mitigated by a live ancestry check (`git merge-base',\n '--is-ancestor`, not hash equality, so a pull takes effect instantly); state B trusts the cached',\n 'merged flag, which is safe only because \"merged\" is monotonic.',\n '',\n '**`hangTimeoutMinutes` is ONE knob** — `branch-state-guard.hangTimeoutMinutes` — because there is',\n 'one refresher writing one cache. It used to be declared four times and read four times, which,',\n 'with the refresher\\'s at-most-once-per-process latch and two config-blind callers ahead of the',\n 'guards, meant at most one of the four values could ever reach a spawn.',\n '',\n ];\n}\n\n// The legend and the table itself — this is the ROW DATA.\n// webpieces-disable no-function-outside-class -- second section of renderL2Doc's string, beside it in this module\nfunction renderTable(): string[] {\n return [\n '## Table — one table, ordered, first match wins',\n '',\n '**Tools:** `B` Bash · `R` Read · `E` Write/Edit',\n '',\n '| # | tools | state | act | cure |',\n '|---|---|---|---|---|',\n ...L2_ROWS.map(tableRow),\n '',\n `Rows 1-5 need **no cache** and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a`,\n `marker-file scan, and row 5 is one \\`git rev-parse\\`. Row ${L2_FAIL_OPEN_ROW} is the divider: everything below it`,\n `reads the main-sync cache, so if the branch is undeterminable, the cache is absent, it holds`,\n `another branch, or the forge could not be reached, evaluation STOPS at row ${L2_FAIL_OPEN_ROW} and ALLOWS.`,\n '',\n `Row ${L2_FAIL_OPEN_ROW} is numbered after row 10 and PRINTED between 5 and 6, and that is not a mistake. Row`,\n 'numbers are identity — they are logged as `row=` and cited here — so renumbering 6-10 to slot it in',\n 'would silently re-point every reference. L1 does the same with its row 8.',\n '',\n ];\n}\n\n// The prose that explains the table's shape — split out of renderTable purely to stay inside the\n// method-line budget. The join order is what makes them one section; keep them adjacent.\n// webpieces-disable no-function-outside-class -- continuation of renderTable above, beside it in this module\nfunction renderTableNotes(): string[] {\n return [\n '### The one rule that explains the tool column',\n '',\n '**`B` tracks `E` everywhere except on `main`, where `B` tracks `R` instead — rows 5, 6 and 7.**',\n '',\n 'The hazards differ. A WRITE on `main` lands work somewhere unreviewable and unrevertable at ANY',\n 'freshness, so row 5 is `E` only and is judged from the branch alone, above the cache divider. A',\n 'READ or a BUILD on a CURRENT `main` harms nothing, and blocking it strands the agent immediately',\n 'after `pnpm wp-checkout-clean-main` — the command this repo prescribes — put it there. So `B` joins',\n '`R` on the freshness-gated pair: row 6 (behind) blocks, row 7 (current) allows, and \"cannot tell\"',\n 'fails open at row ' + String(L2_FAIL_OPEN_ROW) + ' by construction.',\n '',\n 'Inside row 6 they still differ in SHAPE: a Read names exactly one file and is evaluated precisely;',\n 'a Bash command is opaque and gets the conservative answer, default-deny plus the row 4 skip list.',\n '',\n '### Why the order of row 5 is the most load-bearing thing here',\n '',\n 'L2 is armed **from the second tool call onward**, because the refresher populates the cache for the',\n 'NEXT call. That is deliberate — it keeps the blocking path free of network git — and it is fine in',\n 'practice, because the agent discovers the problem within a command or two.',\n '',\n `Row 5 is the exception that must not be relaxed. Put \"on \\`main\\`\" BELOW row ${L2_FAIL_OPEN_ROW} and WRITES on \\`main\\``,\n 'are permitted for the whole first call of every session — and permanently in a multi-worktree repo,',\n 'where another tree can hold the refresh lock indefinitely. That is why row 5 is `E` only: the Bash',\n 'half asks a question the cache CAN answer late without harm (\"is `main` behind?\"), so it belongs',\n 'below the divider, where not knowing means allowing.',\n '',\n '### Why row 9 can block reads without trapping you',\n '',\n 'Blocking reads on a broken fork point looks like it traps the agent away from the files it must',\n 'read to resolve the conflict. It does not, because **row 3 comes first**:',\n '',\n 'blocked → `pnpm wp-start-update` (row 4, skip list) → now merge-in-progress → **row 3 exempts',\n 'everything** → read and write freely to resolve → finish. The exemption row is what lets row 9 be',\n 'strict.',\n '',\n '### Why row 8 can block reads on a dirty tree',\n '',\n '`git checkout -b <new> origin/main` **carries uncommitted changes onto the new branch**. The work',\n 'comes with you, so nothing needs reading first and nothing is trapped. Residual: if `origin/main`',\n 'changed the same files you edited, git refuses the switch — `git stash` is on the skip list and',\n 'clears it.',\n '',\n 'Row 6 looked like the one place the dirty argument had teeth, because its FIRST cure pulls, and a',\n 'pull genuinely is not a clean fast-forward on a dirty tree. But row 6 has always carried a SECOND',\n 'cure — `git checkout -b <new> origin/main` — and that one works dirty for exactly the reason above.',\n 'The teeth were in the MESSAGE, which printed only the pull; it now prints both, labelled, so the',\n 'cure an agent reads is always one it can run. **So there is no dirty row anywhere, and no dirty',\n 'valve in the code either** — both were closed, and \"Not done\" is empty as a result.',\n '',\n '### Rows 12 and 13 — the cure may be COMPOSED with the work, but only with `&&`',\n '',\n '`pnpm wp-checkout-clean-main && cat src/app.ts` is ALLOWED. `pnpm wp-checkout-clean-main ; cat',\n 'src/app.ts` is REFUSED, and the refusal names the operator you typed.',\n '',\n 'The difference belongs to the shell, not to this guard. `&&` short-circuits: the work cannot run',\n 'when the cure exits non-zero, which is exactly the property the row 6 block exists to guarantee.',\n 'Refusing that bought nothing and cost a round trip. `;` discards the cure\\'s exit code and runs the',\n 'work regardless — measured with `>/dev/null 2>&1` on the cure in 7 of 9 observed cases, so the',\n 'failure was invisible as well as ignored. The two-step is genuinely safer there, because the NEXT',\n 'tool call is a fresh evaluation that recomputes `localMain` against `originMain`: a failed pull',\n 're-blocks. An allowed `;` compound never gets that second look.',\n '',\n '`git fetch` may LEAD the prefix (`git fetch --prune origin main && git pull --ff-only origin main`',\n 'is the shape agents type) but never satisfies it alone: a fetch moves the remote-tracking ref and',\n 'leaves local `main` exactly as far behind, so there is nothing for the `&&` to protect.',\n '',\n ];\n}\n\n// The use-case table (ROW DATA, in the doc's own global numbering) and the note that explains it.\n// webpieces-disable no-function-outside-class -- third section of renderL2Doc's string, beside it in this module\nfunction renderUseCases(): string[] {\n return [\n '## L2 use cases',\n '',\n 'Same row shape as L0 and L1: the **Fix** is literal or it is not a fix. Each case is attached IN',\n 'CODE to the row that judges it (`useCases` on `L2Row`), so this table cannot describe a row that no',\n 'longer exists and a row cannot quietly acquire behaviour nothing documents.',\n '',\n '**This table is how the layer LEARNS.** When a new situation comes up in a session, the change is',\n 'one more `new L2UseCase(...)` on the row that judged it — not a paragraph added here, which the',\n 'byte-lock spec would reject anyway. Every case also carries the exact `reason` string the guard',\n 'logs, and a spec pushes that back through `l2RowForReason` to assert it lands on the row it is filed',\n 'under. So a case whose row is wrong fails the build rather than misinforming a reader.',\n '',\n '| # | what you SEE (exact symptom) | state | verdict | Fix |',\n '|---|---|---|---|---|',\n ...allL2UseCases().map(useCaseRow),\n '',\n 'The incidental-write case under row 6 is the one to read first: `npx expo install` on `main`, in',\n 'another repo on this toolchain, modified two tracked files and no guard fired. It is filed under row',\n '6 rather than row 5 because that is the row that judges Bash on `main` today — a command\\'s stated',\n 'purpose never says whether it also writes, which is why the shape there is default-deny plus the row',\n '4 skip list rather than a blocklist of readers anybody could have enumerated.',\n '',\n ];\n}\n\n// How the log joins to this table, and the skip list. Prose plus the reason→row contract.\n// webpieces-disable no-function-outside-class -- fourth section of renderL2Doc's string, beside it in this module\nfunction renderNotes(): string[] {\n return [\n '## How a log line joins to a row',\n '',\n 'Every L2 decision is written to `.webpieces/logs/L2-decisions/<writer>.log` with `layer=L2` and',\n '`row=<n>`, where `<n>` is a row number from the table above. So `row=8` means \"this call was judged',\n 'by row 8\" and you read the state, the verdict and the cure straight off this page.',\n '',\n '**The join is by REASON, not by dispatch, and the difference is worth knowing.** L1 takes the first',\n 'matching row and switches on it, so deleting an L1 row deletes a block. L2\\'s four classes each own',\n 'their own ladder (see \"The four classes\" above for why they cannot be one function), and',\n '`L2_ROW_FOR_REASON` in `l2-rows.ts` maps each ladder exit to the row it is an instance of. A unit',\n 'test reads the four guard sources and asserts every reason literal resolves to a row, so a new exit',\n 'with no row fails the build rather than logging `row=-` forever.',\n '',\n '## The skip list (row 4)',\n '',\n 'Principle: **these get you OUT or tell you where you are.** They are not \"working here\".',\n '',\n '| group | commands |',\n '|---|---|',\n '| get out | `git checkout -b <new> origin/main` · `git switch -c <new> origin/main` · `git switch <other>` · `git worktree add … -b <new> origin/main` |',\n '| make `main` current | `pnpm wp-checkout-clean-main` *(the prescribed form — also reaps dead branches/worktrees and sweeps orphan directories)* · `git pull` · `git fetch` · `git checkout main && git pull origin main` *(paired only; still allowed, and still the L0 recovery cure, where no `pnpm` bin can be trusted)* |',\n '| orient | `git status\\\\|log\\\\|diff\\\\|branch` · `gh` generally (`pr view`, `pr close`, `pr comment`, `api`, `run watch` — it talks to GitHub, not to this tree) |',\n '| talk to the network | `curl` · `wget` — a URL is not this repo. NOT the forms that write a local file: `curl -o`, `wget -O`, `gh repo clone`, `gh pr checkout`, `gh run download`, or any `> file` redirect |',\n '| park work | `git stash` |',\n '| repair / tooling | `pnpm wp-start-update` · `pnpm wp-start-upsert-pr` · the `wp-*` bins |',\n '',\n '**NOT on it:** `git commit` `add` `push` `merge` `rebase` `reset` `restore` `clean` `cherry-pick` ·',\n '`git grep` `show <rev>:<path>` `cat-file` `ls-files` (those read tracked content). **`pnpm build` /',\n '`pnpm test` are not on it either** — there is no point running them on `main` or on a dead branch.',\n '',\n '## Cannot tell — everything that lands on row ' + String(L2_FAIL_OPEN_ROW),\n '',\n '| state | expected? | treatment |',\n '|---|---|---|',\n '| cache absent | **yes** — the refresher populates for the NEXT call, so this fires on the first tool call of every session | fail open, log |',\n '| detached HEAD | **yes** — mid-rebase, `git checkout <sha>` | fail open, log |',\n '| forge unreachable | **yes** — `gh` missing, unauthenticated, rate-limited or offline | fail open, log as `no-forge` |',\n '| branch unresolvable | **no** — not a repo, git broken | fail open, log LOUDLY |',\n '| cache for another branch | **no** — unreachable since the cache became branch-keyed | fail open, log LOUDLY |',\n '',\n '**Do NOT block to capture these cases.** `cache-absent` fires on every session\\'s first call;',\n 'blocking there deadlocks every session behind a network fetch. And if a guard cannot establish',\n 'state, blocking means a *broken* guard wedges the session — the exact failure this family exists to',\n 'avoid.',\n '',\n '`ALLOW_FAIL_OPEN` is a TYPED VERDICT, not a string suffix on the reason, so abstentions are',\n 'countable. `no-forge` is the newest member: `branchAlreadyMerged: false` used to be produced both by',\n '\"this branch has no merged PR\" and by \"we could not ask\", and both logged a plain ALLOW — so from',\n 'the trail you could not tell whether the merged-branch policy was protecting anything or quietly',\n 'standing down.',\n '',\n ];\n}\n\n// The gaps between the table and the code, and the code anchors.\n// webpieces-disable no-function-outside-class -- last section of renderL2Doc's string, beside it in this module\nfunction renderTail(): string[] {\n return [\n '## Not done — rows the guards do not yet honour',\n '',\n ...renderNotDoneBody(),\n '',\n 'This section is generated from `NOT_DONE` in `l2-rows.ts`, so closing a gap means deleting its entry',\n 'and the doc follows — it cannot rot into a list of things that were fixed years ago.',\n '',\n '## Incidents these guards exist because of',\n '',\n '- **The 157-commit checkout.** An agent ran `git checkout main` in a clone whose local `main` was',\n ' 157 commits behind. That checkout reverted the `@webpieces` pin, reverted the guard shim — **the',\n ' drift guard itself** — to a copy whose message stated the drift backwards, and so reverted the',\n ' agent\\'s judgment: it ran the `pnpm install` that message named and downgraded `node_modules`.',\n ' Lesson, quoted from the code: *a guard a stale checkout can revert cannot be relied on to catch a',\n ' stale checkout.* Hence row 2, which is preventive, matches on command TEXT only, and asks git',\n ' nothing — deliberately, because the only `main` it could measure is the one it is about to leave.',\n '- **The side door.** An agent on a `main` 18 commits behind (108 files, +8069/−3692 upstream) had',\n ' its Read tool blocked exactly as designed, then spent the session `ls`-ing, `grep`-ing and',\n ' `cat`-ing the same stale tree, and described a CI workflow set missing a 186-line workflow that',\n ' existed upstream. *The logs read \"read-stale-guard handled\", which is worse than no guard: it',\n ' looks covered.*',\n '- **Computed and thrown away.** Both file guards are file-scoped, so Bash reached neither. An agent',\n ' that only ran shell sailed through on a merged branch **even though `branchAlreadyMerged` was',\n ' loaded and logged on that very path.**',\n '',\n '---',\n '',\n '',\n '## Code anchors',\n '',\n '| section | file | symbol |',\n '|---|---|---|',\n '| the rows + the reason→row join | `ai-hook-rules/src/core/l2-rows.ts` | `L2_ROWS`, `l2RowForReason`, `NOT_DONE` |',\n '| write policy | `ai-hook-rules/src/core/rules/feature-branch-guard.ts` | `check` |',\n '| read policy | `ai-hook-rules/src/core/rules/read-stale-guard.ts` | `checkStaleMain`, `checkMergedBranch` |',\n '| stale-main Bash | `ai-hook-rules/src/core/rules/stale-main-bash-guard.ts` | `checkFreshness`, `bareCheckoutOfMain` |',\n '| the shared freshness predicate | `ai-hook-rules/src/core/rules/main-freshness.ts` | `containsOriginMain`, `summarize` |',\n '| merged-branch Bash | `ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts` | `isFullyRecovery`, `ALLOWED_GIT_SUBCOMMANDS` |',\n '| the cache | `rules-config/src/main-sync-status.ts`, `main-sync-file.ts` | `readMainSyncStatus`, `MainSyncStatusFile`, `forgeReachable` |',\n '| the refresher | `ai-hook-rules/src/core/sync-main.ts` | `refreshMainSync` |',\n '| command scanning | `ai-hook-rules/src/core/rules/content-read-scan.ts`, `shell-segment-scan.ts` | `readsStaleContent`, `classify` |',\n '| the config key | `rules-config/src/main-sync-guard-configs.ts`, `sections.ts` | `BranchStateGuardConfig`, `BRANCH_STATE_GUARD_KEY` |',\n '',\n ];\n}\n"]}
@@ -106,7 +106,7 @@ export declare class L2Row {
106
106
  toolCell(): string;
107
107
  }
108
108
  /**
109
- * THE ELEVEN L2 ROWS, in first-match-wins order.
109
+ * THE THIRTEEN L2 ROWS, in first-match-wins order.
110
110
  *
111
111
  * Rows 1-5 need NO cache and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a marker-file
112
112
  * scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.
@@ -3,7 +3,7 @@
3
3
  // L2 — the BRANCH-STATE layer, as data.
4
4
  //
5
5
  // L2 answers one question: *may I work here, and is what I read current?* Drawn as a decision matrix
6
- // that is TEN ordered rows plus one terminal fail-open row, first match wins.
6
+ // that is TWELVE ordered rows plus one terminal fail-open row, first match wins.
7
7
  //
8
8
  // This module holds those rows, l2-doc.ts renders them into guards/L2-branch-state.md, and a unit test
9
9
  // (l2-matrix.spec.ts) locks that file byte-identical to the renderer — the same mechanism that already
@@ -156,7 +156,7 @@ class L2Row {
156
156
  }
157
157
  exports.L2Row = L2Row;
158
158
  /**
159
- * THE ELEVEN L2 ROWS, in first-match-wins order.
159
+ * THE THIRTEEN L2 ROWS, in first-match-wins order.
160
160
  *
161
161
  * Rows 1-5 need NO cache and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a marker-file
162
162
  * scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.
@@ -201,9 +201,19 @@ exports.L2_ROWS = [
201
201
  new L2UseCase(27, 'A build, a `cat` or a `curl` on `main`, on the first Bash call of a session', 'on `main`, cache absent — so whether `main` is behind is UNKNOWN', 'ALLOW (fail-open), logged `ALLOW_FAIL_OPEN`. `B` on `main` is judged by rows 6/7 and therefore lands here when the cache cannot answer; the WRITE half is not, which is why row 5 sits above this divider', 'None — the second call is judged normally', 'no-sync-cache'),
202
202
  new L2UseCase(14, 'Mid-rebase, every guard abstains', 'detached HEAD — there is no branch name to judge', 'ALLOW (fail-open), logged LOUDLY when the branch is unresolvable rather than merely detached', 'None — finish or abort the rebase', 'branch-undeterminable'),
203
203
  ]),
204
- new L2Row(6, ['B', 'R'], 'on `main`, behind `origin/main`', exports.L2_BLOCK, '`git pull origin main`, or `git checkout -b <new> origin/main`', [
204
+ // ROWS 12/13 sit ABOVE row 6 because they are judged inside its state: local `main` is established
205
+ // BEHIND, and the only remaining question is whether the command already carries its own cure. They
206
+ // are numbered 12/13 rather than slotted in as 6a/6b for the reason row 11 is numbered 11 — row
207
+ // numbers are logged as `row=` and cited here, so renumbering re-points every existing reference.
208
+ new L2Row(12, ['B'], 'on `main`, behind `origin/main`, and the command STARTS with a refresh-main cure joined to the work by `&&`', exports.L2_ALLOW, '—', [
209
+ new L2UseCase(29, '`git fetch --prune origin main -q && git pull --ff-only origin main 2>&1 | tail -1 && sed -n \'30,75p\' src/app.ts` — the agent cures and reads in one call', 'on `main`, behind `origin/main`, cure first, `&&` between', 'ALLOW: `&&` short-circuits, so the `sed` never runs if the pull fails — the guard was refusing a safety property the shell already enforces. Measured fleet-wide as `cure_bundled_and`, and filed as a TOOLING defect, not an agent one', 'None needed', 'cure-prefixed, && short-circuits the work'),
210
+ ]),
211
+ new L2Row(13, ['B'], 'on `main`, behind `origin/main`, and the cure is joined to the work by `;` (or `||`, `&`, a newline) — the work runs even if the cure fails', exports.L2_BLOCK, '`pnpm wp-checkout-clean-main && <your command>`', [
212
+ new L2UseCase(30, '`pnpm wp-checkout-clean-main >/dev/null 2>&1; git log --oneline -1; sed -n \'598,612p\' eslint.config.mjs` — and the agent then quotes an eslint rule out of a file 15 commits stale', 'on `main`, behind `origin/main`, cure first, `;` between', 'BLOCK: `;` discards the cure\'s exit code, so a conflict, a dirty tree or no network leaves the `sed` reading still-stale content — and 7 of the 9 observed cases also silenced the cure with `>/dev/null 2>&1`, so the failure was invisible too. The two-step is safer because the NEXT tool call re-computes `localMain` against `originMain`, so a failed pull re-blocks; an allowed `;` compound never gets that second look', 'Swap the `;` for `&&` — `pnpm wp-checkout-clean-main && <your command>` — or run the cure alone and re-issue the command in the next call', 'cure-prefixed, work runs anyway'),
213
+ ]),
214
+ new L2Row(6, ['B', 'R'], 'on `main`, behind `origin/main`', exports.L2_BLOCK, '`pnpm wp-checkout-clean-main`, or `git checkout -b <new> origin/main`', [
205
215
  new L2UseCase(13, 'The Read tool refuses a file on a stale `main` while you have UNCOMMITTED edits', 'on `main`, behind `origin/main`, dirty tree', 'BLOCK. This used to fail open, on the argument that the prescribed `git pull` is not a clean fast-forward when the tree is dirty. That was true of the MESSAGE, not the row: the cure cell always offered a second form, and it works dirty', '`git checkout -b <new> origin/main` — uncommitted changes come with you onto the new branch. If git refuses because `origin/main` touched the same files, `git stash` first (never blocked), then retry, then `git stash pop`', 'on-stale-main'),
206
- new L2UseCase(15, 'The Read tool refuses a file that exists, on a `main` 18 commits behind', 'on `main`, behind `origin/main`, clean tree', 'BLOCK: judged by live ancestry (`git merge-base --is-ancestor`), not hash equality, so a pull takes effect instantly', '`git pull origin main`, or `git checkout -b <new> origin/main`', 'on-stale-main'),
216
+ new L2UseCase(15, 'The Read tool refuses a file that exists, on a `main` 18 commits behind', 'on `main`, behind `origin/main`, clean tree', 'BLOCK: judged by live ancestry (`git merge-base --is-ancestor`), not hash equality, so a pull takes effect instantly', '`pnpm wp-checkout-clean-main`, or `git checkout -b <new> origin/main`', 'on-stale-main'),
207
217
  new L2UseCase(16, 'Read is blocked, so the session reaches for `cat`, `grep` and `ls` instead — and describes a CI workflow set missing a whole workflow that existed upstream', 'the SIDE DOOR: same tree, same staleness, different tool', 'BLOCK: `B` is judged here beside `R`, so closing the Read tool no longer opens a shell-shaped hole. The log used to read "read-stale-guard handled", which is worse than no guard — it looks covered', '`git checkout -b <new> origin/main`', 'on-stale-main'),
208
218
  new L2UseCase(10, 'A Bash command that WRITES tracked files as a side effect — `npx expo install`, a formatter, codegen, `sed -i`, a `>` redirect', 'on a `main` known to be BEHIND, and the write is incidental to a command whose stated purpose is something else', 'BLOCK: inside this row `B` is default-DENY plus row 4\'s skip list, never a blocklist of readers — a command nobody thought to enumerate is caught by not being on the list, which is the only shape that could have caught this one', '`git checkout -b <new> origin/main` BEFORE running anything that may write', 'on-stale-main'),
209
219
  ]),
@@ -261,6 +271,10 @@ const EXACT_REASON_ROWS = {
261
271
  // for the Read tool, stale-main-bash-guard for Bash. Same cache, same ancestry test, one verdict.
262
272
  'on-stale-main': 6,
263
273
  'local-main-contains-origin (up to date)': 7,
274
+ // Rows 12/13 — composition, judged INSIDE row 6's state: the tree is established behind, and the
275
+ // only remaining question is whether the command carries its own cure and with which operator.
276
+ 'cure-prefixed, && short-circuits the work': 12,
277
+ 'cure-prefixed, work runs anyway': 13,
264
278
  // Row 9 — the two unhealthy-fork states.
265
279
  'no-fork-point': 9,
266
280
  'main-moved-conflict': 9,