@webpieces/ai-hook-rules 0.4.509 → 0.4.511
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/read-only-inspection.d.ts +34 -0
- package/src/core/read-only-inspection.js +88 -0
- package/src/core/read-only-inspection.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 +92 -78
- 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
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
* fails loudly without touching anything.
|
|
16
16
|
*/
|
|
17
17
|
export declare class StaleMainMessage {
|
|
18
|
+
private readonly treeRoot;
|
|
19
|
+
/**
|
|
20
|
+
* `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried
|
|
21
|
+
* a leading `cd`). Pass it and the cure is rendered as `cd <treeRoot> && git pull …`, naming the
|
|
22
|
+
* directory outright.
|
|
23
|
+
*
|
|
24
|
+
* WHY that matters here specifically: in the field this guard told an agent working in a worktree
|
|
25
|
+
* to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the
|
|
26
|
+
* PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must
|
|
27
|
+
* never mutate a tree other than the one the command targeted, and naming it is how you ensure it.
|
|
28
|
+
*/
|
|
29
|
+
constructor(treeRoot?: string);
|
|
18
30
|
private common;
|
|
19
31
|
forReads(behindCount: string): string;
|
|
20
32
|
/**
|
|
@@ -18,15 +18,31 @@ exports.StaleMainMessage = void 0;
|
|
|
18
18
|
* fails loudly without touching anything.
|
|
19
19
|
*/
|
|
20
20
|
class StaleMainMessage {
|
|
21
|
+
treeRoot;
|
|
22
|
+
/**
|
|
23
|
+
* `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried
|
|
24
|
+
* a leading `cd`). Pass it and the cure is rendered as `cd <treeRoot> && git pull …`, naming the
|
|
25
|
+
* directory outright.
|
|
26
|
+
*
|
|
27
|
+
* WHY that matters here specifically: in the field this guard told an agent working in a worktree
|
|
28
|
+
* to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the
|
|
29
|
+
* PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must
|
|
30
|
+
* never mutate a tree other than the one the command targeted, and naming it is how you ensure it.
|
|
31
|
+
*/
|
|
32
|
+
constructor(treeRoot = '') {
|
|
33
|
+
this.treeRoot = treeRoot;
|
|
34
|
+
}
|
|
21
35
|
// The diagnosis + cure. Identical for both guards — the part that must never drift.
|
|
22
36
|
common(behindCount) {
|
|
37
|
+
const pull = 'git pull --ff-only origin main';
|
|
23
38
|
return [
|
|
24
39
|
`You are on main and main is ${behindCount} commit(s) behind origin/main.`,
|
|
40
|
+
...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch main)`] : []),
|
|
25
41
|
'Anything you read here is STALE, and every plan built from it is built on code that no',
|
|
26
42
|
'longer exists upstream.',
|
|
27
43
|
'',
|
|
28
44
|
'Run exactly this, then retry:',
|
|
29
|
-
|
|
45
|
+
` ${this.treeRoot !== '' ? `cd ${this.treeRoot} && ${pull}` : pull}`,
|
|
30
46
|
'',
|
|
31
47
|
'If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD holds a',
|
|
32
48
|
'duplicate entry — clear it with `git fetch --prune origin main`, then pull again.',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stale-main-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-message.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAa,gBAAgB;
|
|
1
|
+
{"version":3,"file":"stale-main-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-message.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAa,gBAAgB;IAWI;IAV7B;;;;;;;;;OASG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;IAAG,CAAC;IAEtD,oFAAoF;IAC5E,MAAM,CAAC,WAAmB;QAC9B,MAAM,IAAI,GAAG,gCAAgC,CAAC;QAC9C,OAAO;YACH,+BAA+B,WAAW,gCAAgC;YAC1E,GAAG,CAAC,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,sBAAsB,IAAI,CAAC,QAAQ,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACvF,wFAAwF;YACxF,yBAAyB;YACzB,EAAE;YACF,+BAA+B;YAC/B,KAAK,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE;YACrE,EAAE;YACF,yFAAyF;YACzF,mFAAmF;SACtF,CAAC;IACN,CAAC;IAED,QAAQ,CAAC,WAAmB;QACxB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,uCAAuC;YACvC,2FAA2F;YAC3F,mDAAmD;YACnD,oEAAoE;YACpE,0FAA0F;SAC7F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;IAED;;;;OAIG;IACH,OAAO,CAAC,WAAmB;QACvB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,yFAAyF;YACzF,wFAAwF;YACxF,yFAAyF;YACzF,yFAAyF;YACzF,EAAE;YACF,qDAAqD;YACrD,wFAAwF;YACxF,uFAAuF;YACvF,sGAAsG;YACtG,uGAAuG;SAC1G,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;CACJ;AA7DD,4CA6DC","sourcesContent":["/**\n * The \"you are on a stale main\" text, shared by the TWO guards that detect the state from the same\n * cached signal (`MainSyncStatus.localMain` vs `originMain`):\n *\n * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}\n * - stale-main-bash-guard blocks content-read Bash → {@link StaleMainMessage.forBash}\n *\n * One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the\n * AI follows literally, so two drifting copies mean two behaviors for one repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The cure is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which\n * is the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either\n * fast-forwards (the case here, since the block only fires when the tree is clean and behind) or\n * fails loudly without touching anything.\n */\nexport class StaleMainMessage {\n /**\n * `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried\n * a leading `cd`). Pass it and the cure is rendered as `cd <treeRoot> && git pull …`, naming the\n * directory outright.\n *\n * WHY that matters here specifically: in the field this guard told an agent working in a worktree\n * to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the\n * PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must\n * never mutate a tree other than the one the command targeted, and naming it is how you ensure it.\n */\n constructor(private readonly treeRoot: string = '') {}\n\n // The diagnosis + cure. Identical for both guards — the part that must never drift.\n private common(behindCount: string): string[] {\n const pull = 'git pull --ff-only origin main';\n return [\n `You are on main and main is ${behindCount} commit(s) behind origin/main.`,\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch main)`] : []),\n 'Anything you read here is STALE, and every plan built from it is built on code that no',\n 'longer exists upstream.',\n '',\n 'Run exactly this, then retry:',\n ` ${this.treeRoot !== '' ? `cd ${this.treeRoot} && ${pull}` : pull}`,\n '',\n 'If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD holds a',\n 'duplicate entry — clear it with `git fetch --prune origin main`, then pull again.',\n ];\n }\n\n forReads(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'Still allowed while this block is up:',\n ' - Bash that does not read repo files: builds, tests, installs, the pull itself, and all',\n ' git/gh METADATA (status|log|diff|show|branch)',\n ' - All Write/Edit (feature-branch-guard governs those separately)',\n ' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. stale-main-bash-guard blocks only CONTENT reads, so the message has to say\n * which shell is still open — an agent that reads \"Bash blocked\" and believes the whole shell is\n * gone will not run the cure, which is itself a Bash command.\n */\n forBash(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'This command was blocked because it reads FILE CONTENT out of the stale tree (cat/grep/',\n 'ls/find/sed/awk/git grep/git show <rev>:<path>). That is how stale bytes get into your',\n 'context and quietly poison everything you conclude — the incident behind this guard had',\n 'an agent describing a CI workflow set that was missing a whole workflow added upstream.',\n '',\n 'Still allowed right now (the cure is one of these):',\n ' - git pull/fetch, installs, upgrades, builds, tests, every other non-reading command',\n ' - git/gh METADATA: status, log, diff, show <rev>, branch, rev-list, gh pr list|view',\n ' - reads against the CURRENT upstream tree: git show origin/main:<path>, git grep <pat> origin/main',\n ' - all Write/Edit, and reading webpieces.config.json (set stale-main-bash-guard mode OFF to disable)',\n ]).join('\\n');\n }\n}\n"]}
|
|
@@ -22,9 +22,25 @@
|
|
|
22
22
|
*/
|
|
23
23
|
export type TreeKind = 'worktree' | 'branch' | 'unknown';
|
|
24
24
|
export declare class TreeRecovery {
|
|
25
|
+
private readonly treeRoot;
|
|
25
26
|
private readonly worktrees;
|
|
27
|
+
/**
|
|
28
|
+
* `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as
|
|
29
|
+
* `cd <treeRoot> && <command>`.
|
|
30
|
+
*
|
|
31
|
+
* WHY: a Bash tool call does NOT persist `cd` between calls, so an agent working in a linked
|
|
32
|
+
* worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore
|
|
33
|
+
* runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone
|
|
34
|
+
* the agent had been explicitly forbidden to touch. Naming the directory in the command itself is
|
|
35
|
+
* the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`
|
|
36
|
+
* cannot change what a command does to a repo, so the guards accept it.
|
|
37
|
+
*
|
|
38
|
+
* Empty (the default) renders the bare commands, for callers with no root to name.
|
|
39
|
+
*/
|
|
40
|
+
constructor(treeRoot?: string);
|
|
26
41
|
/** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
|
|
27
42
|
kindOf(root: string): TreeKind;
|
|
43
|
+
private at;
|
|
28
44
|
/**
|
|
29
45
|
* Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
|
|
30
46
|
* works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
|
|
@@ -3,11 +3,32 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.TreeRecovery = void 0;
|
|
4
4
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
5
|
class TreeRecovery {
|
|
6
|
+
treeRoot;
|
|
6
7
|
worktrees = new rules_config_1.WorktreeService();
|
|
8
|
+
/**
|
|
9
|
+
* `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as
|
|
10
|
+
* `cd <treeRoot> && <command>`.
|
|
11
|
+
*
|
|
12
|
+
* WHY: a Bash tool call does NOT persist `cd` between calls, so an agent working in a linked
|
|
13
|
+
* worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore
|
|
14
|
+
* runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone
|
|
15
|
+
* the agent had been explicitly forbidden to touch. Naming the directory in the command itself is
|
|
16
|
+
* the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`
|
|
17
|
+
* cannot change what a command does to a repo, so the guards accept it.
|
|
18
|
+
*
|
|
19
|
+
* Empty (the default) renders the bare commands, for callers with no root to name.
|
|
20
|
+
*/
|
|
21
|
+
constructor(treeRoot = '') {
|
|
22
|
+
this.treeRoot = treeRoot;
|
|
23
|
+
}
|
|
7
24
|
/** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
|
|
8
25
|
kindOf(root) {
|
|
9
26
|
return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';
|
|
10
27
|
}
|
|
28
|
+
// Render one command in the form that survives a tool call: `cd <root> && <command>`.
|
|
29
|
+
at(command) {
|
|
30
|
+
return this.treeRoot === '' ? command : `cd ${this.treeRoot} && ${command}`;
|
|
31
|
+
}
|
|
11
32
|
/**
|
|
12
33
|
* Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
|
|
13
34
|
* works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
|
|
@@ -21,12 +42,12 @@ class TreeRecovery {
|
|
|
21
42
|
? '<feature-dir>'
|
|
22
43
|
: newBranchName.replace(/\//g, '-');
|
|
23
44
|
const branchForm = [
|
|
24
|
-
'
|
|
25
|
-
` git checkout -b ${newBranchName} origin/main`,
|
|
45
|
+
` ${this.at('git fetch origin main')}`,
|
|
46
|
+
` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,
|
|
26
47
|
];
|
|
27
48
|
const worktreeForm = [
|
|
28
|
-
'
|
|
29
|
-
` git worktree add ../${dir} -b ${newBranchName} origin/main`,
|
|
49
|
+
` ${this.at('git fetch origin main')}`,
|
|
50
|
+
` ${this.at(`git worktree add ../${dir} -b ${newBranchName} origin/main`)}`,
|
|
30
51
|
];
|
|
31
52
|
if (kind === 'worktree') {
|
|
32
53
|
return [
|
|
@@ -68,7 +89,7 @@ class TreeRecovery {
|
|
|
68
89
|
* delete ordering is the part that has to be exactly right.
|
|
69
90
|
*/
|
|
70
91
|
cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
|
|
71
|
-
const branchForm = '
|
|
92
|
+
const branchForm = ` ${this.at('git checkout main && git pull origin main && pnpm wp-cleanup')}`;
|
|
72
93
|
const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
|
|
73
94
|
if (kind === 'worktree') {
|
|
74
95
|
return [
|
|
@@ -95,8 +116,8 @@ class TreeRecovery {
|
|
|
95
116
|
* you need to then branch off `origin/main`.
|
|
96
117
|
*/
|
|
97
118
|
updateMainSteps(kind) {
|
|
98
|
-
const branchForm = '
|
|
99
|
-
const worktreeForm = '
|
|
119
|
+
const branchForm = ` ${this.at('git checkout main && git pull origin main')}`;
|
|
120
|
+
const worktreeForm = ` ${this.at('git fetch origin main')} (then work off origin/main)`;
|
|
100
121
|
if (kind === 'worktree') {
|
|
101
122
|
return [
|
|
102
123
|
'You are in a linked worktree — `git checkout main` fatals here (main is checked out 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;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"]}
|
|
1
|
+
{"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":"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","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 /**\n * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as\n * `cd <treeRoot> && <command>`.\n *\n * WHY: a Bash tool call does NOT persist `cd` between calls, so an agent working in a linked\n * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore\n * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone\n * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is\n * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`\n * cannot change what a command does to a repo, so the guards accept it.\n *\n * Empty (the default) renders the bare commands, for callers with no root to name.\n */\n constructor(private readonly treeRoot: string = '') {}\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 // Render one command in the form that survives a tool call: `cd <root> && <command>`.\n private at(command: string): string {\n return this.treeRoot === '' ? command : `cd ${this.treeRoot} && ${command}`;\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 ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,\n ];\n const worktreeForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`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 = ` ${this.at('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 = ` ${this.at('git checkout main && git pull origin main')}`;\n const worktreeForm = ` ${this.at('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
|
@@ -9,16 +9,16 @@ exports.runRead = runRead;
|
|
|
9
9
|
exports.runRuleCheck = runRuleCheck;
|
|
10
10
|
const tslib_1 = require("tslib");
|
|
11
11
|
const path = tslib_1.__importStar(require("path"));
|
|
12
|
-
const child_process_1 = require("child_process");
|
|
13
12
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
14
13
|
const build_context_1 = require("./build-context");
|
|
15
|
-
const
|
|
14
|
+
const effective_tree_1 = require("./effective-tree");
|
|
16
15
|
const load_rules_1 = require("./load-rules");
|
|
17
16
|
const match_rule_1 = require("./rules/match-rule");
|
|
18
17
|
const main_sync_refresh_1 = require("./main-sync-refresh");
|
|
19
18
|
const decision_log_1 = require("./decision-log");
|
|
20
19
|
const to_error_1 = require("./to-error");
|
|
21
20
|
const report_1 = require("./report");
|
|
21
|
+
const read_only_inspection_1 = require("./read-only-inspection");
|
|
22
22
|
const shim_1 = require("../bin/shim");
|
|
23
23
|
const types_1 = require("./types");
|
|
24
24
|
// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a
|
|
@@ -42,46 +42,12 @@ function filterByExcludedPaths(rules, relativePath, ex) {
|
|
|
42
42
|
return !patterns.some((p) => (0, load_rules_1.globMatches)(p, relativePath));
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
-
// The
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
function gitToplevel(cwd) {
|
|
49
|
-
const r = (0, child_process_1.spawnSync)('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
50
|
-
return r.status === 0 ? (r.stdout ?? '').trim() : null;
|
|
51
|
-
}
|
|
52
|
-
// True when `cwd` sits inside a git repo OTHER than the one `workspaceRoot` governs (a nested clone).
|
|
53
|
-
// Not in a git repo / git unavailable (null) is NOT foreign — it falls through to the normal guards.
|
|
54
|
-
// 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
|
|
55
|
-
function isForeignGitRepo(cwd, workspaceRoot) {
|
|
56
|
-
const gitRoot = gitToplevel(cwd);
|
|
57
|
-
return gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot);
|
|
58
|
-
}
|
|
59
|
-
// The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command itself.
|
|
60
|
-
// PreToolUse fires BEFORE the command runs, so the shell's `cwd` is the pre-`cd` directory; a command
|
|
61
|
-
// like `cd repositories/clone && git push` really executes in `repositories/clone`. Every git-boundary
|
|
62
|
-
// and excludePaths decision must key off THIS directory, not the pre-`cd` one, or a nested clone is
|
|
63
|
-
// judged against the outer repo.
|
|
64
|
-
//
|
|
65
|
-
// ONLY a leading run of cd/pushd counts. Once a non-cd command appears it has ALREADY run in the
|
|
66
|
-
// current dir, so a later cd must not retroactively pull it out of scope — otherwise a trailing
|
|
67
|
-
// `... && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the
|
|
68
|
-
// guards. `cd a && cd b && git …` (leading run) resolves left to right, matching the shell.
|
|
69
|
-
//
|
|
70
|
-
// Reuses CommandScanner so quoting is handled exactly as the guards handle it: `echo "cd sub && git
|
|
71
|
-
// push"` is ONE opaque segment whose first word is `echo`, so the quoted `cd` is never picked up —
|
|
72
|
-
// the prose/quoted `cd` cannot be weaponised into a scope escape.
|
|
45
|
+
// The cwd a command actually runs from, after its own leading `cd`/`pushd` run. Thin delegate kept
|
|
46
|
+
// for the callers (and specs) that only need the directory; the full tree classification — primary
|
|
47
|
+
// clone vs linked worktree vs nested clone vs outside any repo — is EffectiveTreeResolver.resolve().
|
|
73
48
|
// 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
|
|
74
49
|
function effectiveBashCwd(command, cwd) {
|
|
75
|
-
|
|
76
|
-
let effective = cwd;
|
|
77
|
-
for (const segment of scanner.commandSegments(command)) {
|
|
78
|
-
const words = scanner.words(segment);
|
|
79
|
-
if (words[0] !== 'cd' && words[0] !== 'pushd')
|
|
80
|
-
break;
|
|
81
|
-
if (words[1] !== undefined)
|
|
82
|
-
effective = path.resolve(effective, words[1]);
|
|
83
|
-
}
|
|
84
|
-
return effective;
|
|
50
|
+
return new effective_tree_1.EffectiveTreeResolver().effectiveCwd(command, cwd);
|
|
85
51
|
}
|
|
86
52
|
// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).
|
|
87
53
|
const GIT_OR_GH_RE = /(?:^|[;&|]\s*)(?:git|gh)\b/;
|
|
@@ -168,9 +134,9 @@ function runRead(filePath, cwd, mode = 'all') {
|
|
|
168
134
|
if (loaded.configPath === null)
|
|
169
135
|
return null;
|
|
170
136
|
const workspaceRoot = path.dirname(loaded.configPath);
|
|
171
|
-
// Same git-repo-boundary governance as bash: a read inside a different
|
|
172
|
-
|
|
173
|
-
if (
|
|
137
|
+
// Same git-repo-boundary governance as bash, through the SAME resolver: a read inside a different
|
|
138
|
+
// clone is out of scope. (No command to parse here, so the shell cwd IS the effective cwd.)
|
|
139
|
+
if (new effective_tree_1.EffectiveTreeResolver().resolve('', cwd, workspaceRoot).kind === 'foreign')
|
|
174
140
|
return null;
|
|
175
141
|
const relativePath = path.relative(workspaceRoot, filePath);
|
|
176
142
|
const all = (0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot);
|
|
@@ -188,52 +154,99 @@ function runRead(filePath, cwd, mode = 'all') {
|
|
|
188
154
|
// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very
|
|
189
155
|
// command that updates the validator (deadlock). Mirrors the fail-closed shim's INSTALLER_ALLOW_ERE
|
|
190
156
|
// (missing-bin case); INSTALLER_ALLOW_JS is its locked JS twin. Match is tight (`pnpm install` /
|
|
191
|
-
// `npm i` + `--flags
|
|
157
|
+
// `npm i` + `--flags`, plus an optional LEADING `cd <path> &&` so the cure is typable from a
|
|
158
|
+
// worktree) so `pnpm install && rm -rf /` still falls to the guards.
|
|
192
159
|
function isInstallerCommand(command) {
|
|
193
160
|
return shim_1.INSTALLER_ALLOW_JS.test(command.trim());
|
|
194
161
|
}
|
|
195
|
-
// Force-to-root: git/gh commands must run from the repo root
|
|
196
|
-
// state coherently. Fires ONLY when the shell is STUCK in a governed subdir — the
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
162
|
+
// Force-to-root: git/gh commands must run from the repo root of the tree they act on, where the guards
|
|
163
|
+
// can reason about git state coherently. Fires ONLY when the shell is STUCK in a governed subdir — the
|
|
164
|
+
// shell persists in a subdir AND the command does not `cd` to a tree root (`tree.root`, which is the
|
|
165
|
+
// linked worktree's root when the command cd's into one). A command that explicitly cd's to a root is
|
|
166
|
+
// judged by the normal guards, never here.
|
|
167
|
+
//
|
|
168
|
+
// The remedy is emitted as ONE runnable line, `cd <root> && <the original command>`, because `cd` does
|
|
169
|
+
// not persist between tool calls: telling the agent to "cd first, then re-run" costs a turn and the
|
|
170
|
+
// next call starts back in the old directory anyway. The bare-`cd` advice is what made this guard
|
|
171
|
+
// print the very command it had just rejected.
|
|
202
172
|
// 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
|
|
203
|
-
function gitFromSubdirBlock(command,
|
|
204
|
-
const
|
|
205
|
-
const cdsToRoot = path.resolve(effectiveCwd) === path.resolve(
|
|
206
|
-
if (!isGitOrGhCommand(command) ||
|
|
173
|
+
function gitFromSubdirBlock(command, tree) {
|
|
174
|
+
const shellAtRoot = path.resolve(tree.shellCwd) === path.resolve(tree.root);
|
|
175
|
+
const cdsToRoot = path.resolve(tree.effectiveCwd) === path.resolve(tree.root);
|
|
176
|
+
if (!isGitOrGhCommand(command) || shellAtRoot || cdsToRoot)
|
|
207
177
|
return null;
|
|
208
178
|
const report = `❌ Run git/gh commands from the repo root, not a subdirectory.\n` +
|
|
209
|
-
` You are in: ${
|
|
210
|
-
`
|
|
211
|
-
`
|
|
212
|
-
|
|
179
|
+
` You are in: ${tree.shellCwd}\n` +
|
|
180
|
+
` Judged against: ${tree.root}\n` +
|
|
181
|
+
` Run EXACTLY this instead (one line — \`cd\` does NOT persist between tool calls):\n` +
|
|
182
|
+
` ${(0, effective_tree_1.atRoot)(tree.root, command)}\n` +
|
|
183
|
+
` A leading \`cd <path> &&\` is ACCEPTED by the guards — it cannot change what the command\n` +
|
|
184
|
+
` does to the repo. (The webpieces guards evaluate the repo's git state at its root.)`;
|
|
185
|
+
(0, decision_log_1.logGuardDecision)(tree.root, new decision_log_1.GuardDecision('force-to-root', 'Bash', command, (0, decision_log_1.branchForLog)(tree.root), 'BLOCK', 'git/gh from subdir'));
|
|
213
186
|
return new types_1.BlockedResult(report);
|
|
214
187
|
}
|
|
188
|
+
// The installer bypass's audit line. Anchored at the repo root that owns `.webpieces` — RepoRootFinder
|
|
189
|
+
// (config-walk-up first, then git toplevel) is the authority for that, and it is correct in a linked
|
|
190
|
+
// worktree because each worktree checks out its own webpieces.config.json. This runs BEFORE
|
|
191
|
+
// loadAndValidate, which is why it resolves the root itself rather than using workspaceRoot.
|
|
192
|
+
// 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
|
|
193
|
+
function logInstallerBypass(command, cwd) {
|
|
194
|
+
const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
195
|
+
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(root), 'ALLOW', 'installer bypass (always allowed)'));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Load the config for the bash path — but do NOT let an unloadable config trap the tools needed to
|
|
199
|
+
* repair it.
|
|
200
|
+
*
|
|
201
|
+
* loadAndValidate throws an InformAiError when webpieces.config.json is unparseable (a real syntax
|
|
202
|
+
* error, or leftover `<<<<<<< HEAD` markers mid-merge) or fails validation. That throw propagates to
|
|
203
|
+
* the hook adapter, which fails CLOSED and denies the command — correct for work, since a config that
|
|
204
|
+
* did not load means no guards ran. But it denied `cat`/`grep`/`sed -n` on webpieces.config.json too,
|
|
205
|
+
* i.e. it blocked the only way to see the problem it was reporting. Observed live, twice.
|
|
206
|
+
*
|
|
207
|
+
* So: on a load failure, a provably-inert INSPECTION command is allowed through (returns null, "no
|
|
208
|
+
* block"), matching the escape hatch every other layer already grants this file. Everything else —
|
|
209
|
+
* every write, every git/gh command, every build — still hits the same hard failure as before. The
|
|
210
|
+
* bypass cannot be widened by accident; see ReadOnlyInspectionScan for how narrow "inert" is.
|
|
211
|
+
*
|
|
212
|
+
* Returns the loaded config, or null meaning "allow this command without guards".
|
|
213
|
+
*/
|
|
214
|
+
// 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
|
|
215
|
+
function loadConfigOrAllowInspection(command, cwd) {
|
|
216
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrown unchanged unless the command is provably inert
|
|
217
|
+
try {
|
|
218
|
+
return (0, rules_config_1.loadAndValidate)(cwd);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
const error = (0, to_error_1.toError)(err);
|
|
222
|
+
if (error instanceof types_1.InformAiError && new read_only_inspection_1.ReadOnlyInspectionScan().isReadOnlyInspection(command)) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
215
228
|
function runBashInternal(command, cwd, mode) {
|
|
216
229
|
if (isInstallerCommand(command)) {
|
|
217
|
-
|
|
218
|
-
// then git toplevel) so a bypass logged from a subdir/nested clone never scatters a stray
|
|
219
|
-
// `.webpieces` tree. This runs before loadAndValidate, so resolveRepoRoot (not workspaceRoot).
|
|
220
|
-
const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
221
|
-
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(root), 'ALLOW', 'installer bypass (always allowed)'));
|
|
230
|
+
logInstallerBypass(command, cwd);
|
|
222
231
|
return null;
|
|
223
232
|
}
|
|
224
|
-
const loaded = (
|
|
233
|
+
const loaded = loadConfigOrAllowInspection(command, cwd);
|
|
234
|
+
// null = the config would not load AND this command only inspects → allow, see the helper.
|
|
235
|
+
if (loaded === null)
|
|
236
|
+
return null;
|
|
225
237
|
if (loaded.configPath === null)
|
|
226
238
|
return new types_1.BlockedResult(CONFIG_MISSING_REPORT);
|
|
227
239
|
const workspaceRoot = path.dirname(loaded.configPath);
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
240
|
+
// WHICH TREE does this command act on? Not necessarily the shell's cwd — an agent working in a
|
|
241
|
+
// linked worktree writes `cd <worktree> && …` because `cd` does not persist between tool calls.
|
|
242
|
+
// ONE resolver answers this for the guards AND for force-to-root below, so the two can never
|
|
243
|
+
// disagree about which tree you are in.
|
|
244
|
+
const tree = new effective_tree_1.EffectiveTreeResolver().resolve(command, cwd, workspaceRoot);
|
|
232
245
|
// Git-repo-boundary governance: the command runs inside a DIFFERENT git repo than this
|
|
233
246
|
// webpieces.config governs (e.g. a clone under repositories/). Out of scope → allow, hands-off.
|
|
234
|
-
// Intentional, not a silent hole.
|
|
235
|
-
//
|
|
236
|
-
if (
|
|
247
|
+
// Intentional, not a silent hole. A LINKED WORKTREE of this repo is deliberately NOT foreign — it
|
|
248
|
+
// is the same project, so the guards run against THAT tree's branch and cache.
|
|
249
|
+
if (tree.kind === 'foreign') {
|
|
237
250
|
(0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));
|
|
238
251
|
return null;
|
|
239
252
|
}
|
|
@@ -241,21 +254,22 @@ function runBashInternal(command, cwd, mode) {
|
|
|
241
254
|
// cwd sits under an excluded tree (e.g. repositories/**) drops the whole guard set — matching how
|
|
242
255
|
// runInternal/runRead treat file paths. The relative path is '' when there is no `cd` (root), which
|
|
243
256
|
// matches no exclusion glob, so a plain command at the repo root is unaffected.
|
|
244
|
-
const rules = filterByExcludedPaths(filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot), mode), path.relative(workspaceRoot, effectiveCwd), loaded.excludePaths);
|
|
257
|
+
const rules = filterByExcludedPaths(filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot), mode), path.relative(workspaceRoot, tree.effectiveCwd), loaded.excludePaths);
|
|
245
258
|
if (rules.length === 0)
|
|
246
259
|
return null;
|
|
247
260
|
const outOfSync = checkConfigSync(rules, loaded.rulesConfig);
|
|
248
261
|
if (outOfSync)
|
|
249
262
|
return outOfSync;
|
|
250
|
-
const subdirBlock = gitFromSubdirBlock(command,
|
|
263
|
+
const subdirBlock = gitFromSubdirBlock(command, tree);
|
|
251
264
|
if (subdirBlock)
|
|
252
265
|
return subdirBlock;
|
|
253
266
|
// Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs
|
|
254
267
|
// far more bash than edits, so refreshing here means the guard's next file-edit check reads a
|
|
255
268
|
// fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is
|
|
256
269
|
// loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
// Keyed on the JUDGED tree, so a worktree's cache is refreshed rather than the primary clone's.
|
|
271
|
+
maybeRefreshMainSync(rules, tree.root);
|
|
272
|
+
const ctx = (0, build_context_1.buildBashContext)(command, tree);
|
|
259
273
|
const groups = runBashRules(rules, ctx);
|
|
260
274
|
if (groups.length === 0) {
|
|
261
275
|
// Record the ALLOW only for git/gh commands — the operations the bash guards actually reason
|
|
@@ -263,12 +277,12 @@ function runBashInternal(command, cwd, mode) {
|
|
|
263
277
|
// focused (the whole point of the log is "why did/didn't a guard fire?"). Blocks are always
|
|
264
278
|
// logged below.
|
|
265
279
|
if (/\b(?:git|gh)\b/.test(command)) {
|
|
266
|
-
(0, decision_log_1.logGuardDecision)(
|
|
280
|
+
(0, decision_log_1.logGuardDecision)(tree.root, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(tree.root), 'ALLOW', 'no bash-guard block'));
|
|
267
281
|
}
|
|
268
282
|
return null;
|
|
269
283
|
}
|
|
270
284
|
const ruleNames = groups.map((g) => g.ruleName).join(',');
|
|
271
|
-
(0, decision_log_1.logGuardDecision)(
|
|
285
|
+
(0, decision_log_1.logGuardDecision)(tree.root, new decision_log_1.GuardDecision(ruleNames, 'Bash', command, (0, decision_log_1.branchForLog)(tree.root), 'BLOCK', 'bash-guard block'));
|
|
272
286
|
const report = (0, report_1.formatReport)(commandLabel(command), groups, report_1.BASH_SUBJECT) + exemptTreesHint(groups, loaded.excludePaths.guards);
|
|
273
287
|
return new types_1.BlockedResult(report);
|
|
274
288
|
}
|