@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.
@@ -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"]}