@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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/bin/shim.d.ts +2 -0
  3. package/src/bin/shim.js +46 -19
  4. package/src/bin/shim.js.map +1 -1
  5. package/src/core/build-context.d.ts +2 -1
  6. package/src/core/build-context.js +5 -2
  7. package/src/core/build-context.js.map +1 -1
  8. package/src/core/effective-tree.d.ts +75 -0
  9. package/src/core/effective-tree.js +136 -0
  10. package/src/core/effective-tree.js.map +1 -0
  11. package/src/core/read-only-inspection.d.ts +34 -0
  12. package/src/core/read-only-inspection.js +88 -0
  13. package/src/core/read-only-inspection.js.map +1 -0
  14. package/src/core/rules/content-read-scan.d.ts +29 -4
  15. package/src/core/rules/content-read-scan.js +54 -9
  16. package/src/core/rules/content-read-scan.js.map +1 -1
  17. package/src/core/rules/feature-branch-guard.js +1 -1
  18. package/src/core/rules/feature-branch-guard.js.map +1 -1
  19. package/src/core/rules/merged-branch-bash-guard.js +8 -2
  20. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  21. package/src/core/rules/merged-branch-message.d.ts +8 -0
  22. package/src/core/rules/merged-branch-message.js +17 -1
  23. package/src/core/rules/merged-branch-message.js.map +1 -1
  24. package/src/core/rules/read-stale-guard.js +2 -2
  25. package/src/core/rules/read-stale-guard.js.map +1 -1
  26. package/src/core/rules/stale-main-bash-guard.js +2 -2
  27. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  28. package/src/core/rules/stale-main-message.d.ts +12 -0
  29. package/src/core/rules/stale-main-message.js +17 -1
  30. package/src/core/rules/stale-main-message.js.map +1 -1
  31. package/src/core/rules/tree-recovery.d.ts +16 -0
  32. package/src/core/rules/tree-recovery.js +28 -7
  33. package/src/core/rules/tree-recovery.js.map +1 -1
  34. package/src/core/runner.js +92 -78
  35. package/src/core/runner.js.map +1 -1
  36. package/src/core/types.d.ts +12 -1
  37. package/src/core/types.js +14 -1
  38. package/src/core/types.js.map +1 -1
  39. 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
- ' git pull --ff-only origin main',
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;IACzB,oFAAoF;IAC5E,MAAM,CAAC,WAAmB;QAC9B,OAAO;YACH,+BAA+B,WAAW,gCAAgC;YAC1E,wFAAwF;YACxF,yBAAyB;YACzB,EAAE;YACF,+BAA+B;YAC/B,kCAAkC;YAClC,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;AA/CD,4CA+CC","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 // The diagnosis + cure. Identical for both guards — the part that must never drift.\n private common(behindCount: string): string[] {\n return [\n `You are on main and main is ${behindCount} commit(s) behind origin/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 ' git pull --ff-only origin main',\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"]}
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
- ' git fetch origin main',
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
- ' git fetch origin main',
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 = ' git checkout main && git pull origin main && pnpm wp-cleanup';
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 = ' git checkout main && git pull origin main';
99
- const worktreeForm = ' git fetch origin main (then work off origin/main)';
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"]}
@@ -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 command_scan_1 = require("./command-scan");
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 git repo root of `cwd`, or null if cwd is not in a git repo / git is unavailable. This is the
46
- // repo-boundary signal: the guards only govern commands whose repo IS the one this webpieces.config
47
- // governs; a command run inside a nested clone (different git root) is out of scope.
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
- const scanner = new command_scan_1.CommandScanner();
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 clone is out of scope.
172
- const gitRoot = gitToplevel(cwd);
173
- if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot))
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` only, no chaining) so `pnpm install && rm -rf /` still falls to the guards.
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, where the guards can reason about git
196
- // state coherently. Fires ONLY when the shell is STUCK in a governed subdir — the shell persists in a
197
- // subdir (raw `cwd`) AND the command does not `cd` back to the root (`effectiveCwd`). A command that
198
- // explicitly cd's to the root runs at the root, so it is judged by the normal guards, never here:
199
- // without the effectiveCwd condition, `cd <root> && git push` from a nested clone was force-to-root
200
- // blocked while advising the very `cd <root>` it just ran (the self-contradicting dead-end this fixes).
201
- // Returns null when there is nothing to block.
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, cwd, effectiveCwd, workspaceRoot) {
204
- const atRoot = path.resolve(cwd) === path.resolve(workspaceRoot);
205
- const cdsToRoot = path.resolve(effectiveCwd) === path.resolve(workspaceRoot);
206
- if (!isGitOrGhCommand(command) || atRoot || cdsToRoot)
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: ${cwd}\n` +
210
- ` cd to the repo root first: cd ${workspaceRoot}\n` +
211
- ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;
212
- (0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision('force-to-root', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'BLOCK', 'git/gh from subdir'));
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
- // Anchor the audit-log write at the repo root that owns `.webpieces` (config-walk-up first,
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 = (0, rules_config_1.loadAndValidate)(cwd);
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
- // The directory the command actually runs from (after any in-command `cd`), not the pre-`cd`
229
- // shell cwd. Both the git-boundary check and the excludePaths filter below key off this, so a
230
- // self-contained `cd <nested clone> && …` is judged against the clone, not the outer repo.
231
- const effectiveCwd = effectiveBashCwd(command, cwd);
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. (The hook always runs via $CLAUDE_PROJECT_DIR, so this is where
235
- // out-of-scope work is let through deliberately instead of the old accidental 127.)
236
- if (isForeignGitRepo(effectiveCwd, workspaceRoot)) {
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, cwd, effectiveCwd, workspaceRoot);
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
- maybeRefreshMainSync(rules, workspaceRoot);
258
- const ctx = (0, build_context_1.buildBashContext)(command, workspaceRoot);
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)(workspaceRoot, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'ALLOW', 'no bash-guard block'));
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)(workspaceRoot, new decision_log_1.GuardDecision(ruleNames, 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'BLOCK', 'bash-guard block'));
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
  }