@webpieces/ai-hook-rules 0.4.625 → 0.4.626

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.625",
3
+ "version": "0.4.626",
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.625"
28
+ "@webpieces/rules-config": "0.4.626"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"
@@ -45,8 +45,27 @@ import { CommandScanner } from './command-scan';
45
45
  * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is
46
46
  * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's
47
47
  * branch and its own state.
48
- * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test. The governed root itself is
49
- * always `primary`: it is home, whether the session was started in the clone or in a worktree.
48
+ * 4. PRIMARY OR LINKED? `gitDir !== commonDir`, git's own canonical test AND NOTHING ELSE. Where
49
+ * `webpieces.config.json` happened to be found does not enter into it (see below).
50
+ * 5. WHICH TREE GOVERNS THE INSTALL? `<git-common-dir>/..` — the PRIMARY clone, carried as `mainRoot`.
51
+ * It is the tree whose `node_modules` supplies the binary judging the call, from any checkout.
52
+ *
53
+ * GOVERNANCE IS NOT IDENTITY EITHER — the second half of the same lesson, and the second bug. classify()
54
+ * used to answer `primary` for ANY tree that also owned the `webpieces.config.json` it was judged
55
+ * against (`sameDir(treeRoot, governedRoot)`). `governedRoot` is walked UP from the payload cwd, and a
56
+ * linked worktree has its own TRACKED config, so for an agent whose cwd IS the worktree — the common
57
+ * case, and the only one the harness creates for a worktree-isolated subagent — its own tree read as
58
+ * `primary`. Row 8 (VersionSyncGuard) matches on `w`, so it could not fire for exactly the agents it
59
+ * exists to protect: measured 2026-08-10, a worktree bumped its pin to 0.4.624 and ran its own
60
+ * `pnpm install` while the main clone stayed on 0.4.616, and nothing said a word. In the SAME tool call
61
+ * the `.webpieces/` log resolver — which asks git — correctly stamped `tree=agent-abfdc0aaf1f981f3f`.
62
+ * Two resolvers in one process disagreeing at the same instant is the shape this module exists to make
63
+ * impossible, so K is now git's answer and ONLY git's answer.
64
+ *
65
+ * That leaves `governedRoot` meaning what its name says (whose config and excludePaths apply) and adds
66
+ * `mainRoot` for the question VersionSyncGuard actually asks (whose `node_modules` is judging this
67
+ * call). Those were never the same value, and conflating them made the guard compare a worktree
68
+ * against ITSELF, which is trivially in sync.
50
69
  *
51
70
  * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()
52
71
  * used to short-circuit on "is `effectiveCwd` inside the governed root?" and never ask git anything
@@ -76,10 +95,20 @@ export declare class EffectiveTree {
76
95
  readonly root: string;
77
96
  /** The root that owns webpieces.config.json — where config and excludePaths come from. */
78
97
  readonly governedRoot: string;
98
+ /**
99
+ * The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the
100
+ * repo, and equal to `root` in the primary clone itself.
101
+ *
102
+ * NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent
103
+ * resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the
104
+ * binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to
105
+ * the primary clone. This field is that walk-up, asked of git instead of inferred.
106
+ */
107
+ readonly mainRoot: string;
79
108
  readonly kind: TreeKind;
80
109
  /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
81
110
  readonly redirected: boolean;
82
- constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind);
111
+ constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, mainRoot: string, kind: TreeKind);
83
112
  }
84
113
  export declare class EffectiveTreeResolver {
85
114
  private readonly scanner;
@@ -17,14 +17,26 @@ class EffectiveTree {
17
17
  root;
18
18
  /** The root that owns webpieces.config.json — where config and excludePaths come from. */
19
19
  governedRoot;
20
+ /**
21
+ * The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the
22
+ * repo, and equal to `root` in the primary clone itself.
23
+ *
24
+ * NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent
25
+ * resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the
26
+ * binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to
27
+ * the primary clone. This field is that walk-up, asked of git instead of inferred.
28
+ */
29
+ mainRoot;
20
30
  kind;
21
31
  /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
22
32
  redirected;
23
- constructor(shellCwd, effectiveCwd, root, governedRoot, kind) {
33
+ // eslint-disable-next-line @typescript-eslint/max-params -- five resolved paths plus the kind decided from them
34
+ constructor(shellCwd, effectiveCwd, root, governedRoot, mainRoot, kind) {
24
35
  this.shellCwd = shellCwd;
25
36
  this.effectiveCwd = effectiveCwd;
26
37
  this.root = root;
27
38
  this.governedRoot = governedRoot;
39
+ this.mainRoot = mainRoot;
28
40
  this.kind = kind;
29
41
  this.redirected = path.resolve(root) !== path.resolve(shellCwd);
30
42
  }
@@ -40,7 +52,7 @@ class EffectiveTreeResolver {
40
52
  resolve(command, shellCwd, governedRoot) {
41
53
  const effectiveCwd = this.effectiveCwd(command, shellCwd);
42
54
  const kindAndRoot = this.classify(effectiveCwd, governedRoot);
43
- return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);
55
+ return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.mainRoot, kindAndRoot.kind);
44
56
  }
45
57
  /**
46
58
  * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
@@ -158,29 +170,33 @@ class EffectiveTreeResolver {
158
170
  // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the
159
171
  // only tree left to steer anyone to.
160
172
  if (!fs.existsSync(effectiveCwd))
161
- return new TreeClassification('missing', governedRoot);
173
+ return new TreeClassification('missing', governedRoot, governedRoot);
162
174
  const dirs = rules_config_1.dotWebpieces.gitDirs(effectiveCwd);
163
175
  // Not a git repo at all. Inside the governed tree that can only be a directory git declined to
164
176
  // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.
165
177
  if (dirs === null) {
166
178
  return this.isInside(effectiveCwd, governedRoot)
167
- ? new TreeClassification('primary', governedRoot)
168
- : new TreeClassification('outside', governedRoot);
179
+ ? new TreeClassification('primary', governedRoot, governedRoot)
180
+ : new TreeClassification('outside', governedRoot, governedRoot);
169
181
  }
170
182
  const treeRoot = rules_config_1.dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;
183
+ // `<git-common-dir>/..`, off the SAME memoized rev-parse pair `gitDirs` just answered with — so
184
+ // this costs no extra process, which matters on the hook's blocking path.
185
+ const mainRoot = rules_config_1.dotWebpieces.primaryRoot(effectiveCwd);
171
186
  const ours = rules_config_1.dotWebpieces.gitDirs(governedRoot);
172
187
  // ONE test for "is this our repo": the shared git dir. It is identical for every checkout of one
173
188
  // repo and different for a nested clone, wherever either happens to sit on disk.
174
189
  if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {
175
- return new TreeClassification('foreign', treeRoot);
176
- }
177
- // Home is `primary` whether the session was started in the clone or in a worktree — the split
178
- // VersionSyncGuard exists to catch is acting on 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);
190
+ return new TreeClassification('foreign', treeRoot, mainRoot);
182
191
  }
183
- return new TreeClassification('worktree', treeRoot);
192
+ // GIT DECIDES, and nothing else. `isLinkedWorktree` is `--git-dir !== --git-common-dir`, an
193
+ // answer about the CHECKOUT — so a worktree is `worktree` whether the command was typed from the
194
+ // primary clone (`cd <wt> && …`) or by an agent living in it. The old extra clause
195
+ // (`|| sameDir(treeRoot, governedRoot)`) made a resident agent's OWN tree read `primary`,
196
+ // silently retiring row 8 for every worktree-isolated subagent — see this module's header.
197
+ if (!dirs.isLinkedWorktree)
198
+ return new TreeClassification('primary', treeRoot, mainRoot);
199
+ return new TreeClassification('worktree', treeRoot, mainRoot);
184
200
  }
185
201
  /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
186
202
  isInside(dir, root) {
@@ -209,13 +225,16 @@ const VARIABLE_TARGET = /[$`]|^~/;
209
225
  // The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the
210
226
  // `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.
211
227
  const LEADING_SEPARATOR = /^\s*(?:&&|\|\||;|\n)\s*/;
212
- /** Data-only carrier for the two values classify() decides together. */
228
+ /** Data-only carrier for the three values classify() decides together. */
213
229
  class TreeClassification {
214
230
  kind;
215
231
  root;
216
- constructor(kind, root) {
232
+ /** The primary clone behind `root` — see EffectiveTree.mainRoot for why it is not `governedRoot`. */
233
+ mainRoot;
234
+ constructor(kind, root, mainRoot) {
217
235
  this.kind = kind;
218
236
  this.root = root;
237
+ this.mainRoot = mainRoot;
219
238
  }
220
239
  }
221
240
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAE7B,0DAA+D;AAE/D,iDAAgD;AAChD,mEAA8D;AAmF9D,mDAAmD;AACnD,MAAa,aAAa;IACtB,kGAAkG;IACzF,QAAQ,CAAS;IAC1B,wFAAwF;IAC/E,YAAY,CAAS;IAC9B,qGAAqG;IAC5F,IAAI,CAAS;IACtB,0FAA0F;IACjF,YAAY,CAAS;IACrB,IAAI,CAAW;IACxB,uGAAuG;IAC9F,UAAU,CAAU;IAE7B,YAAY,QAAgB,EAAE,YAAoB,EAAE,IAAY,EAAE,YAAoB,EAAE,IAAc;QAClG,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpE,CAAC;CACJ;AArBD,sCAqBC;AAED,MAAa,qBAAqB;IAGD;IAFZ,KAAK,CAAmB;IAEzC,YAA6B,UAA0B,IAAI,6BAAc,EAAE;QAA9C,YAAO,GAAP,OAAO,CAAuC;QACvE,IAAI,CAAC,KAAK,GAAG,IAAI,qCAAgB,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC;IAED,OAAO,CAAC,OAAe,EAAE,QAAgB,EAAE,YAAoB;QAC3D,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC;QAC9D,OAAO,IAAI,aAAa,CAAC,QAAQ,EAAE,YAAY,EAAE,WAAW,CAAC,IAAI,EAAE,YAAY,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACvG,CAAC;IAED;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,OAAe,EAAE,QAAgB;QAC1C,IAAI,SAAS,GAAG,QAAQ,CAAC;QACzB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;YACjD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;gBAAE,MAAM;YACrD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS;gBAAE,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9E,CAAC;QACD,OAAO,SAAS,CAAC;IACrB,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,WAAW,CAAC,OAAe;QACvB,IAAI,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAEvC,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,GAAG,CACtD,CAAC,OAAe,EAAqB,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC;QAEhF,kGAAkG;QAClG,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,OAAO,QAAQ,GAAG,QAAQ,CAAC,MAAM,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAAE,QAAQ,EAAE,CAAC;QAE1E,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;gBAAE,SAAS;YACjC,IAAI,CAAC,IAAI,QAAQ,EAAE,CAAC;gBAChB,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC;oBAC3C,CAAC,CAAC,8EAA8E;oBAChF,CAAC,CAAC,uDAAuD,CAAC;YAClE,CAAC;YACD,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9B,IAAI,MAAM,KAAK,SAAS,IAAI,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;gBACvD,OAAO,oFAAoF,CAAC;YAChG,CAAC;QACL,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,IAAY,EAAE,OAAe;QACtC,OAAO,IAAA,qBAAM,EAAC,IAAI,EAAE,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,CAAC;IACzD,CAAC;IAEO,iBAAiB,CAAC,OAAe;QACrC,IAAI,IAAI,GAAG,OAAO,CAAC;QACnB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;YACjD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;gBAAE,MAAM;YACrD,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YACjC,IAAI,EAAE,GAAG,CAAC;gBAAE,MAAM;YAClB,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC,CAAC;QAC1E,CAAC;QACD,gGAAgG;QAChG,gBAAgB;QAChB,OAAO,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;IACtD,CAAC;IAEO,QAAQ,CAAC,YAAoB,EAAE,YAAoB;QACvD,gGAAgG;QAChG,gGAAgG;QAChG,8FAA8F;QAC9F,8FAA8F;QAC9F,qCAAqC;QACrC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC;YAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QAEzF,MAAM,IAAI,GAAG,2BAAY,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QAChD,+FAA+F;QAC/F,8FAA8F;QAC9F,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC;gBAC5C,CAAC,CAAC,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC;gBACjD,CAAC,CAAC,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QAC1D,CAAC;QAED,MAAM,QAAQ,GAAG,2BAAY,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,YAAY,CAAC;QACrE,MAAM,IAAI,GAAG,2BAAY,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QAChD,iGAAiG;QACjG,iFAAiF;QACjF,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAC5D,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QACvD,CAAC;QAED,8FAA8F;QAC9F,uFAAuF;QACvF,+DAA+D;QAC/D,IAAI,CAAC,IAAI,CAAC,gBAAgB,IAAI,OAAO,CAAC,QAAQ,EAAE,YAAY,CAAC,EAAE,CAAC;YAC5D,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QACvD,CAAC;QACD,OAAO,IAAI,kBAAkB,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IACxD,CAAC;IAED,oGAAoG;IAC5F,QAAQ,CAAC,GAAW,EAAE,IAAY;QACtC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QACtE,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;IACzF,CAAC;CACJ;AAlKD,sDAkKC;AAED,uGAAuG;AACvG,wFAAwF;AACxF,sIAAsI;AACtI,SAAS,IAAI,CAAC,KAAwB;IAClC,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC;AACrD,CAAC;AAED,mEAAmE;AACnE,SAAS,aAAa,CAAC,KAAwB;IAC3C,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC5C,CAAC;AAED,sGAAsG;AACtG,iDAAiD;AACjD,MAAM,OAAO,GAAG,uBAAuB,CAAC;AAExC,sGAAsG;AACtG,oGAAoG;AACpG,gBAAgB;AAChB,MAAM,eAAe,GAAG,SAAS,CAAC;AAElC,uGAAuG;AACvG,kGAAkG;AAClG,MAAM,iBAAiB,GAAG,yBAAyB,CAAC;AAEpD,wEAAwE;AACxE,MAAM,kBAAkB;IACX,IAAI,CAAW;IACf,IAAI,CAAS;IAEtB,YAAY,IAAc,EAAE,IAAY;QACpC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ;AAED;;;;;GAKG;AACH,wDAAiD;AAAxC,sGAAA,MAAM,OAAA;AAEf,oGAAoG;AACpG,gDAAgD;AAChD,wHAAwH;AACxH,SAAS,OAAO,CAAC,CAAS,EAAE,CAAS;IACjC,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC","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 * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to\n * later calls. Claude Code documents this as a main-session property and states that \"subagent\n * sessions never carry over working directory changes\" — measured true here, and the reason this\n * rule must not be stated unconditionally to a reader who may be a subagent. 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 * the worktree guard (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 read OFF the tree itself: VersionSyncGuard blocks work in a linked worktree whose\n// @webpieces pin disagrees with the MAIN tree's, because the main tree's binary is what judges it.\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 // VersionSyncGuard exists to catch is acting on 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"]}
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 * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to\n * later calls. Claude Code documents this as a main-session property and states that \"subagent\n * sessions never carry over working directory changes\" — measured true here, and the reason this\n * rule must not be stated unconditionally to a reader who may be a subagent. 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 — AND NOTHING ELSE. Where\n * `webpieces.config.json` happened to be found does not enter into it (see below).\n * 5. WHICH TREE GOVERNS THE INSTALL? `<git-common-dir>/..` — the PRIMARY clone, carried as `mainRoot`.\n * It is the tree whose `node_modules` supplies the binary judging the call, from any checkout.\n *\n * GOVERNANCE IS NOT IDENTITY EITHER — the second half of the same lesson, and the second bug. classify()\n * used to answer `primary` for ANY tree that also owned the `webpieces.config.json` it was judged\n * against (`sameDir(treeRoot, governedRoot)`). `governedRoot` is walked UP from the payload cwd, and a\n * linked worktree has its own TRACKED config, so for an agent whose cwd IS the worktree — the common\n * case, and the only one the harness creates for a worktree-isolated subagent — its own tree read as\n * `primary`. Row 8 (VersionSyncGuard) matches on `w`, so it could not fire for exactly the agents it\n * exists to protect: measured 2026-08-10, a worktree bumped its pin to 0.4.624 and ran its own\n * `pnpm install` while the main clone stayed on 0.4.616, and nothing said a word. In the SAME tool call\n * the `.webpieces/` log resolver — which asks git — correctly stamped `tree=agent-abfdc0aaf1f981f3f`.\n * Two resolvers in one process disagreeing at the same instant is the shape this module exists to make\n * impossible, so K is now git's answer and ONLY git's answer.\n *\n * That leaves `governedRoot` meaning what its name says (whose config and excludePaths apply) and adds\n * `mainRoot` for the question VersionSyncGuard actually asks (whose `node_modules` is judging this\n * call). Those were never the same value, and conflating them made the guard compare a worktree\n * against ITSELF, which is trivially in sync.\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 * the worktree guard (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 read OFF the tree itself: VersionSyncGuard blocks work in a linked worktree whose\n// @webpieces pin disagrees with the MAIN tree's, because the main tree's binary is what judges it.\n// 'worktree' is git's answer (`--git-dir` ≠ `--git-common-dir`) for the tree the command ACTS ON — it\n// does not depend on where the agent was launched, nor on which tree owns the config.\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 /**\n * The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the\n * repo, and equal to `root` in the primary clone itself.\n *\n * NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent\n * resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the\n * binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to\n * the primary clone. This field is that walk-up, asked of git instead of inferred.\n */\n readonly mainRoot: 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 // eslint-disable-next-line @typescript-eslint/max-params -- five resolved paths plus the kind decided from them\n constructor(\n shellCwd: string,\n effectiveCwd: string,\n root: string,\n governedRoot: string,\n mainRoot: string,\n kind: TreeKind,\n ) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.mainRoot = mainRoot;\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(\n shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.mainRoot, 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, 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, governedRoot)\n : new TreeClassification('outside', governedRoot, governedRoot);\n }\n\n const treeRoot = dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;\n // `<git-common-dir>/..`, off the SAME memoized rev-parse pair `gitDirs` just answered with — so\n // this costs no extra process, which matters on the hook's blocking path.\n const mainRoot = dotWebpieces.primaryRoot(effectiveCwd);\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, mainRoot);\n }\n\n // GIT DECIDES, and nothing else. `isLinkedWorktree` is `--git-dir !== --git-common-dir`, an\n // answer about the CHECKOUT — so a worktree is `worktree` whether the command was typed from the\n // primary clone (`cd <wt> && …`) or by an agent living in it. The old extra clause\n // (`|| sameDir(treeRoot, governedRoot)`) made a resident agent's OWN tree read `primary`,\n // silently retiring row 8 for every worktree-isolated subagent — see this module's header.\n if (!dirs.isLinkedWorktree) return new TreeClassification('primary', treeRoot, mainRoot);\n return new TreeClassification('worktree', treeRoot, mainRoot);\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 three values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n /** The primary clone behind `root` — see EffectiveTree.mainRoot for why it is not `governedRoot`. */\n readonly mainRoot: string;\n\n constructor(kind: TreeKind, root: string, mainRoot: string) {\n this.kind = kind;\n this.root = root;\n this.mainRoot = mainRoot;\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"]}
@@ -31,6 +31,15 @@ import { VersionQuartet } from './webpieces-versions';
31
31
  * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner
32
32
  * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.
33
33
  * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.
34
+ *
35
+ * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────
36
+ * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload
37
+ * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.
38
+ * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against
39
+ * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's
40
+ * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and
41
+ * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own
42
+ * install, a main clone on 0.4.616, and not one word from this guard.
34
43
  */
35
44
  export declare class VersionSyncGuard {
36
45
  private readonly inspection;
@@ -59,6 +68,16 @@ export declare class VersionSyncGuard {
59
68
  * because those are the operations that would be judged by the wrong release.
60
69
  */
61
70
  private isCureOrLook;
71
+ /**
72
+ * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:
73
+ *
74
+ * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can
75
+ * never reach the manifests. (In the primary clone this is also structural escape #1: "do the
76
+ * work in the main tree" needs no allowlist entry to keep working.)
77
+ * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the
78
+ * single-tree pin-vs-install question the L0 drift guard already owns.
79
+ */
80
+ private applies;
62
81
  /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */
63
82
  quartetFor(tree: EffectiveTree): VersionQuartet;
64
83
  private report;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VersionSyncGuard = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
4
6
  const read_only_inspection_1 = require("./read-only-inspection");
5
7
  const webpieces_versions_1 = require("./webpieces-versions");
6
8
  /**
@@ -34,6 +36,15 @@ const webpieces_versions_1 = require("./webpieces-versions");
34
36
  * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner
35
37
  * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.
36
38
  * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.
39
+ *
40
+ * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────
41
+ * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload
42
+ * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.
43
+ * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against
44
+ * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's
45
+ * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and
46
+ * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own
47
+ * install, a main clone on 0.4.616, and not one word from this guard.
37
48
  */
38
49
  class VersionSyncGuard {
39
50
  inspection = new read_only_inspection_1.ReadOnlyInspectionScan();
@@ -44,13 +55,13 @@ class VersionSyncGuard {
44
55
  * lands in the audit log even when nothing blocks.
45
56
  */
46
57
  skewed(tree) {
47
- if (tree.kind !== 'worktree')
58
+ if (!this.applies(tree))
48
59
  return false;
49
60
  return !this.quartetFor(tree).inSync;
50
61
  }
51
62
  /** The deny report, or null to allow. */
52
63
  block(command, tree) {
53
- if (tree.kind !== 'worktree')
64
+ if (!this.applies(tree))
54
65
  return null;
55
66
  if (this.inspection.isReadOnlyInspection(command))
56
67
  return null;
@@ -87,9 +98,22 @@ class VersionSyncGuard {
87
98
  }
88
99
  return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');
89
100
  }
101
+ /**
102
+ * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:
103
+ *
104
+ * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can
105
+ * never reach the manifests. (In the primary clone this is also structural escape #1: "do the
106
+ * work in the main tree" needs no allowlist entry to keep working.)
107
+ * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the
108
+ * single-tree pin-vs-install question the L0 drift guard already owns.
109
+ */
110
+ applies(tree) {
111
+ return tree.kind === 'worktree'
112
+ && path.resolve(tree.mainRoot) !== path.resolve(tree.root);
113
+ }
90
114
  /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */
91
115
  quartetFor(tree) {
92
- return this.versions.quartet(tree.governedRoot, tree.root);
116
+ return this.versions.quartet(tree.mainRoot, tree.root);
93
117
  }
94
118
  // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text
95
119
  // if each one argues its case. State the skew, show every version WITH its file, give the git cure
@@ -104,8 +128,8 @@ class VersionSyncGuard {
104
128
  ` linted, validated and built by a release its own manifest does not ask for.`,
105
129
  '',
106
130
  ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,
107
- ` 1. \`git -C ${tree.governedRoot} pull\` and \`git -C ${tree.root} pull\` onto the same main,`,
108
- ` then ONE \`cd '${tree.governedRoot}' && pnpm install\`. A worktree needs no install of its own.`,
131
+ ` 1. \`git -C ${tree.mainRoot} pull\` and \`git -C ${tree.root} pull\` onto the same main,`,
132
+ ` then ONE \`cd '${tree.mainRoot}' && pnpm install\`. A worktree needs no install of its own.`,
109
133
  ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,
110
134
  ` 3. Or, if this tree genuinely needs a different version, use a separate CLONE, not a`,
111
135
  ` worktree: a clone gets its own node_modules and its own governance; a worktree cannot.`,
@@ -123,8 +147,8 @@ class VersionSyncGuard {
123
147
  // "this one is absent" from "I forgot to look".
124
148
  versionLines(tree, quartet) {
125
149
  const lines = [
126
- ` main pin ${this.show(quartet.main.pinned)} ${tree.governedRoot}/pnpm-workspace.yaml`,
127
- ` main installed ${this.show(quartet.main.installed)} ${tree.governedRoot}/node_modules/${webpieces_versions_1.UMBRELLA_PACKAGE}`,
150
+ ` main pin ${this.show(quartet.main.pinned)} ${tree.mainRoot}/pnpm-workspace.yaml`,
151
+ ` main installed ${this.show(quartet.main.installed)} ${tree.mainRoot}/node_modules/${webpieces_versions_1.UMBRELLA_PACKAGE}`,
128
152
  ` ^ the binary judging this very call`,
129
153
  ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,
130
154
  ];
@@ -132,7 +156,7 @@ class VersionSyncGuard {
132
156
  lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${webpieces_versions_1.UMBRELLA_PACKAGE}`);
133
157
  lines.push(' ^ what nx, vitest and eslint load IN this tree');
134
158
  }
135
- const others = this.versions.otherWorktrees(tree.governedRoot, tree.root);
159
+ const others = this.versions.otherWorktrees(tree.mainRoot, tree.root);
136
160
  if (others.length > 0) {
137
161
  lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);
138
162
  lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');
@@ -1 +1 @@
1
- {"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":"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","sourcesContent":["import { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n */\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (tree.kind !== 'worktree') return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (tree.kind !== 'worktree') return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.governedRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` The guard hooks are ABSOLUTE, so the MAIN tree governs every tree. This worktree would be`,\n ` linted, validated and built by a release its own manifest does not ask for.`,\n '',\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.governedRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then ONE \\`cd '${tree.governedRoot}' && pnpm install\\`. A worktree needs no install of its own.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a different version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own node_modules and its own governance; a worktree cannot.`,\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor. If you are a SUBAGENT, you cannot fix the main tree: report to your`,\n ` coordinator that one of you must move to the other's version.`,\n ].join('\\n');\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.governedRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.governedRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.governedRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}
1
+ {"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n *\n * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────\n * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload\n * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.\n * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against\n * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's\n * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and\n * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own\n * install, a main clone on 0.4.616, and not one word from this guard.\n */\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (!this.applies(tree)) return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (!this.applies(tree)) return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /**\n * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:\n *\n * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can\n * never reach the manifests. (In the primary clone this is also structural escape #1: \"do the\n * work in the main tree\" needs no allowlist entry to keep working.)\n * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the\n * single-tree pin-vs-install question the L0 drift guard already owns.\n */\n private applies(tree: EffectiveTree): boolean {\n return tree.kind === 'worktree'\n && path.resolve(tree.mainRoot) !== path.resolve(tree.root);\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.mainRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` The guard hooks are ABSOLUTE, so the MAIN tree governs every tree. This worktree would be`,\n ` linted, validated and built by a release its own manifest does not ask for.`,\n '',\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.mainRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then ONE \\`cd '${tree.mainRoot}' && pnpm install\\`. A worktree needs no install of its own.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a different version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own node_modules and its own governance; a worktree cannot.`,\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor. If you are a SUBAGENT, you cannot fix the main tree: report to your`,\n ` coordinator that one of you must move to the other's version.`,\n ].join('\\n');\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.mainRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.mainRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.mainRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}