@webpieces/ai-hook-rules 0.4.494 → 0.4.495
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/core/command-scan.d.ts +6 -0
- package/src/core/command-scan.js +18 -1
- package/src/core/command-scan.js.map +1 -1
- package/src/core/report.d.ts +20 -1
- package/src/core/report.js +28 -3
- package/src/core/report.js.map +1 -1
- package/src/core/rules/branch-creation-guard.d.ts +30 -0
- package/src/core/rules/branch-creation-guard.js +83 -5
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/content-read-scan.d.ts +5 -0
- package/src/core/rules/content-read-scan.js +9 -2
- package/src/core/rules/content-read-scan.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +10 -0
- package/src/core/rules/merged-branch-bash-guard.js +58 -24
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.d.ts +21 -6
- package/src/core/rules/merged-branch-message.js +45 -15
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/shell-segment-scan.d.ts +55 -0
- package/src/core/rules/shell-segment-scan.js +80 -0
- package/src/core/rules/shell-segment-scan.js.map +1 -0
- package/src/core/rules/tree-recovery.js +12 -2
- package/src/core/rules/tree-recovery.js.map +1 -1
- package/src/core/runner.js +11 -2
- package/src/core/runner.js.map +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +5 -1
- package/src/index.js.map +1 -1
|
@@ -13,18 +13,33 @@ import { TreeKind } from './tree-recovery';
|
|
|
13
13
|
* The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree
|
|
14
14
|
* we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove
|
|
15
15
|
* this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —
|
|
18
|
+
* main is checked out in the primary clone). In the primary clone it is a perfectly good move and the
|
|
19
|
+
* allowance list below says so explicitly. The two used to disagree inside a single message — the
|
|
20
|
+
* header forbade it while the allowance list permitted "git checkout <other-branch>", and `main` is an
|
|
21
|
+
* other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded
|
|
22
|
+
* its only exit was creating a branch, which the branch cap then refused.
|
|
17
23
|
*/
|
|
18
24
|
export declare class MergedBranchMessage {
|
|
19
25
|
private readonly recovery;
|
|
26
|
+
/**
|
|
27
|
+
* The ONE allowance list, shared by every guard that blocks while this state is up.
|
|
28
|
+
*
|
|
29
|
+
* Each guard used to print its own view of the world: this one's narrow bash allowlist, and
|
|
30
|
+
* read-stale-guard's "EVERY Bash command". Both statements were true of their own guard and false
|
|
31
|
+
* of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all
|
|
32
|
+
* Bash runs and that most Bash is blocked. One list, printed by both.
|
|
33
|
+
*/
|
|
34
|
+
private allowances;
|
|
20
35
|
private common;
|
|
21
36
|
forEdits(branch: string, mergedPr: string, kind?: TreeKind, worktreePath?: string): string;
|
|
22
37
|
/**
|
|
23
|
-
* The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
38
|
+
* The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message
|
|
39
|
+
* has to spell out the narrow allowlist — otherwise an agent reads "blocked" and believes it is
|
|
40
|
+
* wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the
|
|
41
|
+
* `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit
|
|
42
|
+
* the guard again.
|
|
28
43
|
*/
|
|
29
44
|
forBash(branch: string, mergedPr: string, kind?: TreeKind, worktreePath?: string): string;
|
|
30
45
|
/**
|
|
@@ -16,10 +16,45 @@ const tree_recovery_1 = require("./tree-recovery");
|
|
|
16
16
|
* The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree
|
|
17
17
|
* we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove
|
|
18
18
|
* this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.
|
|
19
|
-
*
|
|
19
|
+
*
|
|
20
|
+
* ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —
|
|
21
|
+
* main is checked out in the primary clone). In the primary clone it is a perfectly good move and the
|
|
22
|
+
* allowance list below says so explicitly. The two used to disagree inside a single message — the
|
|
23
|
+
* header forbade it while the allowance list permitted "git checkout <other-branch>", and `main` is an
|
|
24
|
+
* other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded
|
|
25
|
+
* its only exit was creating a branch, which the branch cap then refused.
|
|
20
26
|
*/
|
|
21
27
|
class MergedBranchMessage {
|
|
22
28
|
recovery = new tree_recovery_1.TreeRecovery();
|
|
29
|
+
/**
|
|
30
|
+
* The ONE allowance list, shared by every guard that blocks while this state is up.
|
|
31
|
+
*
|
|
32
|
+
* Each guard used to print its own view of the world: this one's narrow bash allowlist, and
|
|
33
|
+
* read-stale-guard's "EVERY Bash command". Both statements were true of their own guard and false
|
|
34
|
+
* of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all
|
|
35
|
+
* Bash runs and that most Bash is blocked. One list, printed by both.
|
|
36
|
+
*/
|
|
37
|
+
allowances(kind) {
|
|
38
|
+
const switching = kind === 'worktree'
|
|
39
|
+
? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +
|
|
40
|
+
'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'
|
|
41
|
+
: ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +
|
|
42
|
+
'`git checkout main && git pull origin main && pnpm wp-cleanup` is allowed and is the ' +
|
|
43
|
+
'shortest exit; also git worktree add/remove/prune';
|
|
44
|
+
return [
|
|
45
|
+
'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',
|
|
46
|
+
' - the fresh-start / cleanup git commands above',
|
|
47
|
+
' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',
|
|
48
|
+
switching,
|
|
49
|
+
' - pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',
|
|
50
|
+
' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',
|
|
51
|
+
' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',
|
|
52
|
+
'',
|
|
53
|
+
'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',
|
|
54
|
+
'(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',
|
|
55
|
+
'One list — all three guards print exactly this one.',
|
|
56
|
+
];
|
|
57
|
+
}
|
|
23
58
|
// The diagnosis + cure. Identical for both guards — this is the part that must never drift.
|
|
24
59
|
common(branch, mergedPr, kind, worktreePath) {
|
|
25
60
|
const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';
|
|
@@ -39,16 +74,18 @@ class MergedBranchMessage {
|
|
|
39
74
|
}
|
|
40
75
|
forEdits(branch, mergedPr, kind = 'unknown', worktreePath = '<worktree-dir>') {
|
|
41
76
|
return this.common(branch, mergedPr, kind, worktreePath).concat([
|
|
77
|
+
'',
|
|
78
|
+
...this.allowances(kind),
|
|
42
79
|
'',
|
|
43
80
|
'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',
|
|
44
81
|
]).join('\n');
|
|
45
82
|
}
|
|
46
83
|
/**
|
|
47
|
-
* The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
84
|
+
* The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message
|
|
85
|
+
* has to spell out the narrow allowlist — otherwise an agent reads "blocked" and believes it is
|
|
86
|
+
* wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the
|
|
87
|
+
* `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit
|
|
88
|
+
* the guard again.
|
|
52
89
|
*/
|
|
53
90
|
forBash(branch, mergedPr, kind = 'unknown', worktreePath = '<worktree-dir>') {
|
|
54
91
|
return this.common(branch, mergedPr, kind, worktreePath).concat([
|
|
@@ -56,11 +93,7 @@ class MergedBranchMessage {
|
|
|
56
93
|
'Bash is blocked here because working on a merged branch (booting servers, running builds,',
|
|
57
94
|
'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',
|
|
58
95
|
'',
|
|
59
|
-
|
|
60
|
-
' - the fresh-start / cleanup git commands above',
|
|
61
|
-
' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status',
|
|
62
|
-
' - switching away: git checkout/switch <other-branch>, git worktree add/remove/prune',
|
|
63
|
-
' - pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',
|
|
96
|
+
...this.allowances(kind),
|
|
64
97
|
'',
|
|
65
98
|
'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',
|
|
66
99
|
]).join('\n');
|
|
@@ -76,10 +109,7 @@ class MergedBranchMessage {
|
|
|
76
109
|
'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',
|
|
77
110
|
'stale relative to origin/main, and a plan built on it is built on code that has moved.',
|
|
78
111
|
'',
|
|
79
|
-
|
|
80
|
-
' - EVERY Bash command (the git commands above, installs, builds, all git/gh)',
|
|
81
|
-
' - All Write/Edit (feature-branch-guard governs those separately)',
|
|
82
|
-
' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',
|
|
112
|
+
...this.allowances(kind),
|
|
83
113
|
'',
|
|
84
114
|
'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',
|
|
85
115
|
]).join('\n');
|
|
@@ -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":";;;AAAA,mDAAyD;AAEzD
|
|
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"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CommandScanner, CommandSegment } from '../command-scan';
|
|
2
|
+
/**
|
|
3
|
+
* What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has
|
|
4
|
+
* to answer before it can judge a compound command.
|
|
5
|
+
*
|
|
6
|
+
* WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged
|
|
7
|
+
* branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by
|
|
8
|
+
* reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as
|
|
9
|
+
* an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:
|
|
10
|
+
*
|
|
11
|
+
* pnpm wp-cleanup allowed
|
|
12
|
+
* pnpm wp-cleanup 2>&1 | tail -40 BLOCKED
|
|
13
|
+
* git fetch origin main allowed
|
|
14
|
+
* git fetch origin main 2>&1; echo BLOCKED
|
|
15
|
+
*
|
|
16
|
+
* Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not
|
|
17
|
+
* commands at all. So a segment is one of three things:
|
|
18
|
+
*
|
|
19
|
+
* - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).
|
|
20
|
+
* - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),
|
|
21
|
+
* or an always-inert command (`echo`, `cd`, `pwd`, `true`).
|
|
22
|
+
* - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell
|
|
23
|
+
* keywords are stripped, so `do gh pr list` classifies as `gh pr list`.
|
|
24
|
+
*
|
|
25
|
+
* Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`
|
|
26
|
+
* reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,
|
|
27
|
+
* `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a
|
|
28
|
+
* redirect to a file and is deliberately not caught.
|
|
29
|
+
*
|
|
30
|
+
* Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard
|
|
31
|
+
* pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)
|
|
32
|
+
* stays blocked.
|
|
33
|
+
*/
|
|
34
|
+
export type SegmentRole = 'structure' | 'shaping' | 'command';
|
|
35
|
+
/** Data-only (per CLAUDE.md, classes for data). */
|
|
36
|
+
export declare class SegmentVerdict {
|
|
37
|
+
role: SegmentRole;
|
|
38
|
+
/** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */
|
|
39
|
+
words: readonly string[];
|
|
40
|
+
constructor(role: SegmentRole, words: readonly string[]);
|
|
41
|
+
}
|
|
42
|
+
export declare class ShellSegmentScan {
|
|
43
|
+
private readonly scanner;
|
|
44
|
+
constructor(scanner?: CommandScanner);
|
|
45
|
+
classify(segment: CommandSegment): SegmentVerdict;
|
|
46
|
+
/**
|
|
47
|
+
* The effective argv of a segment with leading shell keywords removed — what a guard should judge
|
|
48
|
+
* instead of the raw words. `for b in $(…); do git status; done` splits into three segments and
|
|
49
|
+
* the middle one is literally `do git status`; without this, `do` is the command name and every
|
|
50
|
+
* loop body walks straight past a git allowlist.
|
|
51
|
+
*/
|
|
52
|
+
effectiveWords(segmentText: string): readonly string[];
|
|
53
|
+
private stripKeywords;
|
|
54
|
+
private redirectsToFile;
|
|
55
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ShellSegmentScan = exports.SegmentVerdict = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const command_scan_1 = require("../command-scan");
|
|
7
|
+
/** Data-only (per CLAUDE.md, classes for data). */
|
|
8
|
+
class SegmentVerdict {
|
|
9
|
+
role;
|
|
10
|
+
/** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */
|
|
11
|
+
words;
|
|
12
|
+
constructor(role, words) {
|
|
13
|
+
this.role = role;
|
|
14
|
+
this.words = words;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.SegmentVerdict = SegmentVerdict;
|
|
18
|
+
class ShellSegmentScan {
|
|
19
|
+
scanner;
|
|
20
|
+
constructor(scanner = new command_scan_1.CommandScanner()) {
|
|
21
|
+
this.scanner = scanner;
|
|
22
|
+
}
|
|
23
|
+
classify(segment) {
|
|
24
|
+
const words = this.stripKeywords(this.scanner.words(segment.text));
|
|
25
|
+
if (words.length === 0)
|
|
26
|
+
return STRUCTURE;
|
|
27
|
+
const head = path.basename(words[0]);
|
|
28
|
+
if (STRUCTURE_HEADS.has(head))
|
|
29
|
+
return STRUCTURE;
|
|
30
|
+
// A redirect can create or overwrite a file, so it is never inert — judge it as a command.
|
|
31
|
+
if (this.redirectsToFile(words))
|
|
32
|
+
return new SegmentVerdict('command', words);
|
|
33
|
+
if (ALWAYS_INERT.has(head))
|
|
34
|
+
return new SegmentVerdict('shaping', words);
|
|
35
|
+
if (segment.pipedInto && OUTPUT_FILTERS.has(head))
|
|
36
|
+
return new SegmentVerdict('shaping', words);
|
|
37
|
+
return new SegmentVerdict('command', words);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The effective argv of a segment with leading shell keywords removed — what a guard should judge
|
|
41
|
+
* instead of the raw words. `for b in $(…); do git status; done` splits into three segments and
|
|
42
|
+
* the middle one is literally `do git status`; without this, `do` is the command name and every
|
|
43
|
+
* loop body walks straight past a git allowlist.
|
|
44
|
+
*/
|
|
45
|
+
effectiveWords(segmentText) {
|
|
46
|
+
return this.stripKeywords(this.scanner.words(segmentText));
|
|
47
|
+
}
|
|
48
|
+
stripKeywords(words) {
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i]))
|
|
51
|
+
i++;
|
|
52
|
+
return words.slice(i);
|
|
53
|
+
}
|
|
54
|
+
// `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.
|
|
55
|
+
redirectsToFile(words) {
|
|
56
|
+
return words.some((word) => REDIRECT_TO_FILE.test(word));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.ShellSegmentScan = ShellSegmentScan;
|
|
60
|
+
const STRUCTURE = new SegmentVerdict('structure', []);
|
|
61
|
+
// Keywords that PRECEDE a real command; strip them and judge what follows.
|
|
62
|
+
const STRIPPABLE_KEYWORDS = new Set([
|
|
63
|
+
'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',
|
|
64
|
+
]);
|
|
65
|
+
// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`
|
|
66
|
+
// inside was already split into its own segment by CommandScanner) and the closing keywords.
|
|
67
|
+
const STRUCTURE_HEADS = new Set([
|
|
68
|
+
'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',
|
|
69
|
+
]);
|
|
70
|
+
// Commands that cannot read repo content or change the repo, piped or not.
|
|
71
|
+
const ALWAYS_INERT = new Set([
|
|
72
|
+
'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',
|
|
73
|
+
]);
|
|
74
|
+
// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.
|
|
75
|
+
const OUTPUT_FILTERS = new Set([
|
|
76
|
+
'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',
|
|
77
|
+
'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',
|
|
78
|
+
]);
|
|
79
|
+
const REDIRECT_TO_FILE = /^\d*>>?(?!&)/;
|
|
80
|
+
//# sourceMappingURL=shell-segment-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.pipedInto && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n // `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n private redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
|
|
@@ -29,10 +29,20 @@ class TreeRecovery {
|
|
|
29
29
|
` git worktree add ../${dir} -b ${newBranchName} origin/main`,
|
|
30
30
|
];
|
|
31
31
|
if (kind === 'worktree') {
|
|
32
|
-
return [
|
|
32
|
+
return [
|
|
33
|
+
'You are in a linked worktree (`git checkout main` fatals here — main is checked out in',
|
|
34
|
+
'the primary clone). Start the new work in its own worktree:',
|
|
35
|
+
...worktreeForm,
|
|
36
|
+
];
|
|
33
37
|
}
|
|
38
|
+
// The primary clone. NO "never `git checkout main`" here: that is a WORKTREE-only truth, and
|
|
39
|
+
// printing it in the primary clone forbids the shortest exit off a merged branch
|
|
40
|
+
// (`git checkout main && git pull origin main && pnpm wp-cleanup` — the exact command a human
|
|
41
|
+
// had to hand an agent that had wedged itself following this very message). Branching off
|
|
42
|
+
// origin/main is still what we RECOMMEND, because it works from any tree; it is no longer
|
|
43
|
+
// dressed up as the only legal move.
|
|
34
44
|
if (kind === 'branch') {
|
|
35
|
-
return ['Start fresh — branch off origin/main (
|
|
45
|
+
return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...branchForm];
|
|
36
46
|
}
|
|
37
47
|
return [
|
|
38
48
|
'Start fresh off origin/main. Pick the form for the tree you are in:',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":";;;AAAA,0DAA0D;AA0B1D,MAAa,YAAY;IACJ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD,mGAAmG;IACnG,MAAM,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;IACzE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,IAAc,EAAE,gBAAwB,sBAAsB;QAC1E,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,yDAAyD;QACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;YACnC,CAAC,CAAC,eAAe;YACjB,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG;YACf,yBAAyB;YACzB,qBAAqB,aAAa,cAAc;SACnD,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,yBAAyB;YACzB,yBAAyB,GAAG,OAAO,aAAa,cAAc;SACjE,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO,
|
|
1
|
+
{"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":";;;AAAA,0DAA0D;AA0B1D,MAAa,YAAY;IACJ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD,mGAAmG;IACnG,MAAM,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;IACzE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,IAAc,EAAE,gBAAwB,sBAAsB;QAC1E,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,yDAAyD;QACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;YACnC,CAAC,CAAC,eAAe;YACjB,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG;YACf,yBAAyB;YACzB,qBAAqB,aAAa,cAAc;SACnD,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,yBAAyB;YACzB,yBAAyB,GAAG,OAAO,aAAa,cAAc;SACjE,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,GAAG,YAAY;aAClB,CAAC;QACN,CAAC;QACD,6FAA6F;QAC7F,iFAAiF;QACjF,8FAA8F;QAC9F,0FAA0F;QAC1F,0FAA0F;QAC1F,qCAAqC;QACrC,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,+EAA+E,EAAE,GAAG,UAAU,CAAC,CAAC;QAC5G,CAAC;QACD,OAAO;YACH,qEAAqE;YACrE,2BAA2B;YAC3B,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;YACxD,8DAA8D;YAC9D,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;SAC7D,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,YAAY,CAAC,IAAc,EAAE,MAAc,EAAE,eAAuB,gBAAgB;QAChF,MAAM,UAAU,GAAG,gEAAgE,CAAC;QACpF,MAAM,YAAY,GACd,+CAA+C,YAAY,qBAAqB,MAAM,EAAE,CAAC;QAE7F,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,8EAA8E;gBAC9E,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,6BAA6B,EAAE,UAAU,CAAC,CAAC;QACvD,CAAC;QACD,OAAO;YACH,kDAAkD;YAClD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,0FAA0F;YAC1F,yCAAyC;YACzC,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,eAAe,CAAC,IAAc;QAC1B,MAAM,UAAU,GAAG,6CAA6C,CAAC;QACjE,MAAM,YAAY,GAAG,4DAA4D,CAAC;QAElF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;QACxC,CAAC;QACD,OAAO;YACH,qDAAqD;YACrD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,8DAA8D;YAC9D,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;CACJ;AAzHD,oCAyHC","sourcesContent":["import { WorktreeService } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ' git fetch origin main',\n ` git checkout -b ${newBranchName} origin/main`,\n ];\n const worktreeForm = [\n ' git fetch origin main',\n ` git worktree add ../${dir} -b ${newBranchName} origin/main`,\n ];\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree (`git checkout main` fatals here — main is checked out in',\n 'the primary clone). Start the new work in its own worktree:',\n ...worktreeForm,\n ];\n }\n // The primary clone. NO \"never `git checkout main`\" here: that is a WORKTREE-only truth, and\n // printing it in the primary clone forbids the shortest exit off a merged branch\n // (`git checkout main && git pull origin main && pnpm wp-cleanup` — the exact command a human\n // had to hand an agent that had wedged itself following this very message). Branching off\n // origin/main is still what we RECOMMEND, because it works from any tree; it is no longer\n // dressed up as the only legal move.\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...branchForm];\n }\n return [\n 'Start fresh off origin/main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ...branchForm.map((line: string): string => ` ${line}`),\n ' - in a linked worktree (`git checkout main` fatals there):',\n ...worktreeForm.map((line: string): string => ` ${line}`),\n ];\n }\n\n /**\n * Reap the tree you just finished with. The worktree order is load-bearing: prune clears\n * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the\n * branch delete must come LAST because git refuses to delete a branch a worktree still holds.\n *\n * The BRANCH form ends in `pnpm wp-cleanup`, not `git branch -d <branch>`. An agent reads a bare\n * `-d`/`-D` as destructive and stops to ask permission, so the branch survives the turn and local\n * branches pile up — the exact failure this whole cleanup path exists to prevent. wp-cleanup is\n * one named command that deletes only provably-dead branches (and reaps every OTHER dead one at\n * the same time), so it is safe to allowlist and never needs a judgement call.\n *\n * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches\n * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →\n * delete ordering is the part that has to be exactly right.\n */\n cleanupSteps(kind: TreeKind, branch: string, worktreePath: string = '<worktree-dir>'): string[] {\n const branchForm = ' git checkout main && git pull origin main && pnpm wp-cleanup';\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ' git checkout main && git pull origin main';\n const worktreeForm = ' git fetch origin main (then work off origin/main)';\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (`git checkout main` fatals there):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
|
package/src/core/runner.js
CHANGED
|
@@ -181,7 +181,7 @@ function runRead(filePath, cwd, mode = 'all') {
|
|
|
181
181
|
const groups = runFileRules(rules, ctx);
|
|
182
182
|
if (groups.length === 0)
|
|
183
183
|
return null;
|
|
184
|
-
return new types_1.BlockedResult((0, report_1.formatReport)(relativePath, groups));
|
|
184
|
+
return new types_1.BlockedResult((0, report_1.formatReport)(relativePath, groups, report_1.READ_SUBJECT));
|
|
185
185
|
}
|
|
186
186
|
// Installer bypass — package-manager install commands ALWAYS pass, ahead of any config load. A
|
|
187
187
|
// webpieces.config.json that is ahead of the installed validator (new rule tokens the published
|
|
@@ -269,9 +269,18 @@ function runBashInternal(command, cwd, mode) {
|
|
|
269
269
|
}
|
|
270
270
|
const ruleNames = groups.map((g) => g.ruleName).join(',');
|
|
271
271
|
(0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision(ruleNames, 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'BLOCK', 'bash-guard block'));
|
|
272
|
-
const report = (0, report_1.formatReport)(
|
|
272
|
+
const report = (0, report_1.formatReport)(commandLabel(command), groups, report_1.BASH_SUBJECT) + exemptTreesHint(groups, loaded.excludePaths.guards);
|
|
273
273
|
return new types_1.BlockedResult(report);
|
|
274
274
|
}
|
|
275
|
+
// The bash report's subject line. It used to be the literal string `<bash>`, which told the agent
|
|
276
|
+
// nothing; the command itself is what was blocked, so name it — truncated, because a heredoc-bearing
|
|
277
|
+
// command can run to thousands of characters and the violation lines already carry the detail.
|
|
278
|
+
// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
|
|
279
|
+
function commandLabel(command) {
|
|
280
|
+
const oneLine = command.replace(/\s+/g, ' ').trim();
|
|
281
|
+
const MAX = 100;
|
|
282
|
+
return oneLine.length <= MAX ? oneLine : oneLine.slice(0, MAX) + '…';
|
|
283
|
+
}
|
|
275
284
|
// When a push/PR block fires AND the config exempts vendored/nested trees, surface the escape hatch the
|
|
276
285
|
// AI cannot otherwise discover: git/gh run UNGUARDED inside those trees if it cd's there first (each is
|
|
277
286
|
// governed by its own repo, not this one). Scoped to pr-creation-or-push-guard — for the other guards
|