@webpieces/ai-hook-rules 0.4.494 → 0.4.496
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.
- package/package.json +2 -2
- package/src/core/command-scan.d.ts +6 -0
- package/src/core/command-scan.js +18 -1
- package/src/core/command-scan.js.map +1 -1
- package/src/core/report.d.ts +20 -1
- package/src/core/report.js +28 -3
- package/src/core/report.js.map +1 -1
- package/src/core/rules/branch-creation-guard.d.ts +30 -0
- package/src/core/rules/branch-creation-guard.js +83 -5
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/content-read-scan.d.ts +5 -0
- package/src/core/rules/content-read-scan.js +9 -2
- package/src/core/rules/content-read-scan.js.map +1 -1
- package/src/core/rules/merge-in-progress-guard.js +27 -8
- package/src/core/rules/merge-in-progress-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +10 -0
- package/src/core/rules/merged-branch-bash-guard.js +58 -24
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.d.ts +21 -6
- package/src/core/rules/merged-branch-message.js +45 -15
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/shell-segment-scan.d.ts +55 -0
- package/src/core/rules/shell-segment-scan.js +80 -0
- package/src/core/rules/shell-segment-scan.js.map +1 -0
- package/src/core/rules/tree-recovery.js +12 -2
- package/src/core/rules/tree-recovery.js.map +1 -1
- package/src/core/runner.js +11 -2
- package/src/core/runner.js.map +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +5 -1
- package/src/index.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.496",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"directory": "packages/tooling/ai-hook-rules"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@webpieces/rules-config": "0.4.
|
|
35
|
+
"@webpieces/rules-config": "0.4.496"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -61,6 +61,12 @@ export declare class CommandScanner {
|
|
|
61
61
|
* Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.
|
|
62
62
|
*/
|
|
63
63
|
gitSubcommand(segment: string): string | null;
|
|
64
|
+
/**
|
|
65
|
+
* gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan
|
|
66
|
+
* strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the
|
|
67
|
+
* raw segment text where `do` is the command).
|
|
68
|
+
*/
|
|
69
|
+
gitSubcommandOf(words: readonly string[]): string | null;
|
|
64
70
|
/** True when this segment actually invokes `git <subcommand>`. */
|
|
65
71
|
invokesGit(segment: string, subcommand: string): boolean;
|
|
66
72
|
/** True when ANY segment of the command invokes one of `subcommands`. */
|
package/src/core/command-scan.js
CHANGED
|
@@ -82,6 +82,15 @@ class CommandScanner {
|
|
|
82
82
|
current += ch;
|
|
83
83
|
continue;
|
|
84
84
|
}
|
|
85
|
+
// The `&` of a REDIRECTION (`2>&1`, `1>&2`, `&>log`) is not a separator. Splitting on it
|
|
86
|
+
// tore `git fetch origin main 2>&1 | tail -5` into THREE segments — `git fetch … 2>`, `1`
|
|
87
|
+
// and `tail -5` — and the bare `1` is not an allowlisted command, so every guard that
|
|
88
|
+
// requires all segments to pass denied the command. That is `2>&1`, the single most common
|
|
89
|
+
// decoration an agent appends.
|
|
90
|
+
if (ch === '&' && command[i + 1] !== '&' && (current.trimEnd().endsWith('>') || command[i + 1] === '>')) {
|
|
91
|
+
current += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
85
94
|
if (ch === '\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {
|
|
86
95
|
// Consume the second char of `&&` / `||` so it does not start an empty segment.
|
|
87
96
|
const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;
|
|
@@ -116,7 +125,15 @@ class CommandScanner {
|
|
|
116
125
|
* Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.
|
|
117
126
|
*/
|
|
118
127
|
gitSubcommand(segment) {
|
|
119
|
-
|
|
128
|
+
return this.gitSubcommandOf(this.stripPrefixes(this.tokenize(segment)));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan
|
|
132
|
+
* strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the
|
|
133
|
+
* raw segment text where `do` is the command).
|
|
134
|
+
*/
|
|
135
|
+
gitSubcommandOf(words) {
|
|
136
|
+
const tokens = this.stripPrefixes(words);
|
|
120
137
|
if (tokens.length === 0 || tokens[0] !== 'git')
|
|
121
138
|
return null;
|
|
122
139
|
let i = 1;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\n/**\n * One invoked segment of a command, plus whether a PIPE fed it.\n *\n * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from\n * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command\n * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly\n * that fact away. Data-only, so a class (per CLAUDE.md).\n */\nexport class CommandSegment {\n text: string;\n pipedInto: boolean;\n\n constructor(text: string, pipedInto: boolean) {\n this.text = text;\n this.pipedInto = pipedInto;\n }\n}\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n return this.segmentsWithPipes(command).map((s: CommandSegment): string => s.text);\n }\n\n /**\n * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.\n * Only a guard reasoning about which files a segment reads needs that; everything else uses\n * commandSegments, which is this method with the flag dropped.\n */\n segmentsWithPipes(command: string): readonly CommandSegment[] {\n const segments: CommandSegment[] = [];\n let current = '';\n let quote: string | null = null;\n let piped = false; // was the separator that ENDED the previous segment a pipe?\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;\n if (doubled) i++;\n segments.push(new CommandSegment(current, piped));\n // `|` pipes into the next segment; `||` is a separator, not a pipe.\n piped = ch === '|' && !doubled;\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(new CommandSegment(current, piped));\n\n return segments\n .map((s: CommandSegment): CommandSegment => new CommandSegment(s.text.trim(), s.pipedInto))\n .filter((s: CommandSegment): boolean => s.text.length > 0);\n }\n\n /**\n * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`\n * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that\n * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs\n * the tokens, and re-splitting on whitespace in the guard would get quoting wrong.\n */\n words(segment: string): readonly string[] {\n return this.stripPrefixes(this.tokenize(segment));\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n const tokens = this.stripPrefixes(this.tokenize(segment));\n if (tokens.length === 0 || tokens[0] !== 'git') return null;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return token;\n }\n return null;\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\n/**\n * One invoked segment of a command, plus whether a PIPE fed it.\n *\n * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from\n * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command\n * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly\n * that fact away. Data-only, so a class (per CLAUDE.md).\n */\nexport class CommandSegment {\n text: string;\n pipedInto: boolean;\n\n constructor(text: string, pipedInto: boolean) {\n this.text = text;\n this.pipedInto = pipedInto;\n }\n}\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n return this.segmentsWithPipes(command).map((s: CommandSegment): string => s.text);\n }\n\n /**\n * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe.\n * Only a guard reasoning about which files a segment reads needs that; everything else uses\n * commandSegments, which is this method with the flag dropped.\n */\n segmentsWithPipes(command: string): readonly CommandSegment[] {\n const segments: CommandSegment[] = [];\n let current = '';\n let quote: string | null = null;\n let piped = false; // was the separator that ENDED the previous segment a pipe?\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n // The `&` of a REDIRECTION (`2>&1`, `1>&2`, `&>log`) is not a separator. Splitting on it\n // tore `git fetch origin main 2>&1 | tail -5` into THREE segments — `git fetch … 2>`, `1`\n // and `tail -5` — and the bare `1` is not an allowlisted command, so every guard that\n // requires all segments to pass denied the command. That is `2>&1`, the single most common\n // decoration an agent appends.\n if (ch === '&' && command[i + 1] !== '&' && (current.trimEnd().endsWith('>') || command[i + 1] === '>')) {\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n const doubled = (ch === '|' || ch === '&') && command[i + 1] === ch;\n if (doubled) i++;\n segments.push(new CommandSegment(current, piped));\n // `|` pipes into the next segment; `||` is a separator, not a pipe.\n piped = ch === '|' && !doubled;\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(new CommandSegment(current, piped));\n\n return segments\n .map((s: CommandSegment): CommandSegment => new CommandSegment(s.text.trim(), s.pipedInto))\n .filter((s: CommandSegment): boolean => s.text.length > 0);\n }\n\n /**\n * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')`\n * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that\n * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs\n * the tokens, and re-splitting on whitespace in the guard would get quoting wrong.\n */\n words(segment: string): readonly string[] {\n return this.stripPrefixes(this.tokenize(segment));\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n return this.gitSubcommandOf(this.stripPrefixes(this.tokenize(segment)));\n }\n\n /**\n * gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan\n * strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the\n * raw segment text where `do` is the command).\n */\n gitSubcommandOf(words: readonly string[]): string | null {\n const tokens = this.stripPrefixes(words);\n if (tokens.length === 0 || tokens[0] !== 'git') return null;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return token;\n }\n return null;\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
|
package/src/core/report.d.ts
CHANGED
|
@@ -1,2 +1,21 @@
|
|
|
1
1
|
import type { RuleGroup } from './types';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* How the report names the tool it just blocked. Data-only, so a class (per CLAUDE.md).
|
|
4
|
+
*
|
|
5
|
+
* WHY it is a parameter: the header and footer used to be hard-coded to "write". A blocked READ
|
|
6
|
+
* therefore printed "\u274c webpieces ai-hooks blocked this write:" and closed with "This is a pre-write
|
|
7
|
+
* check. Fix and retry the Write/Edit." \u2014 observed live, and actively misleading, because the agent is
|
|
8
|
+
* told to fix and retry the one tool it did not use. A blocked Bash command printed the same thing
|
|
9
|
+
* against the path `<bash>`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class ReportSubject {
|
|
12
|
+
/** Fills "blocked this ___". */
|
|
13
|
+
noun: string;
|
|
14
|
+
/** The closing line: what kind of check this was and which tool to retry. */
|
|
15
|
+
footer: string;
|
|
16
|
+
constructor(noun: string, footer: string);
|
|
17
|
+
}
|
|
18
|
+
export declare const WRITE_SUBJECT: ReportSubject;
|
|
19
|
+
export declare const READ_SUBJECT: ReportSubject;
|
|
20
|
+
export declare const BASH_SUBJECT: ReportSubject;
|
|
21
|
+
export declare function formatReport(relativePath: string, ruleGroups: readonly RuleGroup[], subject?: ReportSubject): string;
|
package/src/core/report.js
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BASH_SUBJECT = exports.READ_SUBJECT = exports.WRITE_SUBJECT = exports.ReportSubject = void 0;
|
|
3
4
|
exports.formatReport = formatReport;
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* How the report names the tool it just blocked. Data-only, so a class (per CLAUDE.md).
|
|
7
|
+
*
|
|
8
|
+
* WHY it is a parameter: the header and footer used to be hard-coded to "write". A blocked READ
|
|
9
|
+
* therefore printed "\u274c webpieces ai-hooks blocked this write:" and closed with "This is a pre-write
|
|
10
|
+
* check. Fix and retry the Write/Edit." \u2014 observed live, and actively misleading, because the agent is
|
|
11
|
+
* told to fix and retry the one tool it did not use. A blocked Bash command printed the same thing
|
|
12
|
+
* against the path `<bash>`.
|
|
13
|
+
*/
|
|
14
|
+
class ReportSubject {
|
|
15
|
+
/** Fills "blocked this ___". */
|
|
16
|
+
noun;
|
|
17
|
+
/** The closing line: what kind of check this was and which tool to retry. */
|
|
18
|
+
footer;
|
|
19
|
+
constructor(noun, footer) {
|
|
20
|
+
this.noun = noun;
|
|
21
|
+
this.footer = footer;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.ReportSubject = ReportSubject;
|
|
25
|
+
exports.WRITE_SUBJECT = new ReportSubject('write', 'This is a pre-write check. Fix and retry the Write/Edit.');
|
|
26
|
+
exports.READ_SUBJECT = new ReportSubject('read', 'This is a pre-read check. Follow the fix above, then retry the Read.');
|
|
27
|
+
exports.BASH_SUBJECT = new ReportSubject('command', 'This is a pre-run check. Follow the fix above, then retry the command.');
|
|
28
|
+
// webpieces-disable no-function-outside-class -- pre-existing shape: this whole module is the report formatter as module-scope functions, and a lone class here would break it
|
|
29
|
+
function formatReport(relativePath, ruleGroups, subject = exports.WRITE_SUBJECT) {
|
|
5
30
|
const lines = [];
|
|
6
|
-
lines.push(`\u274c webpieces ai-hooks blocked this
|
|
31
|
+
lines.push(`\u274c webpieces ai-hooks blocked this ${subject.noun}: ${relativePath}`);
|
|
7
32
|
lines.push('');
|
|
8
33
|
for (const group of ruleGroups) {
|
|
9
34
|
const count = group.violations.length;
|
|
@@ -37,7 +62,7 @@ function formatReport(relativePath, ruleGroups) {
|
|
|
37
62
|
}
|
|
38
63
|
lines.push('');
|
|
39
64
|
}
|
|
40
|
-
lines.push(
|
|
65
|
+
lines.push(subject.footer);
|
|
41
66
|
lines.push('');
|
|
42
67
|
return lines.join('\n');
|
|
43
68
|
}
|
package/src/core/report.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/report.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"report.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/report.ts"],"names":[],"mappings":"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","sourcesContent":["import type { RuleGroup, Violation } from './types';\nimport type { Option } from './fix-hint';\n\n/**\n * How the report names the tool it just blocked. Data-only, so a class (per CLAUDE.md).\n *\n * WHY it is a parameter: the header and footer used to be hard-coded to \"write\". A blocked READ\n * therefore printed \"\\u274c webpieces ai-hooks blocked this write:\" and closed with \"This is a pre-write\n * check. Fix and retry the Write/Edit.\" \\u2014 observed live, and actively misleading, because the agent is\n * told to fix and retry the one tool it did not use. A blocked Bash command printed the same thing\n * against the path `<bash>`.\n */\nexport class ReportSubject {\n /** Fills \"blocked this ___\". */\n noun: string;\n /** The closing line: what kind of check this was and which tool to retry. */\n footer: string;\n\n constructor(noun: string, footer: string) {\n this.noun = noun;\n this.footer = footer;\n }\n}\n\nexport const WRITE_SUBJECT = new ReportSubject(\n 'write', 'This is a pre-write check. Fix and retry the Write/Edit.');\nexport const READ_SUBJECT = new ReportSubject(\n 'read', 'This is a pre-read check. Follow the fix above, then retry the Read.');\nexport const BASH_SUBJECT = new ReportSubject(\n 'command', 'This is a pre-run check. Follow the fix above, then retry the command.');\n\n// webpieces-disable no-function-outside-class -- pre-existing shape: this whole module is the report formatter as module-scope functions, and a lone class here would break it\nexport function formatReport(\n relativePath: string,\n ruleGroups: readonly RuleGroup[],\n subject: ReportSubject = WRITE_SUBJECT,\n): string {\n const lines: string[] = [];\n lines.push(`\\u274c webpieces ai-hooks blocked this ${subject.noun}: ${relativePath}`);\n lines.push('');\n\n for (const group of ruleGroups) {\n const count = group.violations.length;\n const label = count === 1 ? '1 violation' : `${count} violations`;\n lines.push(`[${group.ruleName}] (${label})`);\n const fh = group.fixHint;\n for (const v of group.violations) {\n const editPrefix = formatEditPrefix(v);\n lines.push(` ${editPrefix}L${String(v.line)}: ${v.snippet}`);\n // Per-occurrence override (dynamic rules), else the rule-level FixHint.violation.\n lines.push(` \\u2192 ${v.message ?? fh.violation}`);\n }\n // mainMessage may be '' (guidance already on the violation line) \\u2014 skip when empty.\n if (fh.mainMessage) for (const l of fh.mainMessage.split('\\n')) lines.push(` ${l}`);\n // \"Fix Option N:\" numbering + \"(preferred)\" are framework-owned so a multi-line message\n // can never become fake options and authors never hand-write those labels.\n fh.fixOptions.forEach((opt: Option, i: number) => {\n const optLines = opt.text.split('\\n');\n const tag = opt.preferred ? '(preferred) ' : '';\n lines.push(` Fix Option ${String(i + 1)}: ${tag}${optLines[0]}`);\n for (const l of optLines.slice(1)) lines.push(` ${l}`);\n });\n // Framework-owned disable escape (only the 9 disable-able code-style rules set this).\n if (fh.escape) {\n lines.push(fh.escape.allowed\n ? ` Escape (if truly needed): ${fh.escape.comment}`\n : ' \\u{1F512} The team disabled escaping via webpieces-disable for this rule (disableAllowed:false) — it must be followed.');\n }\n lines.push('');\n }\n\n lines.push(subject.footer);\n lines.push('');\n return lines.join('\\n');\n}\n\nfunction formatEditPrefix(v: Violation): string {\n if (v.editIndex !== undefined && v.editCount !== undefined && v.editCount > 1) {\n return `edit ${String(v.editIndex + 1)}/${String(v.editCount)} `;\n }\n return '';\n}\n"]}
|
|
@@ -72,6 +72,22 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
72
72
|
* so the cap starts enforcing on its own.
|
|
73
73
|
*/
|
|
74
74
|
private checkBranchCap;
|
|
75
|
+
/**
|
|
76
|
+
* The branch cap, yielding by ONE when the agent is standing on an already-merged branch.
|
|
77
|
+
*
|
|
78
|
+
* Two individually-correct guards were composing into a trap: merged-branch-bash-guard blocks
|
|
79
|
+
* almost all Bash until you get off a merged branch, and the ONLY way off it that guard advertises
|
|
80
|
+
* is creating a fresh branch — which this cap then refused. Every printed exit led to editing
|
|
81
|
+
* webpieces.config.json, and that is what an unsupervised agent did.
|
|
82
|
+
*
|
|
83
|
+
* One over cap, and only while a merged branch is what is pushing you: the branch about to be
|
|
84
|
+
* created replaces a branch that is already dead, so the steady-state count does not grow. The cap
|
|
85
|
+
* still fires on the NEXT creation, so this defers the cleanup by exactly one branch, never skips it.
|
|
86
|
+
*
|
|
87
|
+
* Fails toward the strict cap: no cache, a cache for another branch, or a clean branch → no yield.
|
|
88
|
+
*/
|
|
89
|
+
private effectiveBranchCap;
|
|
90
|
+
private currentBranchOrNull;
|
|
75
91
|
/**
|
|
76
92
|
* The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a
|
|
77
93
|
* worktree list we cannot classify (no cache on disk) blocks nothing.
|
|
@@ -95,6 +111,20 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
95
111
|
* a message that tells an agent to delete has to be exactly true about why that's safe.
|
|
96
112
|
*/
|
|
97
113
|
private capFixHint;
|
|
114
|
+
/**
|
|
115
|
+
* The remedy that actually deletes something without loosening anything: SHOW the spared branches
|
|
116
|
+
* and ASK the human which may go.
|
|
117
|
+
*
|
|
118
|
+
* `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work
|
|
119
|
+
* is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never
|
|
120
|
+
* can, and it was never printed: the cap said "N branches were SPARED, do not delete those" and
|
|
121
|
+
* then offered only config edits. So the agent edited the config.
|
|
122
|
+
*
|
|
123
|
+
* Every column is read straight off `.webpieces/merged-branches.json` (written by the detached
|
|
124
|
+
* refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see
|
|
125
|
+
* the delete is reversible; the commit count is there so "0 commits" branches are obvious yeses.
|
|
126
|
+
*/
|
|
127
|
+
private askHumanOption;
|
|
98
128
|
/**
|
|
99
129
|
* The worktree reap instructions.
|
|
100
130
|
*
|
|
@@ -321,7 +321,8 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
321
321
|
const parked = this.mergedBranches.localBranches(ctx.workspaceRoot)
|
|
322
322
|
.filter((branch) => !held.has(branch));
|
|
323
323
|
const count = parked.length;
|
|
324
|
-
|
|
324
|
+
const cap = this.effectiveBranchCap(ctx);
|
|
325
|
+
if (count < cap)
|
|
325
326
|
return null;
|
|
326
327
|
const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);
|
|
327
328
|
if (!cache)
|
|
@@ -335,6 +336,42 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
335
336
|
`the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +
|
|
336
337
|
`${detail} Clean up before creating another.`);
|
|
337
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* The branch cap, yielding by ONE when the agent is standing on an already-merged branch.
|
|
341
|
+
*
|
|
342
|
+
* Two individually-correct guards were composing into a trap: merged-branch-bash-guard blocks
|
|
343
|
+
* almost all Bash until you get off a merged branch, and the ONLY way off it that guard advertises
|
|
344
|
+
* is creating a fresh branch — which this cap then refused. Every printed exit led to editing
|
|
345
|
+
* webpieces.config.json, and that is what an unsupervised agent did.
|
|
346
|
+
*
|
|
347
|
+
* One over cap, and only while a merged branch is what is pushing you: the branch about to be
|
|
348
|
+
* created replaces a branch that is already dead, so the steady-state count does not grow. The cap
|
|
349
|
+
* still fires on the NEXT creation, so this defers the cleanup by exactly one branch, never skips it.
|
|
350
|
+
*
|
|
351
|
+
* Fails toward the strict cap: no cache, a cache for another branch, or a clean branch → no yield.
|
|
352
|
+
*/
|
|
353
|
+
effectiveBranchCap(ctx) {
|
|
354
|
+
const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot);
|
|
355
|
+
if (status === null || !status.branchAlreadyMerged)
|
|
356
|
+
return this.maxLocalBranches;
|
|
357
|
+
const current = this.currentBranchOrNull(ctx.workspaceRoot);
|
|
358
|
+
if (current === null || status.branch !== current)
|
|
359
|
+
return this.maxLocalBranches;
|
|
360
|
+
return this.maxLocalBranches + 1;
|
|
361
|
+
}
|
|
362
|
+
currentBranchOrNull(workspaceRoot) {
|
|
363
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
364
|
+
try {
|
|
365
|
+
return (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
|
|
366
|
+
cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
367
|
+
}).trim();
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
const error = (0, to_error_1.toError)(err);
|
|
371
|
+
void error;
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
338
375
|
/**
|
|
339
376
|
* The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a
|
|
340
377
|
* worktree list we cannot classify (no cache on disk) blocks nothing.
|
|
@@ -374,6 +411,11 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
374
411
|
*/
|
|
375
412
|
capFixHint(cache) {
|
|
376
413
|
const options = [];
|
|
414
|
+
// Nothing auto-reapable → the ASK is the preferred move, and it comes FIRST. This is the whole
|
|
415
|
+
// point of the option: with an empty `deletable` list the only advice left used to be "raise
|
|
416
|
+
// maxLocalBranches" / "set turnOffRuleUntilEpoch", and an agent with no human in the loop
|
|
417
|
+
// edited webpieces.config.json to escape — loosening the very rule that was working correctly.
|
|
418
|
+
const askFirst = cache.deletable.length === 0;
|
|
377
419
|
if (cache.deletable.length > 0) {
|
|
378
420
|
const names = cache.deletable.map((entry) => entry.branch);
|
|
379
421
|
options.push(new fix_hint_1.Option(`Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Each is either ` +
|
|
@@ -381,10 +423,14 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
381
423
|
`is logged with a recover-by-SHA command (see merged-branches.json for the per-branch reason): ` +
|
|
382
424
|
names.join(' '), true));
|
|
383
425
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
426
|
+
const ask = this.askHumanOption(cache, askFirst);
|
|
427
|
+
if (ask)
|
|
428
|
+
options.push(ask);
|
|
429
|
+
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxLocalBranches in ' +
|
|
430
|
+
'webpieces.config.json. Editing this config to get past a guard is not a fix you may make on ' +
|
|
431
|
+
'your own — ask first (use the option above).'));
|
|
432
|
+
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +
|
|
433
|
+
'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.'));
|
|
388
434
|
const kept = cache.keep.length > 0
|
|
389
435
|
? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +
|
|
390
436
|
'do not delete those; a human decides.'
|
|
@@ -392,6 +438,38 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
392
438
|
return new fix_hint_1.FixHint('Too many local branches — reap the dead ones before creating another.', 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +
|
|
393
439
|
`refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`, options);
|
|
394
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* The remedy that actually deletes something without loosening anything: SHOW the spared branches
|
|
443
|
+
* and ASK the human which may go.
|
|
444
|
+
*
|
|
445
|
+
* `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work
|
|
446
|
+
* is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never
|
|
447
|
+
* can, and it was never printed: the cap said "N branches were SPARED, do not delete those" and
|
|
448
|
+
* then offered only config edits. So the agent edited the config.
|
|
449
|
+
*
|
|
450
|
+
* Every column is read straight off `.webpieces/merged-branches.json` (written by the detached
|
|
451
|
+
* refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see
|
|
452
|
+
* the delete is reversible; the commit count is there so "0 commits" branches are obvious yeses.
|
|
453
|
+
*/
|
|
454
|
+
askHumanOption(cache, preferred) {
|
|
455
|
+
if (cache.keep.length === 0)
|
|
456
|
+
return null;
|
|
457
|
+
const rows = cache.keep.map((entry) => {
|
|
458
|
+
const sha = entry.sha !== '' ? entry.sha : '???????';
|
|
459
|
+
const pr = entry.pr > 0
|
|
460
|
+
? `PR #${String(entry.pr)} ${entry.prState || 'MERGED'}`
|
|
461
|
+
: (entry.prState !== '' ? `PR ${entry.prState}` : 'no PR');
|
|
462
|
+
const commits = entry.commits >= 0 ? `${String(entry.commits)} commit(s) of its own` : 'commit count unknown';
|
|
463
|
+
return ` ${entry.branch} [${sha}] ${pr} ${commits} — ${entry.reason}`;
|
|
464
|
+
});
|
|
465
|
+
return new fix_hint_1.Option(`ASK THE HUMAN which of these ${String(cache.keep.length)} branches may be deleted. They are ` +
|
|
466
|
+
'not provably dead, so the tooling will not reap them — but a human can decide in seconds, ' +
|
|
467
|
+
'and deleting one is the correct fix for "too many branches". Paste this list and ask:\n' +
|
|
468
|
+
rows.join('\n') + '\n' +
|
|
469
|
+
'Then delete ONLY the ones approved: git branch -D <approved-branch>\n' +
|
|
470
|
+
'(each is recoverable — `git branch <name> <sha>` restores it at the SHA shown above).\n' +
|
|
471
|
+
'Do NOT delete any of these without an explicit yes, and do NOT edit webpieces.config.json instead.', preferred);
|
|
472
|
+
}
|
|
395
473
|
/**
|
|
396
474
|
* The worktree reap instructions.
|
|
397
475
|
*
|