@webpieces/ai-hook-rules 0.4.668 → 0.4.670

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.
@@ -1 +1 @@
1
- {"version":3,"file":"branch-switch-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-switch-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/**\n * \"Which branch does this `git checkout` / `git switch` land me on?\" — the ONE place that question is\n * answered, for every guard that asks it.\n *\n * It used to be answered by two regexes in redirect-how-to-merge-main\n * (`/git\\s+(?:checkout|switch)\\s+main\\b/` to EXEMPT main, and a negative-lookahead twin to catch a\n * feature switch) and by a third, hand-rolled word walk in stale-main-bash-guard. The regex pair\n * assumed the branch name is the token immediately after the subcommand, so ONE flag broke it:\n *\n * git checkout main → exempted (correct)\n * git checkout -q main → `main` no longer follows `checkout`, so the exemption MISSED, the\n * lookahead concluded the target was not main, and the command was\n * blocked with \"this switches to a feature branch and then pulls main\n * into it\" — the exact opposite of what it does.\n *\n * That block landed on the cure stale-main-bash-guard itself prescribes, so the two guards\n * contradicted each other and a human had to route around both to update main. The fix is not a\n * cleverer regex: it is to TOKENIZE (CommandScanner already does the hard part) and walk the flags,\n * which is also why it must exist once rather than three times.\n *\n * Deliberately NOT \"does the string contain main\" — over-matching here is worse than the original\n * bug, because `git checkout -b feature/main-thing` and `git checkout -- main.ts` would then read as\n * \"switching to main\" and be EXEMPTED from a guard whose whole job is to catch a feature-branch pull.\n */\nconst CREATE_FLAGS: ReadonlySet<string> = new Set(['-b', '-B', '-c', '-C', '--orphan']);\n\n// Non-creating flags that consume the FOLLOWING token, so its value is never mistaken for the target.\nconst FLAGS_WITH_VALUE: ReadonlySet<string> = new Set(['--conflict', '--pathspec-from-file', '--start-point']);\n\n/**\n * The branch a checkout/switch lands on, and whether that command CREATES it.\n *\n * `created` is load-bearing rather than cosmetic: `git checkout -b x origin/main` lands on a branch\n * that is current by construction (nothing to be stale about), whereas landing on an EXISTING local\n * `main` is exactly the stale-checkout hazard. Data-only, so a class (per CLAUDE.md).\n */\nexport class BranchSwitch {\n branch: string;\n created: boolean;\n\n constructor(branch: string, created: boolean) {\n this.branch = branch;\n this.created = created;\n }\n}\n\nexport class BranchSwitchScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n /**\n * The branch this segment switches to, or null when the segment does not land on a branch at all:\n * a different command, no target word, `git checkout -` (the previous branch — unknowable here),\n * or anything after `--` (which ends option parsing, making the rest PATHSPECS, so\n * `git checkout -- main` restores a FILE named main and moves no branch).\n *\n * Flag-tolerant by construction: every leading flag is walked past, so `-q`, `--quiet`,\n * `--no-track`, `--detach` and friends change nothing about which word is the target.\n */\n targetOf(segment: string): BranchSwitch | null {\n const subcommand = this.scanner.gitSubcommand(segment);\n if (subcommand !== 'checkout' && subcommand !== 'switch') return null;\n\n const words = this.scanner.words(segment);\n const args = words.slice(words.indexOf(subcommand) + 1);\n\n for (let i = 0; i < args.length; i++) {\n const word = args[i];\n if (word === '--') return null;\n if (word === '-') return null;\n const created = this.createdBranch(args, i);\n if (created !== null) return created;\n if (FLAGS_WITH_VALUE.has(word)) { i++; continue; }\n if (word.startsWith('-')) continue;\n return new BranchSwitch(word, false);\n }\n return null;\n }\n\n /**\n * True only for landing on an EXISTING branch named `main` — the form that can hand you a stale\n * tree, and the form `git checkout main && git pull origin main` uses. `-b`/`-B`/`-c`/`-C` are\n * excluded because they create the branch here and now.\n */\n landsOnExistingMain(segment: string): boolean {\n const target = this.targetOf(segment);\n return target !== null && this.isExistingMain(target);\n }\n\n /** The same question for a target already parsed (switchesIn). One spelling, two entry shapes. */\n isExistingMain(target: BranchSwitch): boolean {\n return target.branch === 'main' && !target.created;\n }\n\n /** Every segment of a whole command that lands on a branch, in order. */\n switchesIn(command: string): readonly BranchSwitch[] {\n const found: BranchSwitch[] = [];\n for (const segment of this.scanner.commandSegments(command)) {\n const target = this.targetOf(segment);\n if (target !== null) found.push(target);\n }\n return found;\n }\n\n // `-b <name>` / `--orphan=<name>` — the new branch's name, or null when this word creates nothing.\n private createdBranch(args: readonly string[], i: number): BranchSwitch | null {\n const word = args[i];\n if (CREATE_FLAGS.has(word)) {\n const name = args[i + 1];\n if (name === undefined || name.startsWith('-')) return null;\n return new BranchSwitch(name, true);\n }\n const equals = word.indexOf('=');\n if (equals > 0 && CREATE_FLAGS.has(word.slice(0, equals))) {\n return new BranchSwitch(word.slice(equals + 1), true);\n }\n return null;\n }\n}\n"]}
1
+ {"version":3,"file":"branch-switch-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-switch-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/**\n * \"Which branch does this `git checkout` / `git switch` land me on?\" — the ONE place that question is\n * answered, for every guard that asks it.\n *\n * It used to be answered by two regexes in redirect-how-to-merge-main\n * (`/git\\s+(?:checkout|switch)\\s+main\\b/` to EXEMPT main, and a negative-lookahead twin to catch a\n * feature switch) and by a third, hand-rolled word walk in stale-main-bash-guard. The regex pair\n * assumed the branch name is the token immediately after the subcommand, so ONE flag broke it:\n *\n * git checkout main → exempted (correct)\n * git checkout -q main → `main` no longer follows `checkout`, so the exemption MISSED, the\n * lookahead concluded the target was not main, and the command was\n * blocked with \"this switches to a feature branch and then pulls main\n * into it\" — the exact opposite of what it does.\n *\n * That block landed on the cure stale-main-bash-guard itself prescribes, so the two guards\n * contradicted each other and a human had to route around both to update main. The fix is not a\n * cleverer regex: it is to TOKENIZE (CommandScanner already does the hard part) and walk the flags,\n * which is also why it must exist once rather than three times.\n *\n * Deliberately NOT \"does the string contain main\" — over-matching here is worse than the original\n * bug, because `git checkout -b feature/main-thing` and `git checkout -- main.ts` would then read as\n * \"switching to main\" and be EXEMPTED from a guard whose whole job is to catch a feature-branch pull.\n */\nconst CREATE_FLAGS: ReadonlySet<string> = new Set(['-b', '-B', '-c', '-C', '--orphan']);\n\n// Non-creating flags that consume the FOLLOWING token, so its value is never mistaken for the target.\nconst FLAGS_WITH_VALUE: ReadonlySet<string> = new Set(['--conflict', '--pathspec-from-file', '--start-point']);\n\n/**\n * The branch a checkout/switch lands on, and whether that command CREATES it.\n *\n * `created` is load-bearing rather than cosmetic: `git checkout -b x origin/main` lands on a branch\n * that is current by construction (nothing to be stale about), whereas landing on an EXISTING local\n * `main` is exactly the stale-checkout hazard. Data-only, so a class (per CLAUDE.md).\n */\nexport class BranchSwitch {\n branch: string;\n created: boolean;\n\n constructor(branch: string, created: boolean) {\n this.branch = branch;\n this.created = created;\n }\n}\n\nexport class BranchSwitchScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n /**\n * The branch this segment switches to, or null when the segment does not land on a branch at all:\n * a different command, no target word, `git checkout -` (the previous branch — unknowable here),\n * or anything after `--` (which ends option parsing, making the rest PATHSPECS, so\n * `git checkout -- main` restores a FILE named main and moves no branch).\n *\n * Flag-tolerant by construction: every leading flag is walked past, so `-q`, `--quiet`,\n * `--no-track`, `--detach` and friends change nothing about which word is the target.\n */\n targetOf(segment: string): BranchSwitch | null {\n const subcommand = this.scanner.gitSubcommand(segment);\n if (subcommand !== 'checkout' && subcommand !== 'switch') return null;\n\n const words = this.scanner.words(segment);\n const args = words.slice(words.indexOf(subcommand) + 1);\n\n for (let i = 0; i < args.length; i++) {\n const word = args[i];\n if (word === '--') return null;\n if (word === '-') return null;\n const created = this.createdBranch(args, i);\n if (created !== null) return created;\n if (FLAGS_WITH_VALUE.has(word)) { i++; continue; }\n if (word.startsWith('-')) continue;\n return new BranchSwitch(word, false);\n }\n return null;\n }\n\n /**\n * True only for landing on an EXISTING branch named `main` — the form that can hand you a stale\n * tree, and the form `git checkout main && git pull origin main` uses. `-b`/`-B`/`-c`/`-C` are\n * excluded because they create the branch here and now.\n *\n * This scan reads raw GIT, and it has to keep doing so even though the workflow messages now\n * prescribe `pnpm wp-checkout-clean-main` instead: what the guards recommend is not what an agent\n * necessarily types, the raw pair is still allowed and is still the L0 recovery cure, and a scan\n * that stopped recognising it would stop catching the bare `git checkout main` it exists to catch.\n */\n landsOnExistingMain(segment: string): boolean {\n const target = this.targetOf(segment);\n return target !== null && this.isExistingMain(target);\n }\n\n /** The same question for a target already parsed (switchesIn). One spelling, two entry shapes. */\n isExistingMain(target: BranchSwitch): boolean {\n return target.branch === 'main' && !target.created;\n }\n\n /** Every segment of a whole command that lands on a branch, in order. */\n switchesIn(command: string): readonly BranchSwitch[] {\n const found: BranchSwitch[] = [];\n for (const segment of this.scanner.commandSegments(command)) {\n const target = this.targetOf(segment);\n if (target !== null) found.push(target);\n }\n return found;\n }\n\n // `-b <name>` / `--orphan=<name>` — the new branch's name, or null when this word creates nothing.\n private createdBranch(args: readonly string[], i: number): BranchSwitch | null {\n const word = args[i];\n if (CREATE_FLAGS.has(word)) {\n const name = args[i + 1];\n if (name === undefined || name.startsWith('-')) return null;\n return new BranchSwitch(name, true);\n }\n const equals = word.indexOf('=');\n if (equals > 0 && CREATE_FLAGS.has(word.slice(0, equals))) {\n return new BranchSwitch(word.slice(equals + 1), true);\n }\n return null;\n }\n}\n"]}
@@ -8,4 +8,29 @@ export declare class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatter
8
8
  readonly files: string[];
9
9
  get fixHint(): FixHint;
10
10
  check(ctx: EditContext): readonly Violation[];
11
+ /**
12
+ * The first statement of the catch block, judged against BOTH line arrays — and that pairing is the
13
+ * whole fix for a bug that made this rule refuse the exact cure it prescribes.
14
+ *
15
+ * The rule FINDS catch clauses in `ctx.strippedLines`, which is right: a `catch (e) {` inside a
16
+ * comment is not a catch clause. But it used to also LOOK FOR the toError statement there, and
17
+ * stripping deletes `//const error = toError(err);` down to an empty line — so Fix Option 2, the
18
+ * documented way to say "this error is deliberately ignored", was reported as "no toError statement"
19
+ * every single time. TO_ERROR_PATTERN's optional `//` prefix could never match, because nothing
20
+ * carrying a `//` ever reached it. 34 catches in this repo already use that form.
21
+ *
22
+ * So: the RAW line decides whether the commented form is present, and the STRIPPED line decides what
23
+ * counts as "the first statement" (a blank line, a `{`, or an unrelated comment is skipped past).
24
+ * Raw is tested first because it is the only array in which the comment form survives; a live
25
+ * statement with a trailing comment (`const error = toError(err); // why`) fails the raw test on the
26
+ * `$` anchor and is then matched on its stripped form, which is exactly the intent.
27
+ *
28
+ * COMMENT_REMNANT is the other half of that, and it is a property of the stripper: `stripTsNoise`
29
+ * KEEPS the `//` marker and blanks only what follows it, so a stripped comment line trims to `//`
30
+ * rather than to the empty string. Without normalizing that away, `//` is neither blank nor a `{`,
31
+ * so it was taken for the first statement — which is the mechanical reason a commented-out toError
32
+ * reported "no toError statement", and the reason a trailing `// why` on a LIVE toError reported the
33
+ * same. One replace fixes both, in the one place the question is asked.
34
+ */
35
+ private findToErrorStatement;
11
36
  }
@@ -14,23 +14,17 @@ const CATCH_PATTERN = /\bcatch\s*\(\s*(\w+)(?:\s*:\s*(\w+))?\s*\)/;
14
14
  /**
15
15
  * Matches the required toError first statement (with or without comment-out).
16
16
  * Group 1 = variable name, group 2 = param passed to toError
17
+ *
18
+ * The optional `//` prefix is what makes Fix Option 2 — "to explicitly ignore the error, write
19
+ * `//const error = toError(err);`" — a real escape. It is only reachable when the pattern is tested
20
+ * against the RAW source line; see findToErrorStatement() for why both line arrays are needed.
17
21
  */
18
22
  const TO_ERROR_PATTERN = /^\s*(?:\/\/\s*)?const\s+(\w+)\s*=\s*toError\(\s*(\w+)\s*\)\s*;?\s*$/;
19
- function findToErrorStatement(lines, startIndex) {
20
- for (let j = startIndex; j < lines.length; j += 1) {
21
- const line = lines[j].trim();
22
- if (line === '' || line === '{')
23
- continue;
24
- const match = TO_ERROR_PATTERN.exec(line);
25
- if (match) {
26
- return { varName: match[1], paramName: match[2], lineIndex: j };
27
- }
28
- // First non-blank line is not a toError call
29
- return 'not-found';
30
- }
31
- // Ran off the end of the edit content — can't validate further
32
- return 'end-of-content';
33
- }
23
+ /**
24
+ * What `stripTsNoise` leaves behind where a `//` comment was: the two slashes, then blanks to the end of
25
+ * the line. Trimming a stripped comment line therefore yields `//`, not `''`. See findToErrorStatement().
26
+ */
27
+ const COMMENT_REMNANT = /\/\/\s*$/;
34
28
  class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
35
29
  constructor(config) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }
36
30
  description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string
@@ -72,7 +66,7 @@ class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
72
66
  violations.push(new types_1.Violation(lineNum, ctx.lines[i].trim(), msg));
73
67
  }
74
68
  // Find next non-blank line after the catch opening to check for toError
75
- const toErrorResult = findToErrorStatement(lines, i + 1);
69
+ const toErrorResult = this.findToErrorStatement(ctx, i + 1);
76
70
  if (toErrorResult === 'not-found') {
77
71
  violations.push(new types_1.Violation(lineNum, ctx.lines[i].trim(), `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`));
78
72
  }
@@ -92,6 +86,50 @@ class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
92
86
  (0, instruct_ai_writer_1.writeTemplateIfMissing)(ctx.workspaceRoot, 'webpieces.exceptions.md');
93
87
  return violations;
94
88
  }
89
+ /**
90
+ * The first statement of the catch block, judged against BOTH line arrays — and that pairing is the
91
+ * whole fix for a bug that made this rule refuse the exact cure it prescribes.
92
+ *
93
+ * The rule FINDS catch clauses in `ctx.strippedLines`, which is right: a `catch (e) {` inside a
94
+ * comment is not a catch clause. But it used to also LOOK FOR the toError statement there, and
95
+ * stripping deletes `//const error = toError(err);` down to an empty line — so Fix Option 2, the
96
+ * documented way to say "this error is deliberately ignored", was reported as "no toError statement"
97
+ * every single time. TO_ERROR_PATTERN's optional `//` prefix could never match, because nothing
98
+ * carrying a `//` ever reached it. 34 catches in this repo already use that form.
99
+ *
100
+ * So: the RAW line decides whether the commented form is present, and the STRIPPED line decides what
101
+ * counts as "the first statement" (a blank line, a `{`, or an unrelated comment is skipped past).
102
+ * Raw is tested first because it is the only array in which the comment form survives; a live
103
+ * statement with a trailing comment (`const error = toError(err); // why`) fails the raw test on the
104
+ * `$` anchor and is then matched on its stripped form, which is exactly the intent.
105
+ *
106
+ * COMMENT_REMNANT is the other half of that, and it is a property of the stripper: `stripTsNoise`
107
+ * KEEPS the `//` marker and blanks only what follows it, so a stripped comment line trims to `//`
108
+ * rather than to the empty string. Without normalizing that away, `//` is neither blank nor a `{`,
109
+ * so it was taken for the first statement — which is the mechanical reason a commented-out toError
110
+ * reported "no toError statement", and the reason a trailing `// why` on a LIVE toError reported the
111
+ * same. One replace fixes both, in the one place the question is asked.
112
+ */
113
+ findToErrorStatement(ctx, startIndex) {
114
+ const stripped = ctx.strippedLines;
115
+ for (let j = startIndex; j < stripped.length; j += 1) {
116
+ const rawMatch = TO_ERROR_PATTERN.exec((ctx.lines[j] ?? '').trim());
117
+ if (rawMatch)
118
+ return { varName: rawMatch[1], paramName: rawMatch[2], lineIndex: j };
119
+ const line = stripped[j].replace(COMMENT_REMNANT, '').trim();
120
+ // Blank, an opening brace, or a comment that stripping emptied — none of these is the first
121
+ // statement, so keep looking.
122
+ if (line === '' || line === '{')
123
+ continue;
124
+ const match = TO_ERROR_PATTERN.exec(line);
125
+ if (match)
126
+ return { varName: match[1], paramName: match[2], lineIndex: j };
127
+ // First real statement is not a toError call
128
+ return 'not-found';
129
+ }
130
+ // Ran off the end of the edit content — can't validate further
131
+ return 'end-of-content';
132
+ }
95
133
  }
96
134
  exports.CatchErrorPatternRule = CatchErrorPatternRule;
97
135
  //# sourceMappingURL=catch-error-pattern.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"catch-error-pattern.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/catch-error-pattern.ts"],"names":[],"mappings":"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","sourcesContent":["import { CatchErrorPatternConfig, RULE_NAMES, Option } from '@webpieces/rules-config';\n\nimport type { EditContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { EditRuleBase } from '../rule-base';\nimport { FixHint, DisableEscape } from '../fix-hint';\nimport { writeTemplateIfMissing } from '../instruct-ai-writer';\n\n/**\n * Matches a catch clause opening: } catch (paramName: typeAnnotation) {\n * Captures: group 1 = param name, group 2 = type annotation (if present)\n */\nconst CATCH_PATTERN = /\\bcatch\\s*\\(\\s*(\\w+)(?:\\s*:\\s*(\\w+))?\\s*\\)/;\n\n/**\n * Matches the required toError first statement (with or without comment-out).\n * Group 1 = variable name, group 2 = param passed to toError\n */\nconst TO_ERROR_PATTERN = /^\\s*(?:\\/\\/\\s*)?const\\s+(\\w+)\\s*=\\s*toError\\(\\s*(\\w+)\\s*\\)\\s*;?\\s*$/;\n\ninterface ToErrorMatch {\n varName: string;\n paramName: string;\n lineIndex: number;\n}\n\nfunction findToErrorStatement(lines: readonly string[], startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {\n for (let j = startIndex; j < lines.length; j += 1) {\n const line = lines[j].trim();\n if (line === '' || line === '{') continue;\n\n const match = TO_ERROR_PATTERN.exec(line);\n if (match) {\n return { varName: match[1], paramName: match[2], lineIndex: j };\n }\n // First non-blank line is not a toError call\n return 'not-found';\n }\n // Ran off the end of the edit content — can't validate further\n return 'end-of-content';\n}\n\nexport class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatternConfig> {\n constructor(config: CatchErrorPatternConfig) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }\n\n readonly description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string\n override readonly files = ['**/*.ts', '**/*.tsx'];\n get fixHint(): FixHint {\n return new FixHint(\n 'Catch block does not follow the toError(err) pattern.',\n 'Name the catch parameter err (err2/err3 when nested), then pick one:',\n [\n new Option('Add as the first statement in the catch block: const error = toError(err);', true),\n new Option('To explicitly ignore the error: //const error = toError(err);'),\n ],\n new DisableEscape(this.config.disableAllowed ?? true, '// webpieces-disable catch-error-pattern -- <reason>'), // webpieces-disable catch-error-pattern -- example text in a hint string\n );\n }\n\n check(ctx: EditContext): readonly Violation[] {\n const disableAllowed = this.config.disableAllowed ?? true;\n const violations: V[] = [];\n const lines = ctx.strippedLines;\n\n for (let i = 0; i < lines.length; i += 1) {\n const stripped = lines[i];\n const catchMatch = CATCH_PATTERN.exec(stripped);\n if (!catchMatch) continue;\n\n const lineNum = i + 1;\n if (disableAllowed && ctx.isLineDisabled(lineNum, RULE_NAMES.CATCH_ERROR_PATTERN)) continue;\n\n const actualParam = catchMatch[1];\n const typeAnnotation = catchMatch[2];\n\n // Determine expected names from suffix on the actual param (err, err2, err3...)\n const suffixMatch = actualParam.match(/^err(\\d*)$/);\n const suffix = suffixMatch ? suffixMatch[1] : '';\n const expectedParam = 'err' + suffix;\n const expectedVar = 'error' + suffix;\n\n // Check parameter name\n if (actualParam !== expectedParam) {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch parameter must be named \"${expectedParam}\" (or \"err2\", \"err3\" for nested catches), got \"${actualParam}\"`,\n ));\n }\n\n // Check type annotation is unknown\n if (typeAnnotation !== 'unknown') {\n const msg = typeAnnotation\n ? `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown), got \"${typeAnnotation}\"`\n : `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown)`;\n violations.push(new V(lineNum, ctx.lines[i].trim(), msg));\n }\n\n // Find next non-blank line after the catch opening to check for toError\n const toErrorResult = findToErrorStatement(lines, i + 1);\n if (toErrorResult === 'not-found') {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,\n ));\n } else if (toErrorResult !== 'end-of-content') {\n // Validate variable name and param match\n if (toErrorResult.varName !== expectedVar) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `Error variable must be named \"${expectedVar}\", got \"${toErrorResult.varName}\"`,\n ));\n }\n if (toErrorResult.paramName !== actualParam) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `toError() must be called with \"${actualParam}\", got \"${toErrorResult.paramName}\"`,\n ));\n }\n }\n }\n if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');\n return violations;\n }\n}\n"]}
1
+ {"version":3,"file":"catch-error-pattern.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/catch-error-pattern.ts"],"names":[],"mappings":"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","sourcesContent":["import { CatchErrorPatternConfig, RULE_NAMES, Option } from '@webpieces/rules-config';\n\nimport type { EditContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { EditRuleBase } from '../rule-base';\nimport { FixHint, DisableEscape } from '../fix-hint';\nimport { writeTemplateIfMissing } from '../instruct-ai-writer';\n\n/**\n * Matches a catch clause opening: } catch (paramName: typeAnnotation) {\n * Captures: group 1 = param name, group 2 = type annotation (if present)\n */\nconst CATCH_PATTERN = /\\bcatch\\s*\\(\\s*(\\w+)(?:\\s*:\\s*(\\w+))?\\s*\\)/;\n\n/**\n * Matches the required toError first statement (with or without comment-out).\n * Group 1 = variable name, group 2 = param passed to toError\n *\n * The optional `//` prefix is what makes Fix Option 2 — \"to explicitly ignore the error, write\n * `//const error = toError(err);`\" — a real escape. It is only reachable when the pattern is tested\n * against the RAW source line; see findToErrorStatement() for why both line arrays are needed.\n */\nconst TO_ERROR_PATTERN = /^\\s*(?:\\/\\/\\s*)?const\\s+(\\w+)\\s*=\\s*toError\\(\\s*(\\w+)\\s*\\)\\s*;?\\s*$/;\n\n/**\n * What `stripTsNoise` leaves behind where a `//` comment was: the two slashes, then blanks to the end of\n * the line. Trimming a stripped comment line therefore yields `//`, not `''`. See findToErrorStatement().\n */\nconst COMMENT_REMNANT = /\\/\\/\\s*$/;\n\ninterface ToErrorMatch {\n varName: string;\n paramName: string;\n lineIndex: number;\n}\n\nexport class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatternConfig> {\n constructor(config: CatchErrorPatternConfig) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }\n\n readonly description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string\n override readonly files = ['**/*.ts', '**/*.tsx'];\n get fixHint(): FixHint {\n return new FixHint(\n 'Catch block does not follow the toError(err) pattern.',\n 'Name the catch parameter err (err2/err3 when nested), then pick one:',\n [\n new Option('Add as the first statement in the catch block: const error = toError(err);', true),\n new Option('To explicitly ignore the error: //const error = toError(err);'),\n ],\n new DisableEscape(this.config.disableAllowed ?? true, '// webpieces-disable catch-error-pattern -- <reason>'), // webpieces-disable catch-error-pattern -- example text in a hint string\n );\n }\n\n check(ctx: EditContext): readonly Violation[] {\n const disableAllowed = this.config.disableAllowed ?? true;\n const violations: V[] = [];\n const lines = ctx.strippedLines;\n\n for (let i = 0; i < lines.length; i += 1) {\n const stripped = lines[i];\n const catchMatch = CATCH_PATTERN.exec(stripped);\n if (!catchMatch) continue;\n\n const lineNum = i + 1;\n if (disableAllowed && ctx.isLineDisabled(lineNum, RULE_NAMES.CATCH_ERROR_PATTERN)) continue;\n\n const actualParam = catchMatch[1];\n const typeAnnotation = catchMatch[2];\n\n // Determine expected names from suffix on the actual param (err, err2, err3...)\n const suffixMatch = actualParam.match(/^err(\\d*)$/);\n const suffix = suffixMatch ? suffixMatch[1] : '';\n const expectedParam = 'err' + suffix;\n const expectedVar = 'error' + suffix;\n\n // Check parameter name\n if (actualParam !== expectedParam) {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch parameter must be named \"${expectedParam}\" (or \"err2\", \"err3\" for nested catches), got \"${actualParam}\"`,\n ));\n }\n\n // Check type annotation is unknown\n if (typeAnnotation !== 'unknown') {\n const msg = typeAnnotation\n ? `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown), got \"${typeAnnotation}\"`\n : `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown)`;\n violations.push(new V(lineNum, ctx.lines[i].trim(), msg));\n }\n\n // Find next non-blank line after the catch opening to check for toError\n const toErrorResult = this.findToErrorStatement(ctx, i + 1);\n if (toErrorResult === 'not-found') {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,\n ));\n } else if (toErrorResult !== 'end-of-content') {\n // Validate variable name and param match\n if (toErrorResult.varName !== expectedVar) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `Error variable must be named \"${expectedVar}\", got \"${toErrorResult.varName}\"`,\n ));\n }\n if (toErrorResult.paramName !== actualParam) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `toError() must be called with \"${actualParam}\", got \"${toErrorResult.paramName}\"`,\n ));\n }\n }\n }\n if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');\n return violations;\n }\n\n /**\n * The first statement of the catch block, judged against BOTH line arrays — and that pairing is the\n * whole fix for a bug that made this rule refuse the exact cure it prescribes.\n *\n * The rule FINDS catch clauses in `ctx.strippedLines`, which is right: a `catch (e) {` inside a\n * comment is not a catch clause. But it used to also LOOK FOR the toError statement there, and\n * stripping deletes `//const error = toError(err);` down to an empty line — so Fix Option 2, the\n * documented way to say \"this error is deliberately ignored\", was reported as \"no toError statement\"\n * every single time. TO_ERROR_PATTERN's optional `//` prefix could never match, because nothing\n * carrying a `//` ever reached it. 34 catches in this repo already use that form.\n *\n * So: the RAW line decides whether the commented form is present, and the STRIPPED line decides what\n * counts as \"the first statement\" (a blank line, a `{`, or an unrelated comment is skipped past).\n * Raw is tested first because it is the only array in which the comment form survives; a live\n * statement with a trailing comment (`const error = toError(err); // why`) fails the raw test on the\n * `$` anchor and is then matched on its stripped form, which is exactly the intent.\n *\n * COMMENT_REMNANT is the other half of that, and it is a property of the stripper: `stripTsNoise`\n * KEEPS the `//` marker and blanks only what follows it, so a stripped comment line trims to `//`\n * rather than to the empty string. Without normalizing that away, `//` is neither blank nor a `{`,\n * so it was taken for the first statement — which is the mechanical reason a commented-out toError\n * reported \"no toError statement\", and the reason a trailing `// why` on a LIVE toError reported the\n * same. One replace fixes both, in the one place the question is asked.\n */\n private findToErrorStatement(ctx: EditContext, startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {\n const stripped = ctx.strippedLines;\n for (let j = startIndex; j < stripped.length; j += 1) {\n const rawMatch = TO_ERROR_PATTERN.exec((ctx.lines[j] ?? '').trim());\n if (rawMatch) return { varName: rawMatch[1], paramName: rawMatch[2], lineIndex: j };\n\n const line = stripped[j].replace(COMMENT_REMNANT, '').trim();\n // Blank, an opening brace, or a comment that stripping emptied — none of these is the first\n // statement, so keep looking.\n if (line === '' || line === '{') continue;\n\n const match = TO_ERROR_PATTERN.exec(line);\n if (match) return { varName: match[1], paramName: match[2], lineIndex: j };\n // First real statement is not a toError call\n return 'not-found';\n }\n // Ran off the end of the edit content — can't validate further\n return 'end-of-content';\n }\n}\n"]}
@@ -58,7 +58,7 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
58
58
  };
59
59
  fixHint = new fix_hint_1.FixHint('This branch is already merged into main — do not keep working here.', 'Get onto a fresh branch off origin/main, then retry:', [
60
60
  new rules_config_1.Option('git fetch origin main && git checkout -b <new-branch> origin/main (in a worktree: git worktree add ../<dir> -b <new> origin/main). Then re-run your command.', true),
61
- new rules_config_1.Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-cleanup, and installs/upgrades.'),
61
+ new rules_config_1.Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-checkout-clean-main and pnpm wp-cleanup, and installs/upgrades.'),
62
62
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
63
63
  ]);
64
64
  check(ctx) {
@@ -1 +1 @@
1
- {"version":3,"file":"merged-branch-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\nimport { RecoveryAllowlist } from './recovery-allowlist';\n\n/**\n * The BASH half of the merged-branch protection — the gap that let a whole session run on an\n * already-merged branch.\n *\n * feature-branch-guard blocks Write/Edit and read-stale-guard blocks the Read tool when the\n * checked-out branch's PR is already merged into main, but BOTH are file-scoped: a `runBash()` command\n * never reaches either. So an agent that only ran shell — `scripts/local.sh start lang` (boots\n * servers), `cat`/`ls` of repo files, git — sailed through, even though the very same\n * `branchAlreadyMerged` flag was loaded and logged on the Bash path (the `calls/` stream →\n * `merged=PR#…`). It was computed and thrown away; nothing consulted it for a block.\n *\n * Those two file guards intentionally leave Bash alone (\"every cure is a Bash command, so Bash is the\n * escape hatch — never wedge it\"). This guard therefore DEFAULT-DENIES Bash on a merged branch but\n * allowlists exactly the commands that get you OFF the branch (the fresh-start / cleanup git commands,\n * switching away, read-only orientation, wp-* cleanup, installs). The redirect it returns names those\n * same commands, so following it can never re-trip the guard — the agent is redirected, not wedged.\n *\n * FAIL-OPEN like its siblings: branch undeterminable, no cache yet, or a cache for a DIFFERENT branch\n * → allow. The cache is per-branch (`status.branch` is the branch it was computed FOR), so acting on\n * another branch's snapshot is never allowed.\n *\n * On the DELIBERATELY-UNFIXED staleness window: the cache is only as fresh as the last detached\n * refresh, so for a few seconds after a merge lands mid-session it can still read `merged=NO` and this\n * guard fails open. That window is tiny and self-closing — agents burst tool calls every few seconds\n * and every Bash call re-triggers the refresh, so `branchAlreadyMerged` flips within 1–3 calls and the\n * next command is caught. Closing it synchronously would require the slow `gh pr list` on the blocking\n * path (the thing the whole cache design avoids) and would mean blocking on stale/uncertain data,\n * which violates the fail-open principle every one of these guards is built on. Not worth it.\n */\nexport class MergedBranchBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'merged-branch-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n // ROW 4, the skip list — shared with stale-main-bash-guard so the two states cannot drift apart\n // about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n\n readonly description =\n 'Block ordinary Bash on an already-merged branch (allowlisting only recovery/cleanup and ' +\n 'read-only inspection commands), so a session cannot proceed on a stale post-merge branch.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is already merged into main — do not keep working here.',\n 'Get onto a fresh branch off origin/main, then retry:',\n [\n new Option('git fetch origin main && git checkout -b <new-branch> origin/main (in a worktree: git worktree add ../<dir> -b <new> origin/main). Then re-run your command.', true),\n new Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-cleanup, and installs/upgrades.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine the branch (not a git repo, git unavailable) → never block. Fail-open.\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command. (The\n // runner also warms it, but only when feature-branch-guard is loaded — do it here too so this\n // guard is self-sufficient when that one is off.)\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n // No cache yet (first command of the session), or a branch this refresh has not seen → allow;\n // the refresh we just spawned populates it.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation.\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n // For THIS guard the merged flag is the ONLY block condition, so an unreachable forge means\n // it is fully abstaining — the state it exists to catch cannot be observed at all.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n\n // Merged. Allow ONLY when every segment of the command is a recovery / cleanup / read-only\n // inspection command — anything else (servers, builds, tests, cat/ls of repo files, git writes)\n // is denied with the redirect.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'merged-branch recovery/inspection (allowlisted)', cache);\n }\n\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr), cache);\n }\n\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forBash(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n // One-line summary of the async-written cache that drove this decision (mirrors the file guards),\n // so a wrong allow/block is traceable to the exact main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('merged-branch-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n\n// git subcommands that are recovery/cleanup OR read-only orientation, and so stay allowed on a merged\n// branch. Everything NOT here (commit, merge, rebase, push, reset, add, restore, clean, cherry-pick,\n// …) is a \"keep working\" operation and is denied with the redirect. `worktree` covers add/remove/prune\n// (branch-creation-guard governs which worktree adds are legal); `branch` covers listing and `-D`\n// cleanup (branch-creation-guard governs creation); `pull` is the on-main update, itself gated by\n// redirect-how-to-merge-main. Reading git METADATA (log/diff/show) is fine — it is not the stale FILE\n// CONTENT that `cat` would surface, which is exactly why `git grep` (reads tracked content) is absent.\n"]}
1
+ {"version":3,"file":"merged-branch-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\nimport { RecoveryAllowlist } from './recovery-allowlist';\n\n/**\n * The BASH half of the merged-branch protection — the gap that let a whole session run on an\n * already-merged branch.\n *\n * feature-branch-guard blocks Write/Edit and read-stale-guard blocks the Read tool when the\n * checked-out branch's PR is already merged into main, but BOTH are file-scoped: a `runBash()` command\n * never reaches either. So an agent that only ran shell — `scripts/local.sh start lang` (boots\n * servers), `cat`/`ls` of repo files, git — sailed through, even though the very same\n * `branchAlreadyMerged` flag was loaded and logged on the Bash path (the `calls/` stream →\n * `merged=PR#…`). It was computed and thrown away; nothing consulted it for a block.\n *\n * Those two file guards intentionally leave Bash alone (\"every cure is a Bash command, so Bash is the\n * escape hatch — never wedge it\"). This guard therefore DEFAULT-DENIES Bash on a merged branch but\n * allowlists exactly the commands that get you OFF the branch (the fresh-start / cleanup git commands,\n * switching away, read-only orientation, wp-* cleanup, installs). The redirect it returns names those\n * same commands, so following it can never re-trip the guard — the agent is redirected, not wedged.\n *\n * FAIL-OPEN like its siblings: branch undeterminable, no cache yet, or a cache for a DIFFERENT branch\n * → allow. The cache is per-branch (`status.branch` is the branch it was computed FOR), so acting on\n * another branch's snapshot is never allowed.\n *\n * On the DELIBERATELY-UNFIXED staleness window: the cache is only as fresh as the last detached\n * refresh, so for a few seconds after a merge lands mid-session it can still read `merged=NO` and this\n * guard fails open. That window is tiny and self-closing — agents burst tool calls every few seconds\n * and every Bash call re-triggers the refresh, so `branchAlreadyMerged` flips within 1–3 calls and the\n * next command is caught. Closing it synchronously would require the slow `gh pr list` on the blocking\n * path (the thing the whole cache design avoids) and would mean blocking on stale/uncertain data,\n * which violates the fail-open principle every one of these guards is built on. Not worth it.\n */\nexport class MergedBranchBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'merged-branch-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n // ROW 4, the skip list — shared with stale-main-bash-guard so the two states cannot drift apart\n // about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n\n readonly description =\n 'Block ordinary Bash on an already-merged branch (allowlisting only recovery/cleanup and ' +\n 'read-only inspection commands), so a session cannot proceed on a stale post-merge branch.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is already merged into main — do not keep working here.',\n 'Get onto a fresh branch off origin/main, then retry:',\n [\n new Option('git fetch origin main && git checkout -b <new-branch> origin/main (in a worktree: git worktree add ../<dir> -b <new> origin/main). Then re-run your command.', true),\n new Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-checkout-clean-main and pnpm wp-cleanup, and installs/upgrades.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine the branch (not a git repo, git unavailable) → never block. Fail-open.\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command. (The\n // runner also warms it, but only when feature-branch-guard is loaded — do it here too so this\n // guard is self-sufficient when that one is off.)\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n // No cache yet (first command of the session), or a branch this refresh has not seen → allow;\n // the refresh we just spawned populates it.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation.\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n // For THIS guard the merged flag is the ONLY block condition, so an unreachable forge means\n // it is fully abstaining — the state it exists to catch cannot be observed at all.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n\n // Merged. Allow ONLY when every segment of the command is a recovery / cleanup / read-only\n // inspection command — anything else (servers, builds, tests, cat/ls of repo files, git writes)\n // is denied with the redirect.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'merged-branch recovery/inspection (allowlisted)', cache);\n }\n\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr), cache);\n }\n\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forBash(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n // One-line summary of the async-written cache that drove this decision (mirrors the file guards),\n // so a wrong allow/block is traceable to the exact main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('merged-branch-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n\n// git subcommands that are recovery/cleanup OR read-only orientation, and so stay allowed on a merged\n// branch. Everything NOT here (commit, merge, rebase, push, reset, add, restore, clean, cherry-pick,\n// …) is a \"keep working\" operation and is denied with the redirect. `worktree` covers add/remove/prune\n// (branch-creation-guard governs which worktree adds are legal); `branch` covers listing and `-D`\n// cleanup (branch-creation-guard governs creation); `pull` is the on-main update, itself gated by\n// redirect-how-to-merge-main. Reading git METADATA (log/diff/show) is fine — it is not the stale FILE\n// CONTENT that `cat` would surface, which is exactly why `git grep` (reads tracked content) is absent.\n"]}
@@ -50,14 +50,15 @@ class MergedBranchMessage {
50
50
  ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +
51
51
  'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'
52
52
  : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +
53
- '`git checkout main && git pull origin main && pnpm wp-cleanup` is allowed and is the ' +
54
- 'shortest exit; also git worktree add/remove/prune';
53
+ '`pnpm wp-checkout-clean-main` (checkout main, pull it, reap dead branches and ' +
54
+ 'worktrees, sweep orphan directories) is allowed and is the shortest exit; also ' +
55
+ 'git worktree add/remove/prune';
55
56
  return [
56
57
  'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',
57
58
  ' - the fresh-start / cleanup git commands above',
58
59
  ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',
59
60
  switching,
60
- ' - pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',
61
+ ' - pnpm wp-checkout-clean-main, pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',
61
62
  ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',
62
63
  ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',
63
64
  '',
@@ -1 +1 @@
1
- {"version":3,"file":"merged-branch-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-message.ts"],"names":[],"mappings":"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","sourcesContent":["import { TreeRecovery, TreeKind } from './tree-recovery';\n\n/**\n * The \"this branch is already merged, start fresh\" text, shared by the TWO guards that detect the\n * state from the same cached signal (`MainSyncStatus.branchAlreadyMerged`):\n *\n * - feature-branch-guard blocks Write/Edit → {@link MergedBranchMessage.forEdits}\n * - read-stale-guard blocks Read → {@link MergedBranchMessage.forReads}\n *\n * One source of truth on purpose: the recovery steps are instructions the AI follows LITERALLY, so\n * two drifting copies would mean two different behaviors for the same repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree\n * we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove\n * this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.\n *\n * ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —\n * main is checked out in the primary clone). In the primary clone it is a perfectly good move and the\n * allowance list below says so explicitly. The two used to disagree inside a single message — the\n * header forbade it while the allowance list permitted \"git checkout <other-branch>\", and `main` is an\n * other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded\n * its only exit was creating a branch, which the branch cap then refused.\n */\nexport class MergedBranchMessage {\n private readonly recovery: TreeRecovery;\n\n /**\n * `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as\n * `cd <treeRoot> && …`. That form is the only one that is correct across tool calls: the harness\n * RESETS a cwd that left the workspace, so an agent in a linked worktree is back in the primary\n * clone by the time it runs the cure, and a bare `git checkout -b` would branch the WRONG tree.\n */\n constructor(private readonly treeRoot: string = '') {\n this.recovery = new TreeRecovery(treeRoot);\n }\n\n /**\n * The ONE allowance list, shared by every guard that blocks while this state is up.\n *\n * Each guard used to print its own view of the world: this one's narrow bash allowlist, and\n * read-stale-guard's \"EVERY Bash command\". Both statements were true of their own guard and false\n * of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all\n * Bash runs and that most Bash is blocked. One list, printed by both.\n */\n private allowances(kind: TreeKind): string[] {\n const switching = kind === 'worktree'\n ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +\n 'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'\n : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +\n '`git checkout main && git pull origin main && pnpm wp-cleanup` is allowed and is the ' +\n 'shortest exit; also git worktree add/remove/prune';\n return [\n 'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',\n ' - the fresh-start / cleanup git commands above',\n ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',\n switching,\n ' - pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',\n ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',\n ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',\n '',\n 'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',\n '(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',\n 'One list — all three guards print exactly this one.',\n ];\n }\n\n // The diagnosis + cure. Identical for both guards — this is the part that must never drift.\n private common(branch: string, mergedPr: string, kind: TreeKind, worktreePath: string): string[] {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n const where = kind === 'worktree' ? 'worktree' : 'branch';\n const lines = [\n `It looks like you forgot to clean up this ${where} \"${branch}\" — its PR is already merged into main${pr}.`,\n // Name the tree that was judged. With several agents running in parallel worktrees, a guard\n // that reasons from the shell cwd can block a command while citing an UNRELATED agent's\n // branch — observed live. Printing the directory makes a wrong judgement visible instead of\n // baffling, and lets the reader see immediately that it is not the tree they meant.\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),\n 'Your work is in main — do NOT keep working here (you will reconflict with main).',\n '',\n ...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),\n ];\n\n // Only when we KNOW we are in a dead worktree: the branch cure alone leaves the worktree\n // sitting there, spending the worktree budget (branch-creation-guard.maxWorktrees) forever.\n if (kind === 'worktree') {\n lines.push('', 'Then reap this dead worktree:', ...this.recovery.cleanupSteps(kind, branch, worktreePath).slice(-1));\n }\n return lines;\n }\n\n forEdits(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message\n * has to spell out the narrow allowlist — otherwise an agent reads \"blocked\" and believes it is\n * wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the\n * `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit\n * the guard again.\n */\n forBash(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Bash is blocked here because working on a merged branch (booting servers, running builds,',\n 'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Read variant. Says WHY a read (not an edit) is blocked — reading this branch feeds the AI a\n * pre-merge snapshot of the codebase and every plan built on it is built on code main has already\n * moved past — and spells out the escape valves so the agent never believes it is stuck.\n */\n forReads(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',\n 'stale relative to origin/main, and a plan built on it is built on code that has moved.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n}\n"]}
1
+ {"version":3,"file":"merged-branch-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-message.ts"],"names":[],"mappings":"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","sourcesContent":["import { TreeRecovery, TreeKind } from './tree-recovery';\n\n/**\n * The \"this branch is already merged, start fresh\" text, shared by the TWO guards that detect the\n * state from the same cached signal (`MainSyncStatus.branchAlreadyMerged`):\n *\n * - feature-branch-guard blocks Write/Edit → {@link MergedBranchMessage.forEdits}\n * - read-stale-guard blocks Read → {@link MergedBranchMessage.forReads}\n *\n * One source of truth on purpose: the recovery steps are instructions the AI follows LITERALLY, so\n * two drifting copies would mean two different behaviors for the same repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree\n * we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove\n * this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.\n *\n * ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —\n * main is checked out in the primary clone). In the primary clone it is a perfectly good move and the\n * allowance list below says so explicitly. The two used to disagree inside a single message — the\n * header forbade it while the allowance list permitted \"git checkout <other-branch>\", and `main` is an\n * other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded\n * its only exit was creating a branch, which the branch cap then refused.\n */\nexport class MergedBranchMessage {\n private readonly recovery: TreeRecovery;\n\n /**\n * `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as\n * `cd <treeRoot> && …`. That form is the only one that is correct across tool calls: the harness\n * RESETS a cwd that left the workspace, so an agent in a linked worktree is back in the primary\n * clone by the time it runs the cure, and a bare `git checkout -b` would branch the WRONG tree.\n */\n constructor(private readonly treeRoot: string = '') {\n this.recovery = new TreeRecovery(treeRoot);\n }\n\n /**\n * The ONE allowance list, shared by every guard that blocks while this state is up.\n *\n * Each guard used to print its own view of the world: this one's narrow bash allowlist, and\n * read-stale-guard's \"EVERY Bash command\". Both statements were true of their own guard and false\n * of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all\n * Bash runs and that most Bash is blocked. One list, printed by both.\n */\n private allowances(kind: TreeKind): string[] {\n const switching = kind === 'worktree'\n ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +\n 'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'\n : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +\n '`pnpm wp-checkout-clean-main` (checkout main, pull it, reap dead branches and ' +\n 'worktrees, sweep orphan directories) is allowed and is the shortest exit; also ' +\n 'git worktree add/remove/prune';\n return [\n 'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',\n ' - the fresh-start / cleanup git commands above',\n ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',\n switching,\n ' - pnpm wp-checkout-clean-main, pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',\n ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',\n ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',\n '',\n 'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',\n '(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',\n 'One list — all three guards print exactly this one.',\n ];\n }\n\n // The diagnosis + cure. Identical for both guards — this is the part that must never drift.\n private common(branch: string, mergedPr: string, kind: TreeKind, worktreePath: string): string[] {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n const where = kind === 'worktree' ? 'worktree' : 'branch';\n const lines = [\n `It looks like you forgot to clean up this ${where} \"${branch}\" — its PR is already merged into main${pr}.`,\n // Name the tree that was judged. With several agents running in parallel worktrees, a guard\n // that reasons from the shell cwd can block a command while citing an UNRELATED agent's\n // branch — observed live. Printing the directory makes a wrong judgement visible instead of\n // baffling, and lets the reader see immediately that it is not the tree they meant.\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),\n 'Your work is in main — do NOT keep working here (you will reconflict with main).',\n '',\n ...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),\n ];\n\n // Only when we KNOW we are in a dead worktree: the branch cure alone leaves the worktree\n // sitting there, spending the worktree budget (branch-creation-guard.maxWorktrees) forever.\n if (kind === 'worktree') {\n lines.push('', 'Then reap this dead worktree:', ...this.recovery.cleanupSteps(kind, branch, worktreePath).slice(-1));\n }\n return lines;\n }\n\n forEdits(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message\n * has to spell out the narrow allowlist — otherwise an agent reads \"blocked\" and believes it is\n * wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the\n * `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit\n * the guard again.\n */\n forBash(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Bash is blocked here because working on a merged branch (booting servers, running builds,',\n 'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Read variant. Says WHY a read (not an edit) is blocked — reading this branch feeds the AI a\n * pre-merge snapshot of the codebase and every plan built on it is built on code main has already\n * moved past — and spells out the escape valves so the agent never believes it is stuck.\n */\n forReads(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',\n 'stale relative to origin/main, and a plan built on it is built on code that has moved.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n}\n"]}
@@ -109,10 +109,16 @@ class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
109
109
  if (switches.some((s) => !this.switches.isExistingMain(s))) {
110
110
  return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');
111
111
  }
112
- // The recommended `git checkout main && git pull origin main` — but ONLY in the primary
113
- // clone. Inside a linked worktree that checkout FATALS ("'main' is already checked out at
114
- // <primary>"), so waving it through here hands the AI a command that cannot work and costs
112
+ // The hand-rolled `git checkout main && git pull origin main` — still ALLOWED, but ONLY in the
113
+ // primary clone. Inside a linked worktree that checkout FATALS ("'main' is already checked out
114
+ // at <primary>"), so waving it through here hands the AI a command that cannot work and costs
115
115
  // it a turn to discover. Steer to the fetch, which is all a worktree needs.
116
+ //
117
+ // Note this rule stays a pure ALLOW for the raw pair and always will: the workflow layer now
118
+ // PRESCRIBES `pnpm wp-checkout-clean-main` (see TreeRecovery.updateMainSteps, which block()
119
+ // below renders), but prescribing is not blocking. The pair is plain git, and it is the L0
120
+ // recovery cure — a state in which no `pnpm` bin can be trusted to run — so making it a block
121
+ // here would delete the only escape from a deadlock. What changed is what the guards TEACH.
116
122
  if (switches.length > 0) {
117
123
  if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree')
118
124
  return null;
@@ -1 +1 @@
1
- {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { PrLifecycleGuardConfig, PR_LIFECYCLE_GUARD_KEY, RepoRootFinder, SyncFlowGuidance, writeTemplate } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitch, BranchSwitchScan } from './branch-switch-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst GUIDANCE = new SyncFlowGuidance();\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + '\\n'\n + GUIDANCE.flows().join('\\n') + '\\n'\n + '\\n'\n // The fork-point DERIVATION (why the invariant has two halves, and the three consumers a polluted\n // fork point corrupts) and the WORD-FOR-WORD warning to hand the human are NOT here on purpose.\n // Both are stated in full in the git-workflow doc this hint links by absolute path, and a message\n // that re-derives what the linked doc already says is a second copy to keep in step. What survives\n // here is only what changes what the AI types next.\n + 'Either flow does a 3-point merge, which keeps the fork point a pure main commit — what the build\\n'\n + 'gate\\'s --base and the PR review diff are computed from; a raw merge/rebase destroys it. The\\n'\n + 'gated commands merge internally as child processes this hook never sees.\\n'\n + '\\n'\n + GUIDANCE.readOnlyChecks().join('\\n') + '\\n'\n + '\\n'\n // The \"do not run it, ask the human, tell them to push back\" instruction is NOT repeated here: the\n // violation line the AI reads first already says it. This paragraph's job is only the pointer.\n // The liveness phrase is kept on ONE line deliberately: it is asserted in full, and a wrap would\n // split the assertion's substring across two chunks. The retired wording (\"a branch you do not own\")\n // was pinned only by a `never sync a branch` PREFIX, which passed under both framings — which is how\n // this hint kept teaching the removed rule after the doc it links had replaced it.\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\\n'\n + 'flow (incl. worktrees), the fork-point invariant, why you must\\n'\n + 'never sync a branch something is ACTIVELY holding (liveness, not authorship — a finished agent\\n'\n + 'holds nothing), and the exact words to warn a human with when you ask them to run a merge.\\n'\n // NOT \"add that info to memory\": \"that info\" was the whole git-workflow doc, which is REGENERATED\n // per repo and per version — memorizing it guarantees a stale recall in the next session. Only the\n // invariant is stable enough to persist; the doc must be re-read, never remembered.\n + 'Add to memory: never run a raw `git merge`/`git rebase` — use this repo\\'s gated sync flow and\\n'\n + 're-read its git-workflow doc each time rather than recalling it (regenerated per repo/version).',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\n// Typed as a query (\"would this fast-forward?\"), but a successful --ff-only IS the merge.\nconst FF_ONLY = /--ff-only\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<PrLifecycleGuardConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n // \"Which branch does this land on?\" — see branch-switch-scan.ts. It replaced a pair of regexes\n // here (`/git\\s+(?:checkout|switch)\\s+main\\b/` and its negative-lookahead twin) that assumed the\n // branch name is the token immediately after the subcommand. One flag broke both halves at once:\n // `git checkout -q main && git pull -q origin main` missed the main EXEMPTION and then tripped the\n // feature-switch block, so this guard rejected the very command stale-main-bash-guard prescribes,\n // with a reason (\"switches to a feature branch\") that was the opposite of the truth.\n private readonly switches = new BranchSwitchScan(this.scanner);\n\n constructor(config: PrLifecycleGuardConfig) { super(config, 'redirect-how-to-merge-main', PR_LIFECYCLE_GUARD_KEY); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n // `--ff-only` reads like a probe (\"can I fast-forward?\") but it MUTATES whenever the answer\n // is yes, so say that here — the AI that typed it was usually only trying to look.\n const probe = FF_ONLY.test(segment)\n ? ' `--ff-only` is NOT a read-only check — it moves your branch whenever it succeeds; see the read-only checks below.'\n : '';\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.' + probe);\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n // The two branch questions are COMPLEMENTS of one parse, so they are asked of one parse —\n // that is what keeps them from disagreeing. `git branch -D <x>` is not a checkout so it does\n // not trip this, and `git checkout -` (previous branch, unknowable at hook time) lands\n // nowhere we can name, so it counts as neither.\n const switches = this.switches.switchesIn(ctx.command);\n if (switches.some((s: BranchSwitch): boolean => !this.switches.isExistingMain(s))) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main` — but ONLY in the primary\n // clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out at\n // <primary>\"), so waving it through here hands the AI a command that cannot work and costs\n // it a turn to discover. Steer to the fetch, which is all a worktree needs.\n if (switches.length > 0) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // block() appends updateMainSteps for the tree we are in — don't say it twice here.\n return this.block(ctx, segment, 'Blocked.');\n }\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n // Materialize the doc we are about to send the AI to. This guard fires long before any `wp-*`\n // command runs (those are what normally write it), so the linked path could easily not exist —\n // and a STALE copy from an older @webpieces is just as misleading, hence overwrite.\n writeTemplate(ctx.workspaceRoot, INSTRUCT_FILE);\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n // \"How do I get MAIN itself current?\" is a different question from \"sync my feature branch\",\n // and it used to have no answer anywhere on this path — the flows cover feature branches and\n // the read-only checks cover looking. An AI on main with no third option improvises, and what\n // it improvises is `git reset --hard origin/main`. Answer the question instead, shaped to the\n // tree we are actually in (in a worktree `git checkout main` fatals).\n const updateMain = [\n '',\n 'On main and just wanted to bring MAIN itself up to date? That is a different question from',\n 'syncing a feature branch, and merge/reset is not the answer to it:',\n ...this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)),\n ].join('\\n');\n return new V(\n 1,\n truncate(segment),\n `${what} Use the gated 3-point flow instead: 'pnpm wp-start-update' → 'pnpm wp-finish-update' when NO PR is open, or 'pnpm wp-start-upsert-pr' → 'pnpm wp-finish-upsert-pr' when a PR IS open (required then — the merge rewrites the branch and the PR must be re-pointed in the same run). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.${updateMain}`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AAEzC,0DAA0I;AAG1I,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAsC;AACtC,kDAAiD;AACjD,mDAA+C;AAC/C,6DAAsE;AAEtE,MAAM,aAAa,GAAG,2BAA2B,CAAC;AAClD,MAAM,QAAQ,GAAG,IAAI,+BAAgB,EAAE,CAAC;AAExC,MAAM,QAAQ,GAAG,IAAI,kBAAO,CACxB,8EAA8E,EAC9E,sDAAsD;MACpD,IAAI;MACJ,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI;MAClC,IAAI;IACN,kGAAkG;IAClG,gGAAgG;IAChG,kGAAkG;IAClG,mGAAmG;IACnG,oDAAoD;MAClD,oGAAoG;MACpG,gGAAgG;MAChG,4EAA4E;MAC5E,IAAI;MACJ,QAAQ,CAAC,cAAc,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI;MAC3C,IAAI;IACN,mGAAmG;IACnG,+FAA+F;IAC/F,iGAAiG;IACjG,qGAAqG;IACrG,qGAAqG;IACrG,mFAAmF;MACjF,qGAAqG;MACrG,kEAAkE;MAClE,kGAAkG;MAClG,8FAA8F;IAChG,kGAAkG;IAClG,mGAAmG;IACnG,oFAAoF;MAClF,kGAAkG;MAClG,iGAAiG,CACtG,CAAC;AAEF,uGAAuG;AACvG,+FAA+F;AAC/F,qGAAqG;AACrG,iFAAiF;AACjF,MAAM,SAAS,GAAG,oBAAoB,CAAC;AAEvC,0FAA0F;AAC1F,MAAM,OAAO,GAAG,aAAa,CAAC;AAE9B,SAAS,QAAQ,CAAC,CAAS;IACvB,MAAM,GAAG,GAAG,GAAG,CAAC;IAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;AACvD,CAAC;AAED,MAAa,0BAA2B,SAAQ,wBAAoC;IAC/D,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC;IAC/B,QAAQ,GAAG,IAAI,4BAAY,EAAE,CAAC;IAC/C,+FAA+F;IAC/F,iGAAiG;IACjG,iGAAiG;IACjG,mGAAmG;IACnG,kGAAkG;IAClG,qFAAqF;IACpE,QAAQ,GAAG,IAAI,qCAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAE/D,YAAY,MAA8B,IAAI,KAAK,CAAC,MAAM,EAAE,4BAA4B,EAAE,qCAAsB,CAAC,CAAC,CAAC,CAAC;IAE3G,WAAW,GAAG,kJAAkJ,CAAC;IACjK,OAAO,GAAG,QAAQ,CAAC;IAE5B,KAAK,CAAC,GAAgB;QAClB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YAC9D,MAAM,SAAS,GAAG,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAClD,IAAI,SAAS,KAAK,IAAI;gBAAE,OAAO,CAAC,SAAS,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,YAAY,CAAC,GAAgB,EAAE,OAAe;QAClD,uEAAuE;QACvE,EAAE;QACF,+FAA+F;QAC/F,0FAA0F;QAC1F,4FAA4F;QAC5F,0FAA0F;QAC1F,6FAA6F;QAC7F,uEAAuE;QACvE,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC;YAC1F,IAAI,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC;gBAAE,OAAO,IAAI,CAAC;YACzC,4FAA4F;YAC5F,mFAAmF;YACnF,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC;gBAC/B,CAAC,CAAC,oHAAoH;gBACtH,CAAC,CAAC,EAAE,CAAC;YACT,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,+EAA+E,GAAG,KAAK,CAAC,CAAC;QAC7H,CAAC;QAED,0EAA0E;QAC1E,0FAA0F;QAC1F,iFAAiF;QACjF,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAChF,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACxC,CAAC;QAED,OAAO,IAAI,CAAC;IAChB,CAAC;IAEO,SAAS,CAAC,GAAgB,EAAE,OAAe;QAC/C,0FAA0F;QAC1F,6FAA6F;QAC7F,uFAAuF;QACvF,gDAAgD;QAChD,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACvD,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAe,EAAW,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAChF,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,iFAAiF,CAAC,CAAC;QACvH,CAAC;QACD,+FAA+F;QAC/F,+FAA+F;QAC/F,8FAA8F;QAC9F,4EAA4E;QAC5E,EAAE;QACF,6FAA6F;QAC7F,4FAA4F;QAC5F,2FAA2F;QAC3F,8FAA8F;QAC9F,4FAA4F;QAC5F,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACtB,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,UAAU;gBAAE,OAAO,IAAI,CAAC;YACxE,oFAAoF;YACpF,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC;QAChD,CAAC;QAED,MAAM,aAAa,GAAG,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;YAC9D,GAAG,EAAE,GAAG,CAAC,aAAa;YACtB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC,IAAI,EAAE,CAAC;QACV,IAAI,aAAa,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QAE1C,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,qCAAqC,aAAa,eAAe,CAAC,CAAC;IACvG,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,OAAe,EAAE,IAAY;QACzD,8FAA8F;QAC9F,+FAA+F;QAC/F,oFAAoF;QACpF,IAAA,4BAAa,EAAC,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;QAChD,MAAM,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC,iBAAiB,CAAC,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;QACzF,6FAA6F;QAC7F,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,sEAAsE;QACtE,MAAM,UAAU,GAAG;YACf,EAAE;YACF,4FAA4F;YAC5F,oEAAoE;YACpE,GAAG,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;SAC5E,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACb,OAAO,IAAI,iBAAC,CACR,CAAC,EACD,QAAQ,CAAC,OAAO,CAAC,EACjB,GAAG,IAAI,oYAAoY,OAAO,IAAI,UAAU,EAAE,CACra,CAAC;IACN,CAAC;CACJ;AA9GD,gEA8GC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { PrLifecycleGuardConfig, PR_LIFECYCLE_GUARD_KEY, RepoRootFinder, SyncFlowGuidance, writeTemplate } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitch, BranchSwitchScan } from './branch-switch-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst GUIDANCE = new SyncFlowGuidance();\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + '\\n'\n + GUIDANCE.flows().join('\\n') + '\\n'\n + '\\n'\n // The fork-point DERIVATION (why the invariant has two halves, and the three consumers a polluted\n // fork point corrupts) and the WORD-FOR-WORD warning to hand the human are NOT here on purpose.\n // Both are stated in full in the git-workflow doc this hint links by absolute path, and a message\n // that re-derives what the linked doc already says is a second copy to keep in step. What survives\n // here is only what changes what the AI types next.\n + 'Either flow does a 3-point merge, which keeps the fork point a pure main commit — what the build\\n'\n + 'gate\\'s --base and the PR review diff are computed from; a raw merge/rebase destroys it. The\\n'\n + 'gated commands merge internally as child processes this hook never sees.\\n'\n + '\\n'\n + GUIDANCE.readOnlyChecks().join('\\n') + '\\n'\n + '\\n'\n // The \"do not run it, ask the human, tell them to push back\" instruction is NOT repeated here: the\n // violation line the AI reads first already says it. This paragraph's job is only the pointer.\n // The liveness phrase is kept on ONE line deliberately: it is asserted in full, and a wrap would\n // split the assertion's substring across two chunks. The retired wording (\"a branch you do not own\")\n // was pinned only by a `never sync a branch` PREFIX, which passed under both framings — which is how\n // this hint kept teaching the removed rule after the doc it links had replaced it.\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\\n'\n + 'flow (incl. worktrees), the fork-point invariant, why you must\\n'\n + 'never sync a branch something is ACTIVELY holding (liveness, not authorship — a finished agent\\n'\n + 'holds nothing), and the exact words to warn a human with when you ask them to run a merge.\\n'\n // NOT \"add that info to memory\": \"that info\" was the whole git-workflow doc, which is REGENERATED\n // per repo and per version — memorizing it guarantees a stale recall in the next session. Only the\n // invariant is stable enough to persist; the doc must be re-read, never remembered.\n + 'Add to memory: never run a raw `git merge`/`git rebase` — use this repo\\'s gated sync flow and\\n'\n + 're-read its git-workflow doc each time rather than recalling it (regenerated per repo/version).',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\n// Typed as a query (\"would this fast-forward?\"), but a successful --ff-only IS the merge.\nconst FF_ONLY = /--ff-only\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<PrLifecycleGuardConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n // \"Which branch does this land on?\" — see branch-switch-scan.ts. It replaced a pair of regexes\n // here (`/git\\s+(?:checkout|switch)\\s+main\\b/` and its negative-lookahead twin) that assumed the\n // branch name is the token immediately after the subcommand. One flag broke both halves at once:\n // `git checkout -q main && git pull -q origin main` missed the main EXEMPTION and then tripped the\n // feature-switch block, so this guard rejected the very command stale-main-bash-guard prescribes,\n // with a reason (\"switches to a feature branch\") that was the opposite of the truth.\n private readonly switches = new BranchSwitchScan(this.scanner);\n\n constructor(config: PrLifecycleGuardConfig) { super(config, 'redirect-how-to-merge-main', PR_LIFECYCLE_GUARD_KEY); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n // `--ff-only` reads like a probe (\"can I fast-forward?\") but it MUTATES whenever the answer\n // is yes, so say that here — the AI that typed it was usually only trying to look.\n const probe = FF_ONLY.test(segment)\n ? ' `--ff-only` is NOT a read-only check — it moves your branch whenever it succeeds; see the read-only checks below.'\n : '';\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.' + probe);\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n // The two branch questions are COMPLEMENTS of one parse, so they are asked of one parse —\n // that is what keeps them from disagreeing. `git branch -D <x>` is not a checkout so it does\n // not trip this, and `git checkout -` (previous branch, unknowable at hook time) lands\n // nowhere we can name, so it counts as neither.\n const switches = this.switches.switchesIn(ctx.command);\n if (switches.some((s: BranchSwitch): boolean => !this.switches.isExistingMain(s))) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The hand-rolled `git checkout main && git pull origin main` — still ALLOWED, but ONLY in the\n // primary clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out\n // at <primary>\"), so waving it through here hands the AI a command that cannot work and costs\n // it a turn to discover. Steer to the fetch, which is all a worktree needs.\n //\n // Note this rule stays a pure ALLOW for the raw pair and always will: the workflow layer now\n // PRESCRIBES `pnpm wp-checkout-clean-main` (see TreeRecovery.updateMainSteps, which block()\n // below renders), but prescribing is not blocking. The pair is plain git, and it is the L0\n // recovery cure — a state in which no `pnpm` bin can be trusted to run — so making it a block\n // here would delete the only escape from a deadlock. What changed is what the guards TEACH.\n if (switches.length > 0) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // block() appends updateMainSteps for the tree we are in — don't say it twice here.\n return this.block(ctx, segment, 'Blocked.');\n }\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n // Materialize the doc we are about to send the AI to. This guard fires long before any `wp-*`\n // command runs (those are what normally write it), so the linked path could easily not exist —\n // and a STALE copy from an older @webpieces is just as misleading, hence overwrite.\n writeTemplate(ctx.workspaceRoot, INSTRUCT_FILE);\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n // \"How do I get MAIN itself current?\" is a different question from \"sync my feature branch\",\n // and it used to have no answer anywhere on this path — the flows cover feature branches and\n // the read-only checks cover looking. An AI on main with no third option improvises, and what\n // it improvises is `git reset --hard origin/main`. Answer the question instead, shaped to the\n // tree we are actually in (in a worktree `git checkout main` fatals).\n const updateMain = [\n '',\n 'On main and just wanted to bring MAIN itself up to date? That is a different question from',\n 'syncing a feature branch, and merge/reset is not the answer to it:',\n ...this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)),\n ].join('\\n');\n return new V(\n 1,\n truncate(segment),\n `${what} Use the gated 3-point flow instead: 'pnpm wp-start-update' → 'pnpm wp-finish-update' when NO PR is open, or 'pnpm wp-start-upsert-pr' → 'pnpm wp-finish-upsert-pr' when a PR IS open (required then — the merge rewrites the branch and the PR must be re-pointed in the same run). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.${updateMain}`,\n );\n }\n}\n"]}
@@ -35,8 +35,11 @@ import { FixHint } from '../fix-hint';
35
35
  *
36
36
  * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`
37
37
  * appears anywhere in the SAME command.
38
- * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the
39
- * exact line the post-merge cleanup flow already prescribes.
38
+ * ALLOWED `git checkout main && git pull origin main`, the pairing this forces and
39
+ * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the
40
+ * orphan-directory sweep welded on. The message prescribes the one command; the raw pair
41
+ * stays legal because it is plain git and because it is the L0 recovery cure, where
42
+ * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.
40
43
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
41
44
  * `git checkout -- <file>`, and any other branch.
42
45
  *
@@ -49,8 +49,11 @@ const recovery_allowlist_1 = require("./recovery-allowlist");
49
49
  *
50
50
  * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`
51
51
  * appears anywhere in the SAME command.
52
- * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the
53
- * exact line the post-merge cleanup flow already prescribes.
52
+ * ALLOWED `git checkout main && git pull origin main`, the pairing this forces and
53
+ * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the
54
+ * orphan-directory sweep welded on. The message prescribes the one command; the raw pair
55
+ * stays legal because it is plain git and because it is the L0 recovery cure, where
56
+ * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.
54
57
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
55
58
  * `git checkout -- <file>`, and any other branch.
56
59
  *
@@ -98,22 +101,24 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
98
101
  // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states
99
102
  // cannot drift apart about what "gets you out" means. See recovery-allowlist.ts.
100
103
  recoveryList = new recovery_allowlist_1.RecoveryAllowlist(this.scanner);
101
- description = 'Block a bare `git checkout main` (chain the pull into the same command), and block Bash on ' +
104
+ description = 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +
105
+ 'the same command), and block Bash on ' +
102
106
  'main outright — allowlisting only the commands that get you off it — so a session neither ' +
103
107
  'lands on main nor works there, whether or not main happens to be current.';
104
108
  defaultOptions = {
105
109
  hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
106
110
  };
107
- fixHint = new fix_hint_1.FixHint('Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.', 'Get onto a feature branch, or pair the checkout with the pull:', [
111
+ fixHint = new fix_hint_1.FixHint('Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.', 'Get onto a feature branch, or go to main with the one command that pulls it too:', [
108
112
  // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no
109
113
  // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for
110
114
  // "we cannot detect the tree": both forms, each labelled. That matters here because the
111
- // primary-clone form (`git checkout main && …`) is BLOCKED by redirect-how-to-merge-main
112
- // inside a linked worktree, so a preferred option naming it unconditionally hands the AI a
113
- // cure a sibling guard denies. The per-block message (pairingMessage) is detected and
114
- // prints exactly one form; this is the fallback for the hint that cannot look.
115
+ // primary-clone form goes to `main`, and a linked worktree has no `main` to go to it is
116
+ // checked out in the primary clone — so a preferred option naming it unconditionally hands
117
+ // the AI a cure that cannot work where it is standing. The per-block message
118
+ // (pairingMessage) is detected and prints exactly one form; this is the fallback for the
119
+ // hint that cannot look.
115
120
  new rules_config_1.Option(this.recovery.updateMainSteps('unknown').join('\n')
116
- + '\nWhichever form applies, the pull must be in the SAME command as the checkout.', true),
121
+ + '\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),
117
122
  new rules_config_1.Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),
118
123
  new rules_config_1.Option('Still allowed on main: everything that gets you OUT or tells you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, gh pr view|list|status|checks, git stash, every wp-* bin, installs, and reading webpieces.config.json.'),
119
124
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
@@ -203,7 +208,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
203
208
  // docblock above — the reader of THIS text needs only what changes what they type.
204
209
  return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had — '
205
210
  + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '
206
- + 'reports the drift BACKWARDS. Chain the pull into the same command:\n' + steps;
211
+ + 'reports the drift BACKWARDS. Go to main with the one command that also pulls it:\n' + steps;
207
212
  }
208
213
  /**
209
214
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.