@webpieces/ai-hook-rules 0.4.737 → 0.4.739
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/core/excluded-paths.d.ts +23 -0
- package/src/core/excluded-paths.js +54 -0
- package/src/core/excluded-paths.js.map +1 -0
- package/src/core/l1-doc.js +20 -3
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.js +1 -0
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/l2-rows.js +9 -0
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +36 -0
- package/src/core/rules/feature-branch-guard.js +95 -17
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/judged-tree.d.ts +66 -0
- package/src/core/rules/judged-tree.js +97 -0
- package/src/core/rules/judged-tree.js.map +1 -0
- package/src/core/rules/read-stale-guard.d.ts +8 -0
- package/src/core/rules/read-stale-guard.js +47 -16
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/runner.d.ts +0 -2
- package/src/core/runner.js +13 -23
- package/src/core/runner.js.map +1 -1
- package/src/core/target-tree.d.ts +82 -0
- package/src/core/target-tree.js +145 -0
- package/src/core/target-tree.js.map +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { EffectiveTree, EffectiveTreeResolver } from './effective-tree';
|
|
2
|
+
/**
|
|
3
|
+
* WHICH TREE owns the FILE a Write/Edit/Read is aimed at? The file-path counterpart to
|
|
4
|
+
* EffectiveTreeResolver, which answers the same question for a Bash command's effective cwd.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT HAS TO EXIST (issue #851). The bash half of this question has been answered by git since
|
|
7
|
+
* effective-tree.ts was written — `isLinkedWorktree` is `--git-dir !== --git-common-dir`, and its header
|
|
8
|
+
* states the principle in capitals: *PLACEMENT IS NOT IDENTITY*. The FILE half never consumed that
|
|
9
|
+
* answer. Every file-scoped guard judged `ctx.workspaceRoot`, which is the walk-up from the SESSION's
|
|
10
|
+
* cwd to whichever `webpieces.config.json` governs it — the PRIMARY clone, for a main-session edit into
|
|
11
|
+
* an agent worktree. Because Claude Code checks a worktree out INSIDE the repo at
|
|
12
|
+
* `<repo>/.claude/worktrees/agent-XXXX`, that walk-up answers "primary" for a path that is manifestly
|
|
13
|
+
* not the primary's, and nothing downstream re-examined it.
|
|
14
|
+
*
|
|
15
|
+
* Measured live on 2026-09-03: an Edit of one import line inside a worktree on a CLEAN branch was
|
|
16
|
+
* refused, citing `pnpm-lock.yaml` and `pnpm-workspace.yaml` — the PRIMARY clone's branch's conflict
|
|
17
|
+
* files. The main-sync cache is already keyed by branch and already held the correct, non-conflicting
|
|
18
|
+
* entry for the worktree's branch, one key over. Only the lookup key was wrong. So this class exists to
|
|
19
|
+
* make the lookup ask the same authority the bash half asks, rather than to introduce a second one.
|
|
20
|
+
*
|
|
21
|
+
* IT ASKS EffectiveTreeResolver, and adds NOTHING of its own beyond finding a directory to ask about.
|
|
22
|
+
* That is deliberate and it is the whole design: two resolvers WILL disagree about which tree a path is
|
|
23
|
+
* in, and this module exists precisely because two layers already did. There is no `.claude/worktrees`
|
|
24
|
+
* string match here and there must never be one — matching on placement is the bug, not the fix.
|
|
25
|
+
*
|
|
26
|
+
* The only thing worth naming is the walk-up: a Write legitimately names a file, and directories, that
|
|
27
|
+
* do not exist yet, and git cannot be asked about a directory that is not there. So we climb to the
|
|
28
|
+
* nearest EXISTING ancestor and ask about that. A parent directory is in the same worktree as the child
|
|
29
|
+
* it is about to contain — that is a property of the filesystem, not a guess.
|
|
30
|
+
*/
|
|
31
|
+
export declare class TargetTreeResolver {
|
|
32
|
+
private readonly trees;
|
|
33
|
+
constructor(trees?: EffectiveTreeResolver);
|
|
34
|
+
/**
|
|
35
|
+
* The tree that owns `targetPath`, classified exactly as a Bash command's cwd would be.
|
|
36
|
+
*
|
|
37
|
+
* `governedRoot` is the fallback for every case with no better answer — an empty path, a path with
|
|
38
|
+
* no existing ancestor at all — and is also what `EffectiveTree.governedRoot` keeps meaning: whose
|
|
39
|
+
* config and excludePaths apply. It is NOT the tree to judge; `EffectiveTree.root` is.
|
|
40
|
+
*/
|
|
41
|
+
resolve(targetPath: string, governedRoot: string): EffectiveTree;
|
|
42
|
+
/**
|
|
43
|
+
* The two relative spellings of one target path, resolved together so a caller cannot accidentally
|
|
44
|
+
* use the wrong one. See GovernedPath for why there are two.
|
|
45
|
+
*/
|
|
46
|
+
governedPath(targetPath: string, governedRoot: string): GovernedPath;
|
|
47
|
+
private nearestExistingDir;
|
|
48
|
+
/**
|
|
49
|
+
* `targetPath` made absolute AND symlink-resolved, including when it does not exist yet.
|
|
50
|
+
*
|
|
51
|
+
* `fs.realpathSync` throws on a missing path, and a Write's target is missing by definition, so the
|
|
52
|
+
* resolvable HEAD of the path (its nearest existing ancestor) is resolved and the not-yet-existing
|
|
53
|
+
* TAIL is re-appended verbatim. That keeps it comparable with `tree.root`, which is git's answer and
|
|
54
|
+
* always real.
|
|
55
|
+
*/
|
|
56
|
+
private realAbsolute;
|
|
57
|
+
private realDir;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One target path, in the TWO relative spellings the hook path needs — data-only, per CLAUDE.md.
|
|
61
|
+
*
|
|
62
|
+
* They are different questions and conflating them is issue #851's secondary defect:
|
|
63
|
+
*
|
|
64
|
+
* `relativePath` — relative to the GOVERNED root. What `excludePaths`' globs are written against,
|
|
65
|
+
* and what the violation report prints.
|
|
66
|
+
* `treeRelativePath` — relative to the tree that OWNS the file. What `isWebpiecesStateDir` must be
|
|
67
|
+
* asked about, because `.webpieces/` is the tooling's state dir *of a tree*, and
|
|
68
|
+
* a worktree has its own. Governed-root-relative, a reviewer subagent's
|
|
69
|
+
* `<primary>/.claude/worktrees/agent-X/.webpieces/pr-review/<branch>/review.json`
|
|
70
|
+
* reads as `.claude/...` and was NOT exempt — while
|
|
71
|
+
* `<primary>/.webpieces/worktrees/agent-X/pr-review/.../review-1.json`, the same
|
|
72
|
+
* kind of file one directory over, was. `wp-review-upsert-pr` REQUIRES that file
|
|
73
|
+
* before `wp-finish-upsert-pr` will open a PR, so the guard could forbid a file
|
|
74
|
+
* the gate demands.
|
|
75
|
+
*
|
|
76
|
+
* In the primary clone the two strings are identical, which is why the gap was invisible for so long.
|
|
77
|
+
*/
|
|
78
|
+
export declare class GovernedPath {
|
|
79
|
+
readonly relativePath: string;
|
|
80
|
+
readonly treeRelativePath: string;
|
|
81
|
+
constructor(relativePath: string, treeRelativePath: string);
|
|
82
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GovernedPath = exports.TargetTreeResolver = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const effective_tree_1 = require("./effective-tree");
|
|
8
|
+
/**
|
|
9
|
+
* WHICH TREE owns the FILE a Write/Edit/Read is aimed at? The file-path counterpart to
|
|
10
|
+
* EffectiveTreeResolver, which answers the same question for a Bash command's effective cwd.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT HAS TO EXIST (issue #851). The bash half of this question has been answered by git since
|
|
13
|
+
* effective-tree.ts was written — `isLinkedWorktree` is `--git-dir !== --git-common-dir`, and its header
|
|
14
|
+
* states the principle in capitals: *PLACEMENT IS NOT IDENTITY*. The FILE half never consumed that
|
|
15
|
+
* answer. Every file-scoped guard judged `ctx.workspaceRoot`, which is the walk-up from the SESSION's
|
|
16
|
+
* cwd to whichever `webpieces.config.json` governs it — the PRIMARY clone, for a main-session edit into
|
|
17
|
+
* an agent worktree. Because Claude Code checks a worktree out INSIDE the repo at
|
|
18
|
+
* `<repo>/.claude/worktrees/agent-XXXX`, that walk-up answers "primary" for a path that is manifestly
|
|
19
|
+
* not the primary's, and nothing downstream re-examined it.
|
|
20
|
+
*
|
|
21
|
+
* Measured live on 2026-09-03: an Edit of one import line inside a worktree on a CLEAN branch was
|
|
22
|
+
* refused, citing `pnpm-lock.yaml` and `pnpm-workspace.yaml` — the PRIMARY clone's branch's conflict
|
|
23
|
+
* files. The main-sync cache is already keyed by branch and already held the correct, non-conflicting
|
|
24
|
+
* entry for the worktree's branch, one key over. Only the lookup key was wrong. So this class exists to
|
|
25
|
+
* make the lookup ask the same authority the bash half asks, rather than to introduce a second one.
|
|
26
|
+
*
|
|
27
|
+
* IT ASKS EffectiveTreeResolver, and adds NOTHING of its own beyond finding a directory to ask about.
|
|
28
|
+
* That is deliberate and it is the whole design: two resolvers WILL disagree about which tree a path is
|
|
29
|
+
* in, and this module exists precisely because two layers already did. There is no `.claude/worktrees`
|
|
30
|
+
* string match here and there must never be one — matching on placement is the bug, not the fix.
|
|
31
|
+
*
|
|
32
|
+
* The only thing worth naming is the walk-up: a Write legitimately names a file, and directories, that
|
|
33
|
+
* do not exist yet, and git cannot be asked about a directory that is not there. So we climb to the
|
|
34
|
+
* nearest EXISTING ancestor and ask about that. A parent directory is in the same worktree as the child
|
|
35
|
+
* it is about to contain — that is a property of the filesystem, not a guess.
|
|
36
|
+
*/
|
|
37
|
+
class TargetTreeResolver {
|
|
38
|
+
trees;
|
|
39
|
+
constructor(trees = new effective_tree_1.EffectiveTreeResolver()) {
|
|
40
|
+
this.trees = trees;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The tree that owns `targetPath`, classified exactly as a Bash command's cwd would be.
|
|
44
|
+
*
|
|
45
|
+
* `governedRoot` is the fallback for every case with no better answer — an empty path, a path with
|
|
46
|
+
* no existing ancestor at all — and is also what `EffectiveTree.governedRoot` keeps meaning: whose
|
|
47
|
+
* config and excludePaths apply. It is NOT the tree to judge; `EffectiveTree.root` is.
|
|
48
|
+
*/
|
|
49
|
+
resolve(targetPath, governedRoot) {
|
|
50
|
+
// REAL paths on BOTH sides, or git's answers cannot be compared with each other. Identity here
|
|
51
|
+
// is `commonDir(target) === commonDir(governed)`, and git prints an ABSOLUTE, symlink-resolved
|
|
52
|
+
// path from a linked worktree while printing a bare relative `.git` from the primary clone. So
|
|
53
|
+
// on any repo reached through a symlink — `/var/folders/...` on macOS, which is `/private/var`,
|
|
54
|
+
// and every `os.tmpdir()` path under it — the two strings differ for the same `.git` and the
|
|
55
|
+
// repo's OWN worktree classifies as `foreign`, i.e. every guard silently off. Resolving both
|
|
56
|
+
// sides first is what makes the comparison an answer about git rather than about spelling.
|
|
57
|
+
const governed = this.realDir(governedRoot);
|
|
58
|
+
return this.trees.resolve('', this.nearestExistingDir(targetPath, governed), governed);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The two relative spellings of one target path, resolved together so a caller cannot accidentally
|
|
62
|
+
* use the wrong one. See GovernedPath for why there are two.
|
|
63
|
+
*/
|
|
64
|
+
governedPath(targetPath, governedRoot) {
|
|
65
|
+
const absolute = this.realAbsolute(targetPath);
|
|
66
|
+
const tree = this.resolve(targetPath, governedRoot);
|
|
67
|
+
// `tree.root` and `tree.governedRoot` are already resolved by resolve() above, so both
|
|
68
|
+
// subtractions are real-against-real. Mixing one resolved side with one unresolved one produces
|
|
69
|
+
// a `../../../..` climb out of the filesystem — which reads as "outside the workspace" and
|
|
70
|
+
// would quietly hand every path the wrong verdict on a symlinked checkout.
|
|
71
|
+
return new GovernedPath(path.relative(tree.governedRoot, absolute), path.relative(tree.root, absolute));
|
|
72
|
+
}
|
|
73
|
+
// The nearest ancestor directory of `targetPath` that EXISTS — the deepest directory git can be
|
|
74
|
+
// asked about — with symlinks resolved. Bounded by the filesystem root (path.dirname is its own
|
|
75
|
+
// fixed point at `/`), so the loop terminates without a counter.
|
|
76
|
+
nearestExistingDir(targetPath, governedRoot) {
|
|
77
|
+
if (targetPath === '')
|
|
78
|
+
return governedRoot;
|
|
79
|
+
let dir = path.dirname(path.resolve(targetPath));
|
|
80
|
+
for (;;) {
|
|
81
|
+
if (fs.existsSync(dir))
|
|
82
|
+
return fs.realpathSync(dir);
|
|
83
|
+
const parent = path.dirname(dir);
|
|
84
|
+
if (parent === dir)
|
|
85
|
+
return governedRoot;
|
|
86
|
+
dir = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `targetPath` made absolute AND symlink-resolved, including when it does not exist yet.
|
|
91
|
+
*
|
|
92
|
+
* `fs.realpathSync` throws on a missing path, and a Write's target is missing by definition, so the
|
|
93
|
+
* resolvable HEAD of the path (its nearest existing ancestor) is resolved and the not-yet-existing
|
|
94
|
+
* TAIL is re-appended verbatim. That keeps it comparable with `tree.root`, which is git's answer and
|
|
95
|
+
* always real.
|
|
96
|
+
*/
|
|
97
|
+
realAbsolute(targetPath) {
|
|
98
|
+
const absolute = path.resolve(targetPath);
|
|
99
|
+
const tail = [];
|
|
100
|
+
let dir = absolute;
|
|
101
|
+
for (;;) {
|
|
102
|
+
if (fs.existsSync(dir))
|
|
103
|
+
return path.join(fs.realpathSync(dir), ...tail);
|
|
104
|
+
const parent = path.dirname(dir);
|
|
105
|
+
if (parent === dir)
|
|
106
|
+
return absolute;
|
|
107
|
+
tail.unshift(path.basename(dir));
|
|
108
|
+
dir = parent;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// A directory made real when it is there, and merely absolute when it is not.
|
|
112
|
+
realDir(dir) {
|
|
113
|
+
return fs.existsSync(dir) ? fs.realpathSync(dir) : path.resolve(dir);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
exports.TargetTreeResolver = TargetTreeResolver;
|
|
117
|
+
/**
|
|
118
|
+
* One target path, in the TWO relative spellings the hook path needs — data-only, per CLAUDE.md.
|
|
119
|
+
*
|
|
120
|
+
* They are different questions and conflating them is issue #851's secondary defect:
|
|
121
|
+
*
|
|
122
|
+
* `relativePath` — relative to the GOVERNED root. What `excludePaths`' globs are written against,
|
|
123
|
+
* and what the violation report prints.
|
|
124
|
+
* `treeRelativePath` — relative to the tree that OWNS the file. What `isWebpiecesStateDir` must be
|
|
125
|
+
* asked about, because `.webpieces/` is the tooling's state dir *of a tree*, and
|
|
126
|
+
* a worktree has its own. Governed-root-relative, a reviewer subagent's
|
|
127
|
+
* `<primary>/.claude/worktrees/agent-X/.webpieces/pr-review/<branch>/review.json`
|
|
128
|
+
* reads as `.claude/...` and was NOT exempt — while
|
|
129
|
+
* `<primary>/.webpieces/worktrees/agent-X/pr-review/.../review-1.json`, the same
|
|
130
|
+
* kind of file one directory over, was. `wp-review-upsert-pr` REQUIRES that file
|
|
131
|
+
* before `wp-finish-upsert-pr` will open a PR, so the guard could forbid a file
|
|
132
|
+
* the gate demands.
|
|
133
|
+
*
|
|
134
|
+
* In the primary clone the two strings are identical, which is why the gap was invisible for so long.
|
|
135
|
+
*/
|
|
136
|
+
class GovernedPath {
|
|
137
|
+
relativePath;
|
|
138
|
+
treeRelativePath;
|
|
139
|
+
constructor(relativePath, treeRelativePath) {
|
|
140
|
+
this.relativePath = relativePath;
|
|
141
|
+
this.treeRelativePath = treeRelativePath;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
exports.GovernedPath = GovernedPath;
|
|
145
|
+
//# sourceMappingURL=target-tree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"target-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/target-tree.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { EffectiveTree, EffectiveTreeResolver } from './effective-tree';\n\n/**\n * WHICH TREE owns the FILE a Write/Edit/Read is aimed at? The file-path counterpart to\n * EffectiveTreeResolver, which answers the same question for a Bash command's effective cwd.\n *\n * WHY IT HAS TO EXIST (issue #851). The bash half of this question has been answered by git since\n * effective-tree.ts was written — `isLinkedWorktree` is `--git-dir !== --git-common-dir`, and its header\n * states the principle in capitals: *PLACEMENT IS NOT IDENTITY*. The FILE half never consumed that\n * answer. Every file-scoped guard judged `ctx.workspaceRoot`, which is the walk-up from the SESSION's\n * cwd to whichever `webpieces.config.json` governs it — the PRIMARY clone, for a main-session edit into\n * an agent worktree. Because Claude Code checks a worktree out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, that walk-up answers \"primary\" for a path that is manifestly\n * not the primary's, and nothing downstream re-examined it.\n *\n * Measured live on 2026-09-03: an Edit of one import line inside a worktree on a CLEAN branch was\n * refused, citing `pnpm-lock.yaml` and `pnpm-workspace.yaml` — the PRIMARY clone's branch's conflict\n * files. The main-sync cache is already keyed by branch and already held the correct, non-conflicting\n * entry for the worktree's branch, one key over. Only the lookup key was wrong. So this class exists to\n * make the lookup ask the same authority the bash half asks, rather than to introduce a second one.\n *\n * IT ASKS EffectiveTreeResolver, and adds NOTHING of its own beyond finding a directory to ask about.\n * That is deliberate and it is the whole design: two resolvers WILL disagree about which tree a path is\n * in, and this module exists precisely because two layers already did. There is no `.claude/worktrees`\n * string match here and there must never be one — matching on placement is the bug, not the fix.\n *\n * The only thing worth naming is the walk-up: a Write legitimately names a file, and directories, that\n * do not exist yet, and git cannot be asked about a directory that is not there. So we climb to the\n * nearest EXISTING ancestor and ask about that. A parent directory is in the same worktree as the child\n * it is about to contain — that is a property of the filesystem, not a guess.\n */\nexport class TargetTreeResolver {\n constructor(private readonly trees: EffectiveTreeResolver = new EffectiveTreeResolver()) {}\n\n /**\n * The tree that owns `targetPath`, classified exactly as a Bash command's cwd would be.\n *\n * `governedRoot` is the fallback for every case with no better answer — an empty path, a path with\n * no existing ancestor at all — and is also what `EffectiveTree.governedRoot` keeps meaning: whose\n * config and excludePaths apply. It is NOT the tree to judge; `EffectiveTree.root` is.\n */\n resolve(targetPath: string, governedRoot: string): EffectiveTree {\n // REAL paths on BOTH sides, or git's answers cannot be compared with each other. Identity here\n // is `commonDir(target) === commonDir(governed)`, and git prints an ABSOLUTE, symlink-resolved\n // path from a linked worktree while printing a bare relative `.git` from the primary clone. So\n // on any repo reached through a symlink — `/var/folders/...` on macOS, which is `/private/var`,\n // and every `os.tmpdir()` path under it — the two strings differ for the same `.git` and the\n // repo's OWN worktree classifies as `foreign`, i.e. every guard silently off. Resolving both\n // sides first is what makes the comparison an answer about git rather than about spelling.\n const governed = this.realDir(governedRoot);\n return this.trees.resolve('', this.nearestExistingDir(targetPath, governed), governed);\n }\n\n /**\n * The two relative spellings of one target path, resolved together so a caller cannot accidentally\n * use the wrong one. See GovernedPath for why there are two.\n */\n governedPath(targetPath: string, governedRoot: string): GovernedPath {\n const absolute = this.realAbsolute(targetPath);\n const tree = this.resolve(targetPath, governedRoot);\n // `tree.root` and `tree.governedRoot` are already resolved by resolve() above, so both\n // subtractions are real-against-real. Mixing one resolved side with one unresolved one produces\n // a `../../../..` climb out of the filesystem — which reads as \"outside the workspace\" and\n // would quietly hand every path the wrong verdict on a symlinked checkout.\n return new GovernedPath(\n path.relative(tree.governedRoot, absolute),\n path.relative(tree.root, absolute),\n );\n }\n\n // The nearest ancestor directory of `targetPath` that EXISTS — the deepest directory git can be\n // asked about — with symlinks resolved. Bounded by the filesystem root (path.dirname is its own\n // fixed point at `/`), so the loop terminates without a counter.\n private nearestExistingDir(targetPath: string, governedRoot: string): string {\n if (targetPath === '') return governedRoot;\n let dir = path.dirname(path.resolve(targetPath));\n for (;;) {\n if (fs.existsSync(dir)) return fs.realpathSync(dir);\n const parent = path.dirname(dir);\n if (parent === dir) return governedRoot;\n dir = parent;\n }\n }\n\n /**\n * `targetPath` made absolute AND symlink-resolved, including when it does not exist yet.\n *\n * `fs.realpathSync` throws on a missing path, and a Write's target is missing by definition, so the\n * resolvable HEAD of the path (its nearest existing ancestor) is resolved and the not-yet-existing\n * TAIL is re-appended verbatim. That keeps it comparable with `tree.root`, which is git's answer and\n * always real.\n */\n private realAbsolute(targetPath: string): string {\n const absolute = path.resolve(targetPath);\n const tail: string[] = [];\n let dir = absolute;\n for (;;) {\n if (fs.existsSync(dir)) return path.join(fs.realpathSync(dir), ...tail);\n const parent = path.dirname(dir);\n if (parent === dir) return absolute;\n tail.unshift(path.basename(dir));\n dir = parent;\n }\n }\n\n // A directory made real when it is there, and merely absolute when it is not.\n private realDir(dir: string): string {\n return fs.existsSync(dir) ? fs.realpathSync(dir) : path.resolve(dir);\n }\n}\n\n/**\n * One target path, in the TWO relative spellings the hook path needs — data-only, per CLAUDE.md.\n *\n * They are different questions and conflating them is issue #851's secondary defect:\n *\n * `relativePath` — relative to the GOVERNED root. What `excludePaths`' globs are written against,\n * and what the violation report prints.\n * `treeRelativePath` — relative to the tree that OWNS the file. What `isWebpiecesStateDir` must be\n * asked about, because `.webpieces/` is the tooling's state dir *of a tree*, and\n * a worktree has its own. Governed-root-relative, a reviewer subagent's\n * `<primary>/.claude/worktrees/agent-X/.webpieces/pr-review/<branch>/review.json`\n * reads as `.claude/...` and was NOT exempt — while\n * `<primary>/.webpieces/worktrees/agent-X/pr-review/.../review-1.json`, the same\n * kind of file one directory over, was. `wp-review-upsert-pr` REQUIRES that file\n * before `wp-finish-upsert-pr` will open a PR, so the guard could forbid a file\n * the gate demands.\n *\n * In the primary clone the two strings are identical, which is why the gap was invisible for so long.\n */\nexport class GovernedPath {\n readonly relativePath: string;\n readonly treeRelativePath: string;\n\n constructor(relativePath: string, treeRelativePath: string) {\n this.relativePath = relativePath;\n this.treeRelativePath = treeRelativePath;\n }\n}\n"]}
|