@webpieces/ai-hook-rules 0.4.508 → 0.4.510
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/bin/shim.d.ts +2 -0
- package/src/bin/shim.js +46 -19
- package/src/bin/shim.js.map +1 -1
- package/src/core/build-context.d.ts +2 -1
- package/src/core/build-context.js +5 -2
- package/src/core/build-context.js.map +1 -1
- package/src/core/effective-tree.d.ts +75 -0
- package/src/core/effective-tree.js +136 -0
- package/src/core/effective-tree.js.map +1 -0
- package/src/core/rules/branch-creation-guard.d.ts +20 -47
- package/src/core/rules/branch-creation-guard.js +58 -150
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/cap-remedies.d.ts +79 -0
- package/src/core/rules/cap-remedies.js +175 -0
- package/src/core/rules/cap-remedies.js.map +1 -0
- package/src/core/rules/content-read-scan.d.ts +29 -4
- package/src/core/rules/content-read-scan.js +54 -9
- package/src/core/rules/content-read-scan.js.map +1 -1
- package/src/core/rules/feature-branch-guard.js +1 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.js +8 -2
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.d.ts +8 -0
- package/src/core/rules/merged-branch-message.js +17 -1
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/read-stale-guard.js +2 -2
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.js +2 -2
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +12 -0
- package/src/core/rules/stale-main-message.js +17 -1
- package/src/core/rules/stale-main-message.js.map +1 -1
- package/src/core/rules/tree-recovery.d.ts +16 -0
- package/src/core/rules/tree-recovery.js +28 -7
- package/src/core/rules/tree-recovery.js.map +1 -1
- package/src/core/runner.js +56 -77
- package/src/core/runner.js.map +1 -1
- package/src/core/types.d.ts +12 -1
- package/src/core/types.js +14 -1
- package/src/core/types.js.map +1 -1
- package/templates/ai-hook.sh +5 -5
|
@@ -25,10 +25,42 @@ class ContentReadScan {
|
|
|
25
25
|
scanner;
|
|
26
26
|
workspaceRoot;
|
|
27
27
|
shell;
|
|
28
|
-
|
|
28
|
+
baseDir;
|
|
29
|
+
/**
|
|
30
|
+
* `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which
|
|
31
|
+
* is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.
|
|
32
|
+
* RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat
|
|
33
|
+
* notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,
|
|
34
|
+
* while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.
|
|
35
|
+
* Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).
|
|
36
|
+
*/
|
|
37
|
+
constructor(scanner, workspaceRoot, effectiveCwd) {
|
|
29
38
|
this.scanner = scanner;
|
|
30
39
|
this.workspaceRoot = workspaceRoot;
|
|
31
40
|
this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
|
|
41
|
+
this.baseDir = effectiveCwd ?? workspaceRoot;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —
|
|
45
|
+
* `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.
|
|
46
|
+
*
|
|
47
|
+
* merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an
|
|
48
|
+
* `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing
|
|
49
|
+
* about a read that never touches the tree is affected by which branch the tree is on. The
|
|
50
|
+
* "content reader" restriction is what keeps this from becoming a general escape hatch: a build,
|
|
51
|
+
* a server or a git write is not a content reader and never qualifies, however its paths look.
|
|
52
|
+
*/
|
|
53
|
+
readsOnlyOutsideContent(segment) {
|
|
54
|
+
return this.isContentReader(segment) && this.readsStaleContent(segment) === null;
|
|
55
|
+
}
|
|
56
|
+
// Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?
|
|
57
|
+
isContentReader(segment) {
|
|
58
|
+
const words = this.shell.effectiveWords(segment.text);
|
|
59
|
+
if (words.length === 0)
|
|
60
|
+
return false;
|
|
61
|
+
if (this.scanner.gitSubcommandOf(words) !== null)
|
|
62
|
+
return false;
|
|
63
|
+
return CONTENT_READERS.has(this.baseName(words[0]));
|
|
32
64
|
}
|
|
33
65
|
/**
|
|
34
66
|
* The command word that reads stale workspace content, or null when this segment does not.
|
|
@@ -51,8 +83,10 @@ class ContentReadScan {
|
|
|
51
83
|
const operands = this.pathOperands(command, words.slice(1));
|
|
52
84
|
if (operands.length === 0) {
|
|
53
85
|
// No path given: either it reads stdin (fine — and doubly fine when piped into), or it
|
|
54
|
-
// walks the cwd
|
|
55
|
-
|
|
86
|
+
// walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`
|
|
87
|
+
// walks /tmp, which this repo's staleness has nothing to do with.
|
|
88
|
+
const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);
|
|
89
|
+
return walksTree ? command : null;
|
|
56
90
|
}
|
|
57
91
|
return operands.some((operand) => this.isWorkspacePath(operand)) ? command : null;
|
|
58
92
|
}
|
|
@@ -86,9 +120,12 @@ class ContentReadScan {
|
|
|
86
120
|
return positional;
|
|
87
121
|
}
|
|
88
122
|
/**
|
|
89
|
-
* Is this operand a path inside the workspace?
|
|
90
|
-
*
|
|
91
|
-
*
|
|
123
|
+
* Is this operand a path inside the workspace? A RELATIVE operand is resolved against the
|
|
124
|
+
* directory the command actually runs in (`baseDir`), so it counts only when that directory is
|
|
125
|
+
* itself in the tree — the old code assumed every relative path meant "inside the repo", which is
|
|
126
|
+
* how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An
|
|
127
|
+
* absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,
|
|
128
|
+
* `~/notes.md` and `/tmp/x` are not this repo's problem.
|
|
92
129
|
*
|
|
93
130
|
* Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and
|
|
94
131
|
* a stat per operand on the blocking hook path is exactly the cost these guards avoid.
|
|
@@ -96,13 +133,21 @@ class ContentReadScan {
|
|
|
96
133
|
isWorkspacePath(operand) {
|
|
97
134
|
if (operand.startsWith('~'))
|
|
98
135
|
return false;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
136
|
+
// The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`
|
|
137
|
+
// stays readable from any directory — never wedge the file that turns the guard off.
|
|
138
|
+
if (this.isEscapeHatchPath(operand))
|
|
139
|
+
return false;
|
|
140
|
+
const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);
|
|
141
|
+
const relative = path.relative(this.workspaceRoot, absolute);
|
|
102
142
|
if (relative.startsWith('..'))
|
|
103
143
|
return false;
|
|
104
144
|
return !this.isEscapeHatchPath(relative);
|
|
105
145
|
}
|
|
146
|
+
// The cwd-walk question: is the directory this command runs in inside the tree being judged?
|
|
147
|
+
isInWorkspace(dir) {
|
|
148
|
+
const relative = path.relative(this.workspaceRoot, path.resolve(dir));
|
|
149
|
+
return !relative.startsWith('..');
|
|
150
|
+
}
|
|
106
151
|
// Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the
|
|
107
152
|
// file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation
|
|
108
153
|
// data this guard writes itself, not source that upstream has moved past.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd, which IS the stale tree (`ls`, a bare `rg pattern`).\n return !segment.pipedInto && CWD_WALKERS.has(command) ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? Relative paths are (the agent's cwd is the repo\n * or a subdir of it); an absolute path counts only when it is actually under workspaceRoot, so\n * `/etc/hosts`, `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n if (!path.isAbsolute(operand)) return !this.isEscapeHatchPath(operand);\n const relative = path.relative(this.workspaceRoot, operand);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
|
|
1
|
+
{"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n private readonly baseDir: string;\n\n /**\n * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which\n * is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.\n * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat\n * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,\n * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.\n * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).\n */\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n effectiveCwd?: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n this.baseDir = effectiveCwd ?? workspaceRoot;\n }\n\n /**\n * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —\n * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.\n *\n * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an\n * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing\n * about a read that never touches the tree is affected by which branch the tree is on. The\n * \"content reader\" restriction is what keeps this from becoming a general escape hatch: a build,\n * a server or a git write is not a content reader and never qualifies, however its paths look.\n */\n readsOnlyOutsideContent(segment: CommandSegment): boolean {\n return this.isContentReader(segment) && this.readsStaleContent(segment) === null;\n }\n\n // Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?\n private isContentReader(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return false;\n if (this.scanner.gitSubcommandOf(words) !== null) return false;\n return CONTENT_READERS.has(this.baseName(words[0]));\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`\n // walks /tmp, which this repo's staleness has nothing to do with.\n const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);\n return walksTree ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the\n * directory the command actually runs in (`baseDir`), so it counts only when that directory is\n * itself in the tree — the old code assumed every relative path meant \"inside the repo\", which is\n * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An\n * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,\n * `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n // The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`\n // stays readable from any directory — never wedge the file that turns the guard off.\n if (this.isEscapeHatchPath(operand)) return false;\n const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);\n const relative = path.relative(this.workspaceRoot, absolute);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // The cwd-walk question: is the directory this command runs in inside the tree being judged?\n private isInWorkspace(dir: string): boolean {\n const relative = path.relative(this.workspaceRoot, path.resolve(dir));\n return !relative.startsWith('..');\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
|
|
@@ -126,7 +126,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
126
126
|
// The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new
|
|
127
127
|
// worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.
|
|
128
128
|
alreadyMergedMessage(workspaceRoot, branch, mergedPr) {
|
|
129
|
-
return new merged_branch_message_1.MergedBranchMessage().forEdits(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
129
|
+
return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forEdits(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
130
130
|
}
|
|
131
131
|
conflictMessage(conflictFiles, openPr) {
|
|
132
132
|
const files = conflictFiles.length > 0
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-branch-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/feature-branch-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n FeatureBranchGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n squashRecoverySteps,\n MainSyncStatus,\n SyncFlowGuidance,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Comprehensive \"are you on a proper feature branch?\" guard — the single rule that blocks edits when\n * the branch isn't a healthy place to work. Four states, in priority order:\n * 1. On main (checked SYNCHRONOUSLY here) → block: create a feature branch.\n * 2. Branch already merged into main (merged PR) → block: your work is in main, branch off fresh.\n * 3. No fork point with origin/main → block: squash onto a new branch.\n * 4. origin/main moved & touches your files → block: merge main first.\n * States 2–4 are PRECOMPUTED into `.webpieces/main-sync-status.json` by the detached refresher, so\n * this check does NO network git (only a fast local `git rev-parse` for state 1). On every call it\n * fire-and-forget spawns the refresher so the NEXT call is fresh. Runs in the GUARDS hook (it's a\n * hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI\n * can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.\n */\nexport class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGuardConfig> {\n constructor(config: FeatureBranchGuardConfig) { super(config, 'feature-branch-guard'); }\n\n readonly description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n branchNamingConvention: '{whoami}/{featurename}',\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are not on a clean, up-to-date feature branch.',\n 'You must be on a clean, up-to-date feature branch to edit code. Pick one:',\n [\n new Option('On main → create a feature branch. Already merged → branch off fresh main.', true),\n new Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),\n new Option('Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine branch (e.g. not a git repo) → don't block. Fail-open.\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // State 1: on main — synchronous, no cache needed.\n if (branch === 'main') {\n return this.block(ctx, branch, 'on-main', this.onMainMessage());\n }\n\n // Keep the cache warm for the next call. Detached; never blocks this edit.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n // No cache yet (first edit of the session) → allow; the refresh we just spawned populates it\n // for the next call. Fail-open: never block on missing data.\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just\n // switched branches and the refresh for this one hasn't landed yet). Never block on another\n // branch's signals — fail open; the refresh we just spawned rewrites it for this branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n const merged = this.alreadyMergedMessage(ctx.workspaceRoot, branch, status.mergedPr);\n return this.block(ctx, branch, `already-merged PR#${pr}`, merged, cache);\n }\n // State 3: no fork point — main was merged into the branch.\n if (!status.hasForkPoint) {\n return this.block(ctx, branch, 'no-fork-point', this.noForkPointMessage(branch), cache);\n }\n // State 4: origin/main moved and touches files you changed.\n if (status.conflict) {\n return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles, status.openPr), cache);\n }\n return this.allow(ctx, branch, 'clean-feature-branch', cache);\n }\n\n // One-line summary of the async-written cache that drove this decision, for the SYNC log — so a\n // wrong allow/block is traceable to the exact (possibly stale) 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} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n // Log + return for the allow path. Centralizes the decision-log call so every exit of check()\n // is recorded with its reason + the async cache it read (this is the audit trail for \"why didn't\n // the guard fire?\"). `cache` is the summary of the main-sync-status.json that drove the decision.\n private allow(ctx: FileContext, 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: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\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 private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'You should not be working on main.',\n 'Do a `git pull origin main` to get latest, then create a feature branch based on the naming convention.',\n `Branch naming convention (from webpieces.config.json): ${convention}`,\n 'Example: git checkout -b ' + convention.replace(/<[^>]+>/g, 'value'),\n ].join('\\n');\n }\n\n // Shared with read-stale-guard, which blocks READS in this same state — see MergedBranchMessage.\n // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new\n // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.\n private alreadyMergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage().forEdits(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n private conflictMessage(conflictFiles: readonly string[], openPr: string): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n const header = [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n ];\n // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast\n // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR\n // flow and don't waste the AI's tokens on wp-start-update.\n const guidance = new SyncFlowGuidance();\n // An OPEN PR removes the choice, so print ONLY the PR flow here — showing the update-only flow\n // as if it were an option just burns tokens on a command that fail-fasts.\n if (openPr !== '') {\n return header\n .concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so the PR flow is the ONLY option`,\n 'here — it re-merges main AND re-points the PR in the same run:',\n '',\n ])\n .concat(guidance.prFlow())\n .concat(['', ...guidance.whyPrForcesFlowB()])\n .join('\\n');\n }\n return header\n .concat([\n 'You must merge main in before editing further. No PR is open for this branch, so flow A',\n 'below is the one to use — but use flow B the moment a PR exists:',\n '',\n ])\n .concat(guidance.flows())\n .join('\\n');\n }\n\n private noForkPointMessage(branch: string): string {\n return [\n 'No fork point with origin/main — main appears to have been merged into this branch,',\n 'so a clean squash-merge is impossible. A human must redo the work on a fresh branch:',\n '',\n ...squashRecoverySteps(branch),\n ].join('\\n');\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"feature-branch-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/feature-branch-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n FeatureBranchGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n squashRecoverySteps,\n MainSyncStatus,\n SyncFlowGuidance,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Comprehensive \"are you on a proper feature branch?\" guard — the single rule that blocks edits when\n * the branch isn't a healthy place to work. Four states, in priority order:\n * 1. On main (checked SYNCHRONOUSLY here) → block: create a feature branch.\n * 2. Branch already merged into main (merged PR) → block: your work is in main, branch off fresh.\n * 3. No fork point with origin/main → block: squash onto a new branch.\n * 4. origin/main moved & touches your files → block: merge main first.\n * States 2–4 are PRECOMPUTED into `.webpieces/main-sync-status.json` by the detached refresher, so\n * this check does NO network git (only a fast local `git rev-parse` for state 1). On every call it\n * fire-and-forget spawns the refresher so the NEXT call is fresh. Runs in the GUARDS hook (it's a\n * hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI\n * can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.\n */\nexport class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGuardConfig> {\n constructor(config: FeatureBranchGuardConfig) { super(config, 'feature-branch-guard'); }\n\n readonly description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n branchNamingConvention: '{whoami}/{featurename}',\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are not on a clean, up-to-date feature branch.',\n 'You must be on a clean, up-to-date feature branch to edit code. Pick one:',\n [\n new Option('On main → create a feature branch. Already merged → branch off fresh main.', true),\n new Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),\n new Option('Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine branch (e.g. not a git repo) → don't block. Fail-open.\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // State 1: on main — synchronous, no cache needed.\n if (branch === 'main') {\n return this.block(ctx, branch, 'on-main', this.onMainMessage());\n }\n\n // Keep the cache warm for the next call. Detached; never blocks this edit.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n // No cache yet (first edit of the session) → allow; the refresh we just spawned populates it\n // for the next call. Fail-open: never block on missing data.\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just\n // switched branches and the refresh for this one hasn't landed yet). Never block on another\n // branch's signals — fail open; the refresh we just spawned rewrites it for this branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n const merged = this.alreadyMergedMessage(ctx.workspaceRoot, branch, status.mergedPr);\n return this.block(ctx, branch, `already-merged PR#${pr}`, merged, cache);\n }\n // State 3: no fork point — main was merged into the branch.\n if (!status.hasForkPoint) {\n return this.block(ctx, branch, 'no-fork-point', this.noForkPointMessage(branch), cache);\n }\n // State 4: origin/main moved and touches files you changed.\n if (status.conflict) {\n return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles, status.openPr), cache);\n }\n return this.allow(ctx, branch, 'clean-feature-branch', cache);\n }\n\n // One-line summary of the async-written cache that drove this decision, for the SYNC log — so a\n // wrong allow/block is traceable to the exact (possibly stale) 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} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n // Log + return for the allow path. Centralizes the decision-log call so every exit of check()\n // is recorded with its reason + the async cache it read (this is the audit trail for \"why didn't\n // the guard fire?\"). `cache` is the summary of the main-sync-status.json that drove the decision.\n private allow(ctx: FileContext, 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: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\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 private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'You should not be working on main.',\n 'Do a `git pull origin main` to get latest, then create a feature branch based on the naming convention.',\n `Branch naming convention (from webpieces.config.json): ${convention}`,\n 'Example: git checkout -b ' + convention.replace(/<[^>]+>/g, 'value'),\n ].join('\\n');\n }\n\n // Shared with read-stale-guard, which blocks READS in this same state — see MergedBranchMessage.\n // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new\n // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.\n private alreadyMergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forEdits(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n private conflictMessage(conflictFiles: readonly string[], openPr: string): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n const header = [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n ];\n // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast\n // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR\n // flow and don't waste the AI's tokens on wp-start-update.\n const guidance = new SyncFlowGuidance();\n // An OPEN PR removes the choice, so print ONLY the PR flow here — showing the update-only flow\n // as if it were an option just burns tokens on a command that fail-fasts.\n if (openPr !== '') {\n return header\n .concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so the PR flow is the ONLY option`,\n 'here — it re-merges main AND re-points the PR in the same run:',\n '',\n ])\n .concat(guidance.prFlow())\n .concat(['', ...guidance.whyPrForcesFlowB()])\n .join('\\n');\n }\n return header\n .concat([\n 'You must merge main in before editing further. No PR is open for this branch, so flow A',\n 'below is the one to use — but use flow B the moment a PR exists:',\n '',\n ])\n .concat(guidance.flows())\n .join('\\n');\n }\n\n private noForkPointMessage(branch: string): string {\n return [\n 'No fork point with origin/main — main appears to have been merged into this branch,',\n 'so a clean squash-merge is impossible. A human must redo the work on a fresh branch:',\n '',\n ...squashRecoverySteps(branch),\n ].join('\\n');\n }\n}\n"]}
|
|
@@ -100,7 +100,7 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
100
100
|
const segments = this.scanner.segmentsWithPipes(ctx.command);
|
|
101
101
|
if (segments.length === 0)
|
|
102
102
|
return false;
|
|
103
|
-
const content = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot);
|
|
103
|
+
const content = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);
|
|
104
104
|
return segments.every((segment) => this.isRecoverySegment(segment, content));
|
|
105
105
|
}
|
|
106
106
|
isRecoverySegment(segment, content) {
|
|
@@ -112,6 +112,12 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
112
112
|
// is protecting against. ContentReadScan already draws exactly that line.
|
|
113
113
|
if (verdict.role === 'shaping')
|
|
114
114
|
return content.readsStaleContent(segment) === null;
|
|
115
|
+
// A read that names NOTHING in this tree cannot be affected by which branch this tree is on.
|
|
116
|
+
// `ls -la ~/.claude/projects/ | grep -i foo` was blocked as "this branch is merged" — the
|
|
117
|
+
// command touches no repo at all. Only CONTENT READERS qualify, so a build, a server or a git
|
|
118
|
+
// write never slips through on the strength of its paths.
|
|
119
|
+
if (content.readsOnlyOutsideContent(segment))
|
|
120
|
+
return true;
|
|
115
121
|
const gitSub = this.scanner.gitSubcommandOf(verdict.words);
|
|
116
122
|
if (gitSub !== null)
|
|
117
123
|
return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);
|
|
@@ -147,7 +153,7 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
147
153
|
return words.slice(1).some((word) => /^wp-[a-z-]+$/.test(word) || PACKAGE_INSTALL_VERBS.has(word));
|
|
148
154
|
}
|
|
149
155
|
mergedMessage(workspaceRoot, branch, mergedPr) {
|
|
150
|
-
return new merged_branch_message_1.MergedBranchMessage().forBash(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
156
|
+
return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forBash(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
151
157
|
}
|
|
152
158
|
// One-line summary of the async-written cache that drove this decision (mirrors the file guards),
|
|
153
159
|
// so a wrong allow/block is traceable to the exact main-sync-status.json read.
|
|
@@ -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 {\n MergedBranchBashGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\nimport { ShellSegmentScan, SegmentVerdict } from './shell-segment-scan';\nimport { ContentReadScan } from './content-read-scan';\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 (`guard-invocations.log` →\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<MergedBranchBashGuardConfig> {\n constructor(config: MergedBranchBashGuardConfig) { super(config, 'merged-branch-bash-guard'); }\n\n private readonly scanner = new CommandScanner();\n private readonly shell = new ShellSegmentScan(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 → merged-branch-bash-guard (mode OFF) if intentional.'),\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.allow(ctx, branch, 'branch-undeterminable (fail-open)');\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, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n // No cache yet (first command of the session) → allow; the refresh we just spawned populates it.\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Cache computed for a DIFFERENT branch (just switched; the refresh for this one hasn't landed).\n // Never block on another branch's signals — this is also what un-blocks the instant the agent\n // follows the cure and checks out a fresh branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n\n if (!status.branchAlreadyMerged) return this.allow(ctx, branch, 'clean-feature-branch', cache);\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.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 /**\n * A command is a recovery command only when EVERY one of its segments is — a single\n * `… && scripts/local.sh start` in the chain is enough to deny the whole thing.\n *\n * Segments are judged by ROLE first (see ShellSegmentScan). Shell STRUCTURE (`for … in`, `do`,\n * `done`) invokes nothing, and output SHAPING (`| tail -40`, `; echo done`) cannot touch the repo\n * — so neither may veto a chain. Judging the raw string instead is what made the guard reject\n * `git fetch origin main 2>&1 | tail -5`, a command its own redirect had just told the agent to run.\n */\n private isFullyRecovery(ctx: BashContext): boolean {\n const segments = this.scanner.segmentsWithPipes(ctx.command);\n if (segments.length === 0) return false;\n const content = new ContentReadScan(this.scanner, ctx.workspaceRoot);\n return segments.every((segment: CommandSegment): boolean => this.isRecoverySegment(segment, content));\n }\n\n private isRecoverySegment(segment: CommandSegment, content: ContentReadScan): boolean {\n const verdict = this.shell.classify(segment);\n if (verdict.role === 'structure') return true;\n // Inert / piped-into filters are fine EXCEPT when they name a workspace path: `git status |\n // cat src/foo.ts` still hands the agent pre-merge file content, which is the thing this guard\n // is protecting against. ContentReadScan already draws exactly that line.\n if (verdict.role === 'shaping') return content.readsStaleContent(segment) === null;\n\n const gitSub = this.scanner.gitSubcommandOf(verdict.words);\n if (gitSub !== null) return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);\n if (this.isGhInspection(verdict)) return true;\n if (this.isPackageRecovery(verdict)) return true;\n return false;\n }\n\n // Read-only / status `gh` invocations used for orientation, INCLUDING `gh run view|list|watch` —\n // watching CI is precisely what you do while parked on a just-merged branch. gh writes (pr\n // create/merge, run cancel/rerun, api POSTs) are governed by pr-creation-or-push-guard /\n // pr-merge-guard and are NOT allowlisted here.\n private isGhInspection(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || words[0] !== 'gh') return false;\n const top = words[1];\n if (top === undefined) return false;\n const action = words[2];\n const readActions = GH_READ_ACTIONS.get(top);\n if (readActions !== undefined) return action !== undefined && readActions.has(action);\n return GH_READ_TOPLEVEL.has(top);\n }\n\n // pnpm/npm/yarn recovery bins: the `wp-*` cleanup/gated commands and package installs (a chained\n // install that isInstallerCommand — the pure-install bypass — did not catch reaches here).\n private isPackageRecovery(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || !PACKAGE_MANAGERS.has(words[0])) return false;\n return words.slice(1).some((word: string): boolean =>\n /^wp-[a-z-]+$/.test(word) || PACKAGE_INSTALL_VERBS.has(word));\n }\n\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage().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 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', reason, cache);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', 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),\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.\nconst ALLOWED_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([\n 'status', 'log', 'diff', 'show', 'branch', 'checkout', 'switch', 'worktree', 'fetch', 'pull',\n 'rev-parse', 'rev-list', 'merge-base', 'ls-files', 'ls-tree', 'cat-file', 'for-each-ref',\n 'symbolic-ref', 'describe', 'name-rev', 'reflog', 'shortlog', 'remote', 'config', 'stash', 'tag',\n 'blame', 'whatchanged', 'cherry',\n]);\n\n// Read-only actions per `gh` topic. `run` is here because `gh run view <id>` was blocked outright in\n// the field while `gh pr view` beside it succeeded — both are read-only, and CI watching is the normal\n// thing to do while parked. The WRITE actions of the same topics (pr create/merge/close, run\n// cancel/rerun/delete) are simply absent, so they still fall through to the block.\nconst GH_READ_ACTIONS: ReadonlyMap<string, ReadonlySet<string>> = new Map([\n ['pr', new Set(['list', 'view', 'status', 'checks', 'diff'])],\n ['run', new Set(['list', 'view', 'watch'])],\n ['issue', new Set(['list', 'view', 'status'])],\n]);\n// Read-only top-level `gh` commands.\nconst GH_READ_TOPLEVEL: ReadonlySet<string> = new Set(['status', 'auth', 'browse', 'repo', 'search']);\n\nconst PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'npx', 'pnpx', 'yarn']);\nconst PACKAGE_INSTALL_VERBS: ReadonlySet<string> = new Set(['install', 'ci', 'add', 'i']);\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 {\n MergedBranchBashGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\nimport { ShellSegmentScan, SegmentVerdict } from './shell-segment-scan';\nimport { ContentReadScan } from './content-read-scan';\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 (`guard-invocations.log` →\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<MergedBranchBashGuardConfig> {\n constructor(config: MergedBranchBashGuardConfig) { super(config, 'merged-branch-bash-guard'); }\n\n private readonly scanner = new CommandScanner();\n private readonly shell = new ShellSegmentScan(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 → merged-branch-bash-guard (mode OFF) if intentional.'),\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.allow(ctx, branch, 'branch-undeterminable (fail-open)');\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, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n // No cache yet (first command of the session) → allow; the refresh we just spawned populates it.\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Cache computed for a DIFFERENT branch (just switched; the refresh for this one hasn't landed).\n // Never block on another branch's signals — this is also what un-blocks the instant the agent\n // follows the cure and checks out a fresh branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n\n if (!status.branchAlreadyMerged) return this.allow(ctx, branch, 'clean-feature-branch', cache);\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.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 /**\n * A command is a recovery command only when EVERY one of its segments is — a single\n * `… && scripts/local.sh start` in the chain is enough to deny the whole thing.\n *\n * Segments are judged by ROLE first (see ShellSegmentScan). Shell STRUCTURE (`for … in`, `do`,\n * `done`) invokes nothing, and output SHAPING (`| tail -40`, `; echo done`) cannot touch the repo\n * — so neither may veto a chain. Judging the raw string instead is what made the guard reject\n * `git fetch origin main 2>&1 | tail -5`, a command its own redirect had just told the agent to run.\n */\n private isFullyRecovery(ctx: BashContext): boolean {\n const segments = this.scanner.segmentsWithPipes(ctx.command);\n if (segments.length === 0) return false;\n const content = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n return segments.every((segment: CommandSegment): boolean => this.isRecoverySegment(segment, content));\n }\n\n private isRecoverySegment(segment: CommandSegment, content: ContentReadScan): boolean {\n const verdict = this.shell.classify(segment);\n if (verdict.role === 'structure') return true;\n // Inert / piped-into filters are fine EXCEPT when they name a workspace path: `git status |\n // cat src/foo.ts` still hands the agent pre-merge file content, which is the thing this guard\n // is protecting against. ContentReadScan already draws exactly that line.\n if (verdict.role === 'shaping') return content.readsStaleContent(segment) === null;\n\n // A read that names NOTHING in this tree cannot be affected by which branch this tree is on.\n // `ls -la ~/.claude/projects/ | grep -i foo` was blocked as \"this branch is merged\" — the\n // command touches no repo at all. Only CONTENT READERS qualify, so a build, a server or a git\n // write never slips through on the strength of its paths.\n if (content.readsOnlyOutsideContent(segment)) return true;\n\n const gitSub = this.scanner.gitSubcommandOf(verdict.words);\n if (gitSub !== null) return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);\n if (this.isGhInspection(verdict)) return true;\n if (this.isPackageRecovery(verdict)) return true;\n return false;\n }\n\n // Read-only / status `gh` invocations used for orientation, INCLUDING `gh run view|list|watch` —\n // watching CI is precisely what you do while parked on a just-merged branch. gh writes (pr\n // create/merge, run cancel/rerun, api POSTs) are governed by pr-creation-or-push-guard /\n // pr-merge-guard and are NOT allowlisted here.\n private isGhInspection(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || words[0] !== 'gh') return false;\n const top = words[1];\n if (top === undefined) return false;\n const action = words[2];\n const readActions = GH_READ_ACTIONS.get(top);\n if (readActions !== undefined) return action !== undefined && readActions.has(action);\n return GH_READ_TOPLEVEL.has(top);\n }\n\n // pnpm/npm/yarn recovery bins: the `wp-*` cleanup/gated commands and package installs (a chained\n // install that isInstallerCommand — the pure-install bypass — did not catch reaches here).\n private isPackageRecovery(verdict: SegmentVerdict): boolean {\n const words = verdict.words;\n if (words.length === 0 || !PACKAGE_MANAGERS.has(words[0])) return false;\n return words.slice(1).some((word: string): boolean =>\n /^wp-[a-z-]+$/.test(word) || PACKAGE_INSTALL_VERBS.has(word));\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 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', reason, cache);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', 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),\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.\nconst ALLOWED_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([\n 'status', 'log', 'diff', 'show', 'branch', 'checkout', 'switch', 'worktree', 'fetch', 'pull',\n 'rev-parse', 'rev-list', 'merge-base', 'ls-files', 'ls-tree', 'cat-file', 'for-each-ref',\n 'symbolic-ref', 'describe', 'name-rev', 'reflog', 'shortlog', 'remote', 'config', 'stash', 'tag',\n 'blame', 'whatchanged', 'cherry',\n]);\n\n// Read-only actions per `gh` topic. `run` is here because `gh run view <id>` was blocked outright in\n// the field while `gh pr view` beside it succeeded — both are read-only, and CI watching is the normal\n// thing to do while parked. The WRITE actions of the same topics (pr create/merge/close, run\n// cancel/rerun/delete) are simply absent, so they still fall through to the block.\nconst GH_READ_ACTIONS: ReadonlyMap<string, ReadonlySet<string>> = new Map([\n ['pr', new Set(['list', 'view', 'status', 'checks', 'diff'])],\n ['run', new Set(['list', 'view', 'watch'])],\n ['issue', new Set(['list', 'view', 'status'])],\n]);\n// Read-only top-level `gh` commands.\nconst GH_READ_TOPLEVEL: ReadonlySet<string> = new Set(['status', 'auth', 'browse', 'repo', 'search']);\n\nconst PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'npx', 'pnpx', 'yarn']);\nconst PACKAGE_INSTALL_VERBS: ReadonlySet<string> = new Set(['install', 'ci', 'add', 'i']);\n"]}
|
|
@@ -22,7 +22,15 @@ import { TreeKind } from './tree-recovery';
|
|
|
22
22
|
* its only exit was creating a branch, which the branch cap then refused.
|
|
23
23
|
*/
|
|
24
24
|
export declare class MergedBranchMessage {
|
|
25
|
+
private readonly treeRoot;
|
|
25
26
|
private readonly recovery;
|
|
27
|
+
/**
|
|
28
|
+
* `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as
|
|
29
|
+
* `cd <treeRoot> && …`. That form is the only one that is correct across tool calls, because a
|
|
30
|
+
* Bash call does not persist `cd`: an agent in a linked worktree is back in the primary clone by
|
|
31
|
+
* the time it runs the cure, and a bare `git checkout -b` would then branch the WRONG tree.
|
|
32
|
+
*/
|
|
33
|
+
constructor(treeRoot?: string);
|
|
26
34
|
/**
|
|
27
35
|
* The ONE allowance list, shared by every guard that blocks while this state is up.
|
|
28
36
|
*
|
|
@@ -25,7 +25,18 @@ const tree_recovery_1 = require("./tree-recovery");
|
|
|
25
25
|
* its only exit was creating a branch, which the branch cap then refused.
|
|
26
26
|
*/
|
|
27
27
|
class MergedBranchMessage {
|
|
28
|
-
|
|
28
|
+
treeRoot;
|
|
29
|
+
recovery;
|
|
30
|
+
/**
|
|
31
|
+
* `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as
|
|
32
|
+
* `cd <treeRoot> && …`. That form is the only one that is correct across tool calls, because a
|
|
33
|
+
* Bash call does not persist `cd`: an agent in a linked worktree is back in the primary clone by
|
|
34
|
+
* the time it runs the cure, and a bare `git checkout -b` would then branch the WRONG tree.
|
|
35
|
+
*/
|
|
36
|
+
constructor(treeRoot = '') {
|
|
37
|
+
this.treeRoot = treeRoot;
|
|
38
|
+
this.recovery = new tree_recovery_1.TreeRecovery(treeRoot);
|
|
39
|
+
}
|
|
29
40
|
/**
|
|
30
41
|
* The ONE allowance list, shared by every guard that blocks while this state is up.
|
|
31
42
|
*
|
|
@@ -61,6 +72,11 @@ class MergedBranchMessage {
|
|
|
61
72
|
const where = kind === 'worktree' ? 'worktree' : 'branch';
|
|
62
73
|
const lines = [
|
|
63
74
|
`It looks like you forgot to clean up this ${where} "${branch}" — its PR is already merged into main${pr}.`,
|
|
75
|
+
// Name the tree that was judged. With several agents running in parallel worktrees, a guard
|
|
76
|
+
// that reasons from the shell cwd can block a command while citing an UNRELATED agent's
|
|
77
|
+
// branch — observed live. Printing the directory makes a wrong judgement visible instead of
|
|
78
|
+
// baffling, and lets the reader see immediately that it is not the tree they meant.
|
|
79
|
+
...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),
|
|
64
80
|
'Your work is in main — do NOT keep working here (you will reconflict with main).',
|
|
65
81
|
'',
|
|
66
82
|
...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),
|
|
@@ -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 = new TreeRecovery();\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 '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, because a\n * Bash call does not persist `cd`: an agent in a linked worktree is back in the primary clone by\n * the time it runs the cure, and a bare `git checkout -b` would then 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"]}
|
|
@@ -161,7 +161,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
161
161
|
// worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.
|
|
162
162
|
mergedMessage(workspaceRoot, branch, mergedPr) {
|
|
163
163
|
const recovery = new tree_recovery_1.TreeRecovery();
|
|
164
|
-
return new merged_branch_message_1.MergedBranchMessage().forReads(branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot);
|
|
164
|
+
return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forReads(branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot);
|
|
165
165
|
}
|
|
166
166
|
// Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.
|
|
167
167
|
//
|
|
@@ -222,7 +222,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
222
222
|
// never prescribe different cures. Its "still allowed" tail no longer promises EVERY Bash command:
|
|
223
223
|
// content-reading Bash is now blocked too, which is the whole point of the Bash counterpart.
|
|
224
224
|
staleMainMessage(workspaceRoot) {
|
|
225
|
-
return new stale_main_message_1.StaleMainMessage().forReads(this.behindCount(workspaceRoot));
|
|
225
|
+
return new stale_main_message_1.StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));
|
|
226
226
|
}
|
|
227
227
|
cacheSummary(status) {
|
|
228
228
|
const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';
|