@webpieces/ai-hook-rules 0.4.614 → 0.4.616

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.614",
3
+ "version": "0.4.616",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.614"
28
+ "@webpieces/rules-config": "0.4.616"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"
@@ -33,18 +33,36 @@ import { CommandScanner } from './command-scan';
33
33
  *
34
34
  * Resolution, in order:
35
35
  * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.
36
- * 2. If that sits under the governed root, the tree is the governed root unless git says the
37
- * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`)foreign.
38
- * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —
39
- * it is the same project, just another checkoutso guards run, keyed on THAT tree's branch and
40
- * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel
41
- * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.
42
- * 4. Anything else that is a git repo foreign (out of scope, hands-off, as before).
43
- * 5. Not a git repo at all (`cd /tmp && …`) OUTSIDE. The guards still run an absolute path back
44
- * into the repo must still be judged — but nothing the command names relative to `/tmp` is
45
- * workspace content, which is what ContentReadScan uses `effectiveCwd` for.
36
+ * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so
37
+ * comparing it against the governed root's answer is the whole test. Different FOREIGN (a
38
+ * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all
39
+ * (`cd /tmp && …`) OUTSIDE: the guards still run an absolute path back into the repo must
40
+ * still be judged but nothing the command names relative to `/tmp` is workspace content, which
41
+ * is what ContentReadScan uses `effectiveCwd` for.
42
+ * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is
43
+ * MANAGED it is the same project, just another checkout so guards run, keyed on THAT tree's
44
+ * branch and its own state.
45
+ * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test. The governed root itself is
46
+ * always `primary`: it is home, whether the session was started in the clone or in a worktree.
47
+ *
48
+ * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()
49
+ * used to short-circuit on "is `effectiveCwd` inside the governed root?" and never ask git anything
50
+ * else — so an agent worktree, which Claude Code checks out INSIDE the repo at
51
+ * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as
52
+ * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and
53
+ * CoordinatorWorktreeGuard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees
54
+ * the harness creates. A common-dir comparison answers the same for both placements, so there is no
55
+ * inside/outside case left to get wrong.
56
+ *
57
+ * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument
58
+ * in full ("Why `--git-dir` / `--git-common-dir`, and not one of the existing services"); the short
59
+ * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open
60
+ * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it
61
+ * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),
62
+ * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,
63
+ * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.
46
64
  */
47
- export type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';
65
+ export type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';
48
66
  /** Data-only (per CLAUDE.md, classes for data). */
49
67
  export declare class EffectiveTree {
50
68
  /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */
@@ -62,7 +80,6 @@ export declare class EffectiveTree {
62
80
  }
63
81
  export declare class EffectiveTreeResolver {
64
82
  private readonly scanner;
65
- private readonly worktrees;
66
83
  private readonly shell;
67
84
  constructor(scanner?: CommandScanner);
68
85
  resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree;
@@ -111,8 +128,23 @@ export declare class EffectiveTreeResolver {
111
128
  * location still falls back to the shell cwd exactly as before.
112
129
  */
113
130
  misplacedCd(command: string): string | null;
131
+ /**
132
+ * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the
133
+ * command's OWN leading `cd` run REPLACED rather than prefixed.
134
+ *
135
+ * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and
136
+ * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`
137
+ * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on
138
+ * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally
139
+ * non-convergent, and observed in the field against an agent worktree.
140
+ *
141
+ * Only the LEADING run is dropped, because only the leading run moved where the command was judged.
142
+ * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by
143
+ * `misplacedCd`).
144
+ */
145
+ remedyAtRoot(root: string, command: string): string;
146
+ private withoutLeadingCds;
114
147
  private classify;
115
- private owningWorktree;
116
148
  /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
117
149
  private isInside;
118
150
  }
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.atRoot = exports.EffectiveTreeResolver = exports.EffectiveTree = void 0;
4
4
  const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
5
6
  const path = tslib_1.__importStar(require("path"));
6
- const child_process_1 = require("child_process");
7
7
  const rules_config_1 = require("@webpieces/rules-config");
8
8
  const command_scan_1 = require("./command-scan");
9
9
  const shell_segment_scan_1 = require("./rules/shell-segment-scan");
@@ -32,7 +32,6 @@ class EffectiveTree {
32
32
  exports.EffectiveTree = EffectiveTree;
33
33
  class EffectiveTreeResolver {
34
34
  scanner;
35
- worktrees = new rules_config_1.WorktreeService();
36
35
  shell;
37
36
  constructor(scanner = new command_scan_1.CommandScanner()) {
38
37
  this.scanner = scanner;
@@ -120,39 +119,68 @@ class EffectiveTreeResolver {
120
119
  }
121
120
  return null;
122
121
  }
122
+ /**
123
+ * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the
124
+ * command's OWN leading `cd` run REPLACED rather than prefixed.
125
+ *
126
+ * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and
127
+ * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`
128
+ * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on
129
+ * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally
130
+ * non-convergent, and observed in the field against an agent worktree.
131
+ *
132
+ * Only the LEADING run is dropped, because only the leading run moved where the command was judged.
133
+ * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by
134
+ * `misplacedCd`).
135
+ */
136
+ remedyAtRoot(root, command) {
137
+ return (0, rules_config_1.atRoot)(root, this.withoutLeadingCds(command));
138
+ }
139
+ withoutLeadingCds(command) {
140
+ let rest = command;
141
+ for (const segment of this.scanner.commandSegments(command)) {
142
+ const words = this.shell.effectiveWords(segment);
143
+ if (words[0] !== 'cd' && words[0] !== 'pushd')
144
+ break;
145
+ const at = rest.indexOf(segment);
146
+ if (at < 0)
147
+ break;
148
+ rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');
149
+ }
150
+ // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an
151
+ // empty remedy.
152
+ return rest.trim() === '' ? command : rest.trim();
153
+ }
123
154
  classify(effectiveCwd, governedRoot) {
124
- // Fast path no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and
125
- // the nested-clone check keeps its exact previous behaviour.
126
- if (this.isInside(effectiveCwd, governedRoot)) {
127
- const gitRoot = gitToplevel(effectiveCwd);
128
- if (gitRoot === null)
129
- return new TreeClassification('primary', governedRoot);
130
- if (path.resolve(gitRoot) === path.resolve(governedRoot))
131
- return new TreeClassification('primary', governedRoot);
132
- return new TreeClassification('foreign', gitRoot);
155
+ // GONE, not merely un-gitted. git answers `null` for both "not a repo" and "no such directory",
156
+ // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the
157
+ // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,
158
+ // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the
159
+ // only tree left to steer anyone to.
160
+ if (!fs.existsSync(effectiveCwd))
161
+ return new TreeClassification('missing', governedRoot);
162
+ const dirs = rules_config_1.dotWebpieces.gitDirs(effectiveCwd);
163
+ // Not a git repo at all. Inside the governed tree that can only be a directory git declined to
164
+ // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.
165
+ if (dirs === null) {
166
+ return this.isInside(effectiveCwd, governedRoot)
167
+ ? new TreeClassification('primary', governedRoot)
168
+ : new TreeClassification('outside', governedRoot);
133
169
  }
134
- // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a
135
- // worktree nested under another resolves to the innermost one.
136
- const owner = this.owningWorktree(effectiveCwd, governedRoot);
137
- if (owner !== null) {
138
- return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);
170
+ const treeRoot = rules_config_1.dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;
171
+ const ours = rules_config_1.dotWebpieces.gitDirs(governedRoot);
172
+ // ONE test for "is this our repo": the shared git dir. It is identical for every checkout of one
173
+ // repo and different for a nested clone, wherever either happens to sit on disk.
174
+ if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {
175
+ return new TreeClassification('foreign', treeRoot);
139
176
  }
140
- const gitRoot = gitToplevel(effectiveCwd);
141
- if (gitRoot === null)
142
- return new TreeClassification('outside', governedRoot);
143
- if (path.resolve(gitRoot) === path.resolve(governedRoot))
144
- return new TreeClassification('primary', governedRoot);
145
- return new TreeClassification('foreign', gitRoot);
146
- }
147
- owningWorktree(dir, governedRoot) {
148
- let best = null;
149
- for (const tree of this.worktrees.listWorktrees(governedRoot)) {
150
- if (!this.isInside(dir, tree.path))
151
- continue;
152
- if (best === null || tree.path.length > best.path.length)
153
- best = tree;
177
+ // Home is `primary` whether the session was started in the clone or in a worktree — the split
178
+ // CoordinatorWorktreeGuard exists to catch is standing in a tree OTHER than the one that governs
179
+ // you, and there is no split when they are the same directory.
180
+ if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {
181
+ return new TreeClassification('primary', treeRoot);
154
182
  }
155
- return best;
183
+ return new TreeClassification('worktree', treeRoot);
156
184
  }
157
185
  /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
158
186
  isInside(dir, root) {
@@ -178,6 +206,9 @@ const HEREDOC = /<<-?\s*['"]?[A-Za-z_]/;
178
206
  // `~` is here because path.resolve() does not expand it either — the shell does, and the hook never
179
207
  // sees a shell.
180
208
  const VARIABLE_TARGET = /[$`]|^~/;
209
+ // The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the
210
+ // `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.
211
+ const LEADING_SEPARATOR = /^\s*(?:&&|\|\||;|\n)\s*/;
181
212
  /** Data-only carrier for the two values classify() decides together. */
182
213
  class TreeClassification {
183
214
  kind;
@@ -195,14 +226,10 @@ class TreeClassification {
195
226
  */
196
227
  var rules_config_2 = require("@webpieces/rules-config");
197
228
  Object.defineProperty(exports, "atRoot", { enumerable: true, get: function () { return rules_config_2.atRoot; } });
198
- // The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`
199
- // IS the expected "not a repo" answer (spawnSync does not throw on a non-zero exit), so no try/catch.
200
- // webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers
201
- function gitToplevel(dir) {
202
- const r = (0, child_process_1.spawnSync)('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
203
- if (r.status !== 0)
204
- return null;
205
- const root = (r.stdout ?? '').trim();
206
- return root !== '' ? root : null;
229
+ // Two absolute paths naming the same directory. There is no filesystem access here both sides are
230
+ // already git's own answers or a resolved root.
231
+ // webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers
232
+ function sameDir(a, b) {
233
+ return path.resolve(a) === path.resolve(b);
207
234
  }
208
235
  //# sourceMappingURL=effective-tree.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { WorktreeService, Worktree } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - A `cd` that stays INSIDE the session's working directory PERSISTS to later calls. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. If that sits under the governed root, the tree is the governed root unless git says the\n * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`) → foreign.\n * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —\n * it is the same project, just another checkout — so guards run, keyed on THAT tree's branch and\n * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel\n * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.\n * 4. Anything else that is a git repo → foreign (out of scope, hands-off, as before).\n * 5. Not a git repo at all (`cd /tmp && …`) → OUTSIDE. The guards still run — an absolute path back\n * into the repo must still be judged — but nothing the command names relative to `/tmp` is\n * workspace content, which is what ContentReadScan uses `effectiveCwd` for.\n */\n// L1's K dimension. 'primary' and 'worktree' are the same PROJECT, so every rule-scoped guard treats\n// them alike — guards/L1-location.md writes them as one value, `pw`. Exactly ONE guard separates them,\n// and on a dimension that is not the tree at all: CoordinatorWorktreeGuard blocks the COORDINATOR\n// (never a subagent) from working inside a linked worktree, because the coordinator's governance is\n// anchored at session start and does not follow a `cd`.\n//\n// 'outside' is produced below (gitRoot === null) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly worktrees = new WorktreeService();\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n /**\n * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a\n * `cd` in it cannot be resolved, or null when there is nothing wrong.\n *\n * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it\n * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which\n * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:\n *\n * `cd \"$DIR\" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not\n * exist, not the tree that was meant.\n * `D=/x; cd \"$D\"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the\n * leading run ends before the `cd` is reached.\n * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it\n * from the shell cwd and blocked it.\n * `git push && cd /x` — the push already ran at the root, whatever the trailing\n * `cd` says.\n *\n * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a\n * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human\n * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes\n * — every command this rejects was already being judged from the shell cwd — so this trades a\n * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.\n *\n * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole\n * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.\n *\n * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,\n * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and\n * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the\n * location still falls back to the shell cwd exactly as before.\n */\n misplacedCd(command: string): string | null {\n if (HEREDOC.test(command)) return null;\n\n const segments = this.scanner.commandSegments(command).map(\n (segment: string): readonly string[] => this.shell.effectiveWords(segment));\n\n // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.\n let consumed = 0;\n while (consumed < segments.length && isCd(segments[consumed])) consumed++;\n\n for (let i = 0; i < segments.length; i++) {\n if (!isCd(segments[i])) continue;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? 'it comes after another command — a `cd` only counts at the FRONT of the line'\n : 'a `VAR=…` assignment precedes it, which ends the scan';\n }\n const target = segments[i][1];\n if (target !== undefined && VARIABLE_TARGET.test(target)) {\n return 'its target is not a literal path (a `$VAR`, `~` or `$(…)` the guard cannot expand)';\n }\n }\n return null;\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and\n // the nested-clone check keeps its exact previous behaviour.\n if (this.isInside(effectiveCwd, governedRoot)) {\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('primary', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a\n // worktree nested under another resolves to the innermost one.\n const owner = this.owningWorktree(effectiveCwd, governedRoot);\n if (owner !== null) {\n return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);\n }\n\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('outside', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n private owningWorktree(dir: string, governedRoot: string): Worktree | null {\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(governedRoot)) {\n if (!this.isInside(dir, tree.path)) continue;\n if (best === null || tree.path.length > best.path.length) best = tree;\n }\n return best;\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n// The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`\n// assignment (CommandScanner strips assignments as command prefixes), which is neither.\n// webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below\nfunction isCd(words: readonly string[]): boolean {\n return words[0] === 'cd' || words[0] === 'pushd';\n}\n\n// webpieces-disable no-function-outside-class -- sibling of isCd()\nfunction isRealCommand(words: readonly string[]): boolean {\n return words.length > 0 && !isCd(words);\n}\n\n// `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it\n// cannot carry prose that tokenizes as commands.\nconst HEREDOC = /<<-?\\s*['\"]?[A-Za-z_]/;\n\n// A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.\n// `~` is here because path.resolve() does not expand it either — the shell does, and the hook never\n// sees a shell.\nconst VARIABLE_TARGET = /[$`]|^~/;\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`\n// IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit), so no try/catch.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers\nfunction gitToplevel(dir: string): string | null {\n const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n if (r.status !== 0) return null;\n const root = (r.stdout ?? '').trim();\n return root !== '' ? root : null;\n}\n"]}
1
+ {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { atRoot, dotWebpieces } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree\n * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:\n *\n * - A `cd` that stays INSIDE the session's working directory PERSISTS to later calls. So the cwd\n * can be a subdirectory of the governed root, left there by an unrelated command several turns\n * earlier — a relative path then resolves somewhere other than the root while still being in the\n * governed tree.\n * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).\n * So an agent working in a linked worktree is back in the primary clone by the next call and must\n * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary\n * clone, not the worktree the command targets.\n *\n * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →\n * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at\n * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the\n * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)\n *\n * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in\n * one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so\n * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a\n * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all\n * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must\n * still be judged — but nothing the command names relative to `/tmp` is workspace content, which\n * is what ContentReadScan uses `effectiveCwd` for.\n * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is\n * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's\n * branch and its own state.\n * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test. The governed root itself is\n * always `primary`: it is home, whether the session was started in the clone or in a worktree.\n *\n * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()\n * used to short-circuit on \"is `effectiveCwd` inside the governed root?\" and never ask git anything\n * else — so an agent worktree, which Claude Code checks out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as\n * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and\n * CoordinatorWorktreeGuard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees\n * the harness creates. A common-dir comparison answers the same for both placements, so there is no\n * inside/outside case left to get wrong.\n *\n * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument\n * in full (\"Why `--git-dir` / `--git-common-dir`, and not one of the existing services\"); the short\n * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open\n * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it\n * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),\n * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,\n * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.\n */\n// L1's K dimension. 'primary' and 'worktree' are the same PROJECT, so every rule-scoped guard treats\n// them alike — guards/L1-location.md writes them as one value, `pw`. Exactly ONE guard separates them,\n// and on a dimension that is not the tree at all: CoordinatorWorktreeGuard blocks the COORDINATOR\n// (never a subagent) from working inside a linked worktree, because the coordinator's governance is\n// anchored at session start and does not follow a `cd`.\n//\n// 'outside' is produced below (git has no answer for the directory) and consumed NOWHERE, so a command in no git repo is\n// judged against governedRoot — a repo it is not in. guards/L1-location.md's \"Not done\" section explains why\n// exempting it must ship together with target-based jurisdiction, never alone.\n//\n// 'missing' is the directory that is NOT THERE — the worktree reaped out from under a live shell. It is\n// separate from 'outside' because the two used to be one `null` from git, and conflating them produced\n// the worst message this layer has emitted: \"you are in a subdirectory\", with a remedy that `cd`s back\n// into the deleted path. See MissingDirectoryGuard.\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n /**\n * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a\n * `cd` in it cannot be resolved, or null when there is nothing wrong.\n *\n * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it\n * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which\n * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:\n *\n * `cd \"$DIR\" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not\n * exist, not the tree that was meant.\n * `D=/x; cd \"$D\"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the\n * leading run ends before the `cd` is reached.\n * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it\n * from the shell cwd and blocked it.\n * `git push && cd /x` — the push already ran at the root, whatever the trailing\n * `cd` says.\n *\n * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a\n * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human\n * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes\n * — every command this rejects was already being judged from the shell cwd — so this trades a\n * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.\n *\n * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole\n * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.\n *\n * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,\n * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and\n * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the\n * location still falls back to the shell cwd exactly as before.\n */\n misplacedCd(command: string): string | null {\n if (HEREDOC.test(command)) return null;\n\n const segments = this.scanner.commandSegments(command).map(\n (segment: string): readonly string[] => this.shell.effectiveWords(segment));\n\n // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.\n let consumed = 0;\n while (consumed < segments.length && isCd(segments[consumed])) consumed++;\n\n for (let i = 0; i < segments.length; i++) {\n if (!isCd(segments[i])) continue;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? 'it comes after another command — a `cd` only counts at the FRONT of the line'\n : 'a `VAR=…` assignment precedes it, which ends the scan';\n }\n const target = segments[i][1];\n if (target !== undefined && VARIABLE_TARGET.test(target)) {\n return 'its target is not a literal path (a `$VAR`, `~` or `$(…)` the guard cannot expand)';\n }\n }\n return null;\n }\n\n /**\n * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the\n * command's OWN leading `cd` run REPLACED rather than prefixed.\n *\n * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and\n * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`\n * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on\n * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally\n * non-convergent, and observed in the field against an agent worktree.\n *\n * Only the LEADING run is dropped, because only the leading run moved where the command was judged.\n * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by\n * `misplacedCd`).\n */\n remedyAtRoot(root: string, command: string): string {\n return atRoot(root, this.withoutLeadingCds(command));\n }\n\n private withoutLeadingCds(command: string): string {\n let rest = command;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n const at = rest.indexOf(segment);\n if (at < 0) break;\n rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');\n }\n // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an\n // empty remedy.\n return rest.trim() === '' ? command : rest.trim();\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // GONE, not merely un-gitted. git answers `null` for both \"not a repo\" and \"no such directory\",\n // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the\n // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,\n // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the\n // only tree left to steer anyone to.\n if (!fs.existsSync(effectiveCwd)) return new TreeClassification('missing', governedRoot);\n\n const dirs = dotWebpieces.gitDirs(effectiveCwd);\n // Not a git repo at all. Inside the governed tree that can only be a directory git declined to\n // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.\n if (dirs === null) {\n return this.isInside(effectiveCwd, governedRoot)\n ? new TreeClassification('primary', governedRoot)\n : new TreeClassification('outside', governedRoot);\n }\n\n const treeRoot = dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;\n const ours = dotWebpieces.gitDirs(governedRoot);\n // ONE test for \"is this our repo\": the shared git dir. It is identical for every checkout of one\n // repo and different for a nested clone, wherever either happens to sit on disk.\n if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {\n return new TreeClassification('foreign', treeRoot);\n }\n\n // Home is `primary` whether the session was started in the clone or in a worktree — the split\n // CoordinatorWorktreeGuard exists to catch is standing in a tree OTHER than the one that governs\n // you, and there is no split when they are the same directory.\n if (!dirs.isLinkedWorktree || sameDir(treeRoot, governedRoot)) {\n return new TreeClassification('primary', treeRoot);\n }\n return new TreeClassification('worktree', treeRoot);\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n// The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`\n// assignment (CommandScanner strips assignments as command prefixes), which is neither.\n// webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below\nfunction isCd(words: readonly string[]): boolean {\n return words[0] === 'cd' || words[0] === 'pushd';\n}\n\n// webpieces-disable no-function-outside-class -- sibling of isCd()\nfunction isRealCommand(words: readonly string[]): boolean {\n return words.length > 0 && !isCd(words);\n}\n\n// `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it\n// cannot carry prose that tokenizes as commands.\nconst HEREDOC = /<<-?\\s*['\"]?[A-Za-z_]/;\n\n// A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.\n// `~` is here because path.resolve() does not expand it either — the shell does, and the hook never\n// sees a shell.\nconst VARIABLE_TARGET = /[$`]|^~/;\n\n// The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the\n// `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.\nconst LEADING_SEPARATOR = /^\\s*(?:&&|\\|\\||;|\\n)\\s*/;\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/**\n * The steering prefix every remedy needs, re-exported from @webpieces/rules-config so the guards, the\n * message builders and pr-gate's worktree notices all emit the IDENTICAL string — including the single\n * quotes that keep it runnable when the repo path contains a space. See atRoot's own header for why the\n * quotes are single and never double.\n */\nexport { atRoot } from '@webpieces/rules-config';\n\n// Two absolute paths naming the same directory. There is no filesystem access here — both sides are\n// already git's own answers or a resolved root.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers\nfunction sameDir(a: string, b: string): boolean {\n return path.resolve(a) === path.resolve(b);\n}\n"]}
@@ -0,0 +1,32 @@
1
+ import { EffectiveTree } from './effective-tree';
2
+ import { BlockedResult } from './types';
3
+ /**
4
+ * L1 row 5: git/gh commands must run from the repo root of the tree they act on, where the guards can
5
+ * reason about git state coherently. guards/L1-location.md carries the table and the use cases; change
6
+ * this predicate and that file is stale until you update it.
7
+ *
8
+ * ONE variable decides it: `tree.effectiveCwd` — the directory the command actually runs in, which is
9
+ * the shell's cwd unless the command leads with `cd <dir> &&`. Root or not-root, nothing else.
10
+ *
11
+ * It used to be `shellAtRoot || cdsToRoot`, two variables OR'd, and that produced opposite verdicts for
12
+ * the same destination: `git status` with the shell in packages/http/ was BLOCKED, while
13
+ * `cd packages/http && git status` from the root was ALLOWED, because shellAtRoot short-circuited before
14
+ * the destination was ever considered. An agent that cd's INTO a subdir has the same broken mental model
15
+ * as one stranded there, so it gets the same answer now.
16
+ *
17
+ * The remedy is ONE runnable line, `cd <root> && <the work>`, not "cd first, then re-run" — that advice
18
+ * made this guard print the very command it had just rejected, and a `cd` is unreliable in both
19
+ * directions (INTO this repo it sticks; OUT of it the harness resets it).
20
+ *
21
+ * It goes through remedyAtRoot(), NOT bare atRoot(), and that is load-bearing: A REMEDY MUST SATISFY THE
22
+ * PREDICATE THAT PRINTED IT. Prefixing `cd '<root>' &&` onto a command already leading with
23
+ * `cd <elsewhere> &&` left effectiveCwd in `<elsewhere>`, so the identical block fired on the remedy with
24
+ * the prefix doubled, then tripled. remedyAtRoot REPLACES the leading `cd` run instead, and
25
+ * effective-tree.spec.ts asserts that property for every L1 remedy, not just this one.
26
+ */
27
+ export declare class ForceToRootGuard {
28
+ private readonly resolver;
29
+ /** The deny report, or null to allow. */
30
+ block(command: string, tree: EffectiveTree, isGitOrGh: boolean): string | null;
31
+ }
32
+ export declare function gitFromSubdirBlock(command: string, tree: EffectiveTree, isGitOrGh: boolean): BlockedResult | null;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ForceToRootGuard = void 0;
4
+ exports.gitFromSubdirBlock = gitFromSubdirBlock;
5
+ const tslib_1 = require("tslib");
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const effective_tree_1 = require("./effective-tree");
8
+ const types_1 = require("./types");
9
+ /**
10
+ * L1 row 5: git/gh commands must run from the repo root of the tree they act on, where the guards can
11
+ * reason about git state coherently. guards/L1-location.md carries the table and the use cases; change
12
+ * this predicate and that file is stale until you update it.
13
+ *
14
+ * ONE variable decides it: `tree.effectiveCwd` — the directory the command actually runs in, which is
15
+ * the shell's cwd unless the command leads with `cd <dir> &&`. Root or not-root, nothing else.
16
+ *
17
+ * It used to be `shellAtRoot || cdsToRoot`, two variables OR'd, and that produced opposite verdicts for
18
+ * the same destination: `git status` with the shell in packages/http/ was BLOCKED, while
19
+ * `cd packages/http && git status` from the root was ALLOWED, because shellAtRoot short-circuited before
20
+ * the destination was ever considered. An agent that cd's INTO a subdir has the same broken mental model
21
+ * as one stranded there, so it gets the same answer now.
22
+ *
23
+ * The remedy is ONE runnable line, `cd <root> && <the work>`, not "cd first, then re-run" — that advice
24
+ * made this guard print the very command it had just rejected, and a `cd` is unreliable in both
25
+ * directions (INTO this repo it sticks; OUT of it the harness resets it).
26
+ *
27
+ * It goes through remedyAtRoot(), NOT bare atRoot(), and that is load-bearing: A REMEDY MUST SATISFY THE
28
+ * PREDICATE THAT PRINTED IT. Prefixing `cd '<root>' &&` onto a command already leading with
29
+ * `cd <elsewhere> &&` left effectiveCwd in `<elsewhere>`, so the identical block fired on the remedy with
30
+ * the prefix doubled, then tripled. remedyAtRoot REPLACES the leading `cd` run instead, and
31
+ * effective-tree.spec.ts asserts that property for every L1 remedy, not just this one.
32
+ */
33
+ class ForceToRootGuard {
34
+ resolver = new effective_tree_1.EffectiveTreeResolver();
35
+ /** The deny report, or null to allow. */
36
+ block(command, tree, isGitOrGh) {
37
+ const targetAtRoot = path.resolve(tree.effectiveCwd) === path.resolve(tree.root);
38
+ if (!isGitOrGh || targetAtRoot)
39
+ return null;
40
+ return [
41
+ '❌ Run git/gh commands from the repo root, not a subdirectory.',
42
+ ` Command runs in: ${tree.effectiveCwd}`,
43
+ ` Judged against: ${tree.root}`,
44
+ ' Run EXACTLY this instead, as ONE line (a bare `cd` in a separate call is not equivalent —',
45
+ ' a `cd` inside this repo STICKS for later calls, and a `cd` out of it is reset by the harness):',
46
+ ` ${this.resolver.remedyAtRoot(tree.root, command)}`,
47
+ ' A leading `cd <path> &&` is ACCEPTED by the guards — it cannot change what the command',
48
+ " does to the repo. (The webpieces guards evaluate the repo's git state at its root.)",
49
+ ].join('\n');
50
+ }
51
+ }
52
+ exports.ForceToRootGuard = ForceToRootGuard;
53
+ // L1 row 5's dispatch entry, beside the guard it wraps — see missing-directory.ts for why these live
54
+ // with their guard rather than in runner.ts.
55
+ // webpieces-disable no-function-outside-class -- the one-line runner entry point for the class above, beside it
56
+ function gitFromSubdirBlock(command, tree, isGitOrGh) {
57
+ const report = new ForceToRootGuard().block(command, tree, isGitOrGh);
58
+ return report === null ? null : new types_1.BlockedResult(report);
59
+ }
60
+ //# sourceMappingURL=force-to-root.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"force-to-root.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/force-to-root.ts"],"names":[],"mappings":";;;AAoDA,gDAGC;;AAvDD,mDAA6B;AAE7B,qDAAwE;AACxE,mCAAwC;AAExC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAa,gBAAgB;IACR,QAAQ,GAAG,IAAI,sCAAqB,EAAE,CAAC;IAExD,yCAAyC;IACzC,KAAK,CAAC,OAAe,EAAE,IAAmB,EAAE,SAAkB;QAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjF,IAAI,CAAC,SAAS,IAAI,YAAY;YAAE,OAAO,IAAI,CAAC;QAC5C,OAAO;YACH,+DAA+D;YAC/D,uBAAuB,IAAI,CAAC,YAAY,EAAE;YAC1C,sBAAsB,IAAI,CAAC,IAAI,EAAE;YACjC,8FAA8F;YAC9F,mGAAmG;YACnG,QAAQ,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE;YACxD,2FAA2F;YAC3F,wFAAwF;SAC3F,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;CACJ;AAlBD,4CAkBC;AAED,qGAAqG;AACrG,6CAA6C;AAC7C,gHAAgH;AAChH,SAAgB,kBAAkB,CAAC,OAAe,EAAE,IAAmB,EAAE,SAAkB;IACvF,MAAM,MAAM,GAAG,IAAI,gBAAgB,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;IACtE,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,qBAAa,CAAC,MAAM,CAAC,CAAC;AAC9D,CAAC","sourcesContent":["import * as path from 'path';\n\nimport { EffectiveTree, EffectiveTreeResolver } from './effective-tree';\nimport { BlockedResult } from './types';\n\n/**\n * L1 row 5: git/gh commands must run from the repo root of the tree they act on, where the guards can\n * reason about git state coherently. guards/L1-location.md carries the table and the use cases; change\n * this predicate and that file is stale until you update it.\n *\n * ONE variable decides it: `tree.effectiveCwd` — the directory the command actually runs in, which is\n * the shell's cwd unless the command leads with `cd <dir> &&`. Root or not-root, nothing else.\n *\n * It used to be `shellAtRoot || cdsToRoot`, two variables OR'd, and that produced opposite verdicts for\n * the same destination: `git status` with the shell in packages/http/ was BLOCKED, while\n * `cd packages/http && git status` from the root was ALLOWED, because shellAtRoot short-circuited before\n * the destination was ever considered. An agent that cd's INTO a subdir has the same broken mental model\n * as one stranded there, so it gets the same answer now.\n *\n * The remedy is ONE runnable line, `cd <root> && <the work>`, not \"cd first, then re-run\" — that advice\n * made this guard print the very command it had just rejected, and a `cd` is unreliable in both\n * directions (INTO this repo it sticks; OUT of it the harness resets it).\n *\n * It goes through remedyAtRoot(), NOT bare atRoot(), and that is load-bearing: A REMEDY MUST SATISFY THE\n * PREDICATE THAT PRINTED IT. Prefixing `cd '<root>' &&` onto a command already leading with\n * `cd <elsewhere> &&` left effectiveCwd in `<elsewhere>`, so the identical block fired on the remedy with\n * the prefix doubled, then tripled. remedyAtRoot REPLACES the leading `cd` run instead, and\n * effective-tree.spec.ts asserts that property for every L1 remedy, not just this one.\n */\nexport class ForceToRootGuard {\n private readonly resolver = new EffectiveTreeResolver();\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree, isGitOrGh: boolean): string | null {\n const targetAtRoot = path.resolve(tree.effectiveCwd) === path.resolve(tree.root);\n if (!isGitOrGh || targetAtRoot) return null;\n return [\n '❌ Run git/gh commands from the repo root, not a subdirectory.',\n ` Command runs in: ${tree.effectiveCwd}`,\n ` Judged against: ${tree.root}`,\n ' Run EXACTLY this instead, as ONE line (a bare `cd` in a separate call is not equivalent —',\n ' a `cd` inside this repo STICKS for later calls, and a `cd` out of it is reset by the harness):',\n ` ${this.resolver.remedyAtRoot(tree.root, command)}`,\n ' A leading `cd <path> &&` is ACCEPTED by the guards — it cannot change what the command',\n \" does to the repo. (The webpieces guards evaluate the repo's git state at its root.)\",\n ].join('\\n');\n }\n}\n\n// L1 row 5's dispatch entry, beside the guard it wraps — see missing-directory.ts for why these live\n// with their guard rather than in runner.ts.\n// webpieces-disable no-function-outside-class -- the one-line runner entry point for the class above, beside it\nexport function gitFromSubdirBlock(command: string, tree: EffectiveTree, isGitOrGh: boolean): BlockedResult | null {\n const report = new ForceToRootGuard().block(command, tree, isGitOrGh);\n return report === null ? null : new BlockedResult(report);\n}\n"]}