@webpieces/hook-runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +37 -0
  2. package/src/adapters/agent-adapters.d.ts +13 -0
  3. package/src/adapters/agent-adapters.js +23 -0
  4. package/src/adapters/agent-adapters.js.map +1 -0
  5. package/src/adapters/agent-payload.d.ts +48 -0
  6. package/src/adapters/agent-payload.js +30 -0
  7. package/src/adapters/agent-payload.js.map +1 -0
  8. package/src/adapters/claude-code-adapter.d.ts +26 -0
  9. package/src/adapters/claude-code-adapter.js +69 -0
  10. package/src/adapters/claude-code-adapter.js.map +1 -0
  11. package/src/adapters/codex-adapter.d.ts +19 -0
  12. package/src/adapters/codex-adapter.js +48 -0
  13. package/src/adapters/codex-adapter.js.map +1 -0
  14. package/src/adapters/detect-ai.d.ts +70 -0
  15. package/src/adapters/detect-ai.js +82 -0
  16. package/src/adapters/detect-ai.js.map +1 -0
  17. package/src/core/apply-patch-parse.d.ts +36 -0
  18. package/src/core/apply-patch-parse.js +155 -0
  19. package/src/core/apply-patch-parse.js.map +1 -0
  20. package/src/core/build-context.d.ts +11 -0
  21. package/src/core/build-context.js +69 -0
  22. package/src/core/build-context.js.map +1 -0
  23. package/src/core/command-scan.d.ts +122 -0
  24. package/src/core/command-scan.js +282 -0
  25. package/src/core/command-scan.js.map +1 -0
  26. package/src/core/custom-rule-adapter.d.ts +23 -0
  27. package/src/core/custom-rule-adapter.js +50 -0
  28. package/src/core/custom-rule-adapter.js.map +1 -0
  29. package/src/core/delete-scoped-rules.d.ts +9 -0
  30. package/src/core/delete-scoped-rules.js +31 -0
  31. package/src/core/delete-scoped-rules.js.map +1 -0
  32. package/src/core/disable-directives.d.ts +8 -0
  33. package/src/core/disable-directives.js +86 -0
  34. package/src/core/disable-directives.js.map +1 -0
  35. package/src/core/effective-tree.d.ts +197 -0
  36. package/src/core/effective-tree.js +269 -0
  37. package/src/core/effective-tree.js.map +1 -0
  38. package/src/core/excluded-paths.d.ts +23 -0
  39. package/src/core/excluded-paths.js +54 -0
  40. package/src/core/excluded-paths.js.map +1 -0
  41. package/src/core/file-evaluation.d.ts +11 -0
  42. package/src/core/file-evaluation.js +45 -0
  43. package/src/core/file-evaluation.js.map +1 -0
  44. package/src/core/fix-hint.d.ts +40 -0
  45. package/src/core/fix-hint.js +53 -0
  46. package/src/core/fix-hint.js.map +1 -0
  47. package/src/core/glob.d.ts +1 -0
  48. package/src/core/glob.js +42 -0
  49. package/src/core/glob.js.map +1 -0
  50. package/src/core/report.d.ts +21 -0
  51. package/src/core/report.js +74 -0
  52. package/src/core/report.js.map +1 -0
  53. package/src/core/root-manifest.d.ts +1 -0
  54. package/src/core/root-manifest.js +21 -0
  55. package/src/core/root-manifest.js.map +1 -0
  56. package/src/core/rule-base.d.ts +40 -0
  57. package/src/core/rule-base.js +37 -0
  58. package/src/core/rule-base.js.map +1 -0
  59. package/src/core/rule-evaluation.d.ts +18 -0
  60. package/src/core/rule-evaluation.js +179 -0
  61. package/src/core/rule-evaluation.js.map +1 -0
  62. package/src/core/rules/shell-segment-scan.d.ts +64 -0
  63. package/src/core/rules/shell-segment-scan.js +88 -0
  64. package/src/core/rules/shell-segment-scan.js.map +1 -0
  65. package/src/core/shell-read-parity.d.ts +34 -0
  66. package/src/core/shell-read-parity.js +157 -0
  67. package/src/core/shell-read-parity.js.map +1 -0
  68. package/src/core/strip-ts-noise.d.ts +1 -0
  69. package/src/core/strip-ts-noise.js +191 -0
  70. package/src/core/strip-ts-noise.js.map +1 -0
  71. package/src/core/target-tree.d.ts +84 -0
  72. package/src/core/target-tree.js +148 -0
  73. package/src/core/target-tree.js.map +1 -0
  74. package/src/core/types.d.ts +149 -0
  75. package/src/core/types.js +190 -0
  76. package/src/core/types.js.map +1 -0
  77. package/src/harness-vocabulary.d.ts +2 -0
  78. package/src/harness-vocabulary.js +10 -0
  79. package/src/harness-vocabulary.js.map +1 -0
  80. package/src/hook-app.d.ts +15 -0
  81. package/src/hook-app.js +58 -0
  82. package/src/hook-app.js.map +1 -0
  83. package/src/hook-evaluator.d.ts +5 -0
  84. package/src/hook-evaluator.js +13 -0
  85. package/src/hook-evaluator.js.map +1 -0
  86. package/src/hook-ports.d.ts +9 -0
  87. package/src/hook-ports.js +40 -0
  88. package/src/hook-ports.js.map +1 -0
  89. package/src/index.d.ts +31 -0
  90. package/src/index.js +59 -0
  91. package/src/index.js.map +1 -0
  92. package/src/outcome.d.ts +14 -0
  93. package/src/outcome.js +20 -0
  94. package/src/outcome.js.map +1 -0
  95. package/src/protocol.d.ts +40 -0
  96. package/src/protocol.js +55 -0
  97. package/src/protocol.js.map +1 -0
  98. package/src/response.d.ts +2 -0
  99. package/src/response.js +21 -0
  100. package/src/response.js.map +1 -0
  101. package/src/to-error.d.ts +1 -0
  102. package/src/to-error.js +9 -0
  103. package/src/to-error.js.map +1 -0
@@ -0,0 +1,197 @@
1
+ import { CommandScanner } from './command-scan';
2
+ /**
3
+ * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the
4
+ * force-to-root check share.
5
+ *
6
+ * WHY it has to exist at all: the shell cwd a PreToolUse hook is handed does not tell you which tree
7
+ * the command acts on. `cd` behaves TWO different ways, and both break a cwd-based guard:
8
+ *
9
+ * - IN THE MAIN SESSION ONLY, a `cd` that stays INSIDE the session's working directory PERSISTS to
10
+ * later calls. Claude Code documents this as a main-session property and states that "subagent
11
+ * sessions never carry over working directory changes" — measured true here, and the reason this
12
+ * rule must not be stated unconditionally to a reader who may be a subagent. So the cwd
13
+ * can be a subdirectory of the governed root, left there by an unrelated command several turns
14
+ * earlier — a relative path then resolves somewhere other than the root while still being in the
15
+ * governed tree.
16
+ * - A `cd` that LEAVES it is reset by the harness, which says so (`Shell cwd was reset to <root>`).
17
+ * So an agent working in a linked worktree is back in the primary clone by the next call and must
18
+ * write self-contained `cd <worktree> && …` commands — and the cwd the hook sees is the primary
19
+ * clone, not the worktree the command targets.
20
+ *
21
+ * (Measured on 2026-08-02: `cd backlog && pwd` → `…/backlog`, then a bare `pwd` in a FRESH call →
22
+ * still `…/backlog`. But `cd ../<linked-worktree> && pwd` → the worktree, then a bare `pwd` → back at
23
+ * the primary clone. An earlier version of this comment asserted `cd` never persists; that was the
24
+ * worktree case generalized. The conclusion below is unchanged — only the reason was wrong.)
25
+ *
26
+ * Either way a guard that reasons from the raw cwd judges the wrong tree. Three field sightings in
27
+ * one session:
28
+ * an `ls` of a path outside every repo blocked as "this branch is merged"; a version-drift cure
29
+ * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at
30
+ * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in
31
+ * the primary clone) the agent had been explicitly forbidden to run.
32
+ *
33
+ * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check
34
+ * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you
35
+ * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.
36
+ *
37
+ * Resolution, in order:
38
+ * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.
39
+ * 2. SAME REPO? `git rev-parse --git-common-dir` is identical for every checkout of one repo, so
40
+ * comparing it against the governed root's answer is the whole test. Different → FOREIGN (a
41
+ * nested clone under `repositories/**`), out of scope, hands-off. Not a git repo at all
42
+ * (`cd /tmp && …`) → OUTSIDE: the guards still run — an absolute path back into the repo must
43
+ * still be judged — but nothing the command names relative to `/tmp` is workspace content, which
44
+ * is what ContentReadScan uses `effectiveCwd` for.
45
+ * 3. WHICH CHECKOUT? `--show-toplevel` from `effectiveCwd`. A LINKED WORKTREE of the governed repo is
46
+ * MANAGED — it is the same project, just another checkout — so guards run, keyed on THAT tree's
47
+ * branch and its own state.
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.
69
+ *
70
+ * PLACEMENT IS NOT IDENTITY, and assuming it was is the bug this shape exists to prevent. classify()
71
+ * used to short-circuit on "is `effectiveCwd` inside the governed root?" and never ask git anything
72
+ * else — so an agent worktree, which Claude Code checks out INSIDE the repo at
73
+ * `<repo>/.claude/worktrees/agent-XXXX`, took that path, disagreed with `--show-toplevel`, and read as
74
+ * FOREIGN. `foreign` is ALLOW_EXEMPT in runner.ts: every bash guard silently off, and
75
+ * the worktree guard (which requires `kind === 'worktree'`) dead code, for exactly the worktrees
76
+ * the harness creates. A common-dir comparison answers the same for both placements, so there is no
77
+ * inside/outside case left to get wrong.
78
+ *
79
+ * WHY THE GIT DIRS AND NOT ONE OF THE OTHER RESOLVERS — state-dir.ts's own header makes this argument
80
+ * in full ("Why `--git-dir` / `--git-common-dir`, and not one of the existing services"); the short
81
+ * version is that `WorktreeService` is a repo-wide ENUMERATION that fails SOFT to `[]` (i.e. fails open
82
+ * into `foreign`, this bug) and `webpieces.config.json` walk-up is GOVERNANCE, not identity — it
83
+ * deliberately climbs past a nested clone's `.git` to the outer config (repo-root.spec.ts pins that),
84
+ * so identity built on it would hand the guards someone else's repo. This class asks DotWebpieces,
85
+ * whose `rev-parse` pair is memoized per directory per process — it is on the hook's blocking path.
86
+ */
87
+ export type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside' | 'missing';
88
+ /** Data-only (per CLAUDE.md, classes for data). */
89
+ export declare class EffectiveTree {
90
+ /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */
91
+ readonly shellCwd: string;
92
+ /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */
93
+ readonly effectiveCwd: string;
94
+ /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */
95
+ readonly root: string;
96
+ /** The root that owns webpieces.config.json — where config and excludePaths come from. */
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;
108
+ readonly kind: TreeKind;
109
+ /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
110
+ readonly redirected: boolean;
111
+ constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, mainRoot: string, kind: TreeKind);
112
+ }
113
+ export declare class EffectiveTreeResolver {
114
+ private readonly scanner;
115
+ private readonly shell;
116
+ constructor(scanner?: CommandScanner);
117
+ resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree;
118
+ /**
119
+ * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
120
+ *
121
+ * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,
122
+ * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing
123
+ * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the
124
+ * guards. `cd a && cd b && git …` resolves left to right, matching the shell.
125
+ *
126
+ * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the
127
+ * guards handle it: `echo "cd sub && git push"` is ONE opaque segment whose first word is `echo`,
128
+ * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.
129
+ */
130
+ effectiveCwd(command: string, shellCwd: string): string;
131
+ /**
132
+ * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a
133
+ * `cd` in it cannot be resolved, or null when there is nothing wrong.
134
+ *
135
+ * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it
136
+ * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which
137
+ * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:
138
+ *
139
+ * `cd "$DIR" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not
140
+ * exist, not the tree that was meant.
141
+ * `D=/x; cd "$D"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the
142
+ * leading run ends before the `cd` is reached.
143
+ * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it
144
+ * from the shell cwd and blocked it.
145
+ * `git push && cd /x` — the push already ran at the root, whatever the trailing
146
+ * `cd` says.
147
+ *
148
+ * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a
149
+ * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human
150
+ * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes
151
+ * — every command this rejects was already being judged from the shell cwd — so this trades a
152
+ * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.
153
+ *
154
+ * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole
155
+ * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.
156
+ *
157
+ * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,
158
+ * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and
159
+ * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the
160
+ * location still falls back to the shell cwd exactly as before.
161
+ */
162
+ misplacedCd(command: string): string | null;
163
+ /**
164
+ * "…and the `cd` you are looking at was FINE."
165
+ *
166
+ * Naming the offender is only half the cure. A command shaped `cd <abs literal> && … && cd sub && …`
167
+ * begins with a perfectly compliant `cd`, so a reader told "a `cd` must come FIRST" audits the FRONT
168
+ * of the line, finds a `cd` that IS first and IS literal, and concludes the guard is broken. That
169
+ * happened: a human read this deny, checked the leading `cd`, and started filing a bug against the
170
+ * rule (2026-08-11). Working out which of the two `cd`s was meant took a diff of the allowed retry
171
+ * against the denied attempts.
172
+ *
173
+ * `consumed` already marks the boundary exactly — `segments[0..consumed-1]` are the `cd`s that
174
+ * counted — so the accepted side costs nothing to state and is what stops the reader auditing the
175
+ * wrong one.
176
+ */
177
+ private acceptedNote;
178
+ /**
179
+ * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the
180
+ * command's OWN leading `cd` run REPLACED rather than prefixed.
181
+ *
182
+ * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and
183
+ * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`
184
+ * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on
185
+ * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally
186
+ * non-convergent, and observed in the field against an agent worktree.
187
+ *
188
+ * Only the LEADING run is dropped, because only the leading run moved where the command was judged.
189
+ * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by
190
+ * `misplacedCd`).
191
+ */
192
+ remedyAtRoot(root: string, command: string): string;
193
+ private withoutLeadingCds;
194
+ private classify;
195
+ /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
196
+ private isInside;
197
+ }
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EffectiveTreeResolver = exports.EffectiveTree = 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 rules_config_1 = require("@webpieces/rules-config");
8
+ const tooling_common_1 = require("@webpieces/tooling-common");
9
+ const command_scan_1 = require("./command-scan");
10
+ const shell_segment_scan_1 = require("./rules/shell-segment-scan");
11
+ /** Data-only (per CLAUDE.md, classes for data). */
12
+ class EffectiveTree {
13
+ /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */
14
+ shellCwd;
15
+ /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */
16
+ effectiveCwd;
17
+ /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */
18
+ root;
19
+ /** The root that owns webpieces.config.json — where config and excludePaths come from. */
20
+ governedRoot;
21
+ /**
22
+ * The PRIMARY clone's root — git's `<git-common-dir>/..`, the same answer from every checkout of the
23
+ * repo, and equal to `root` in the primary clone itself.
24
+ *
25
+ * NOT `governedRoot`. A linked worktree owns its own TRACKED `webpieces.config.json`, so for an agent
26
+ * resident in one the governed root is the WORKTREE — while the `node_modules` (and therefore the
27
+ * binary judging the call, the nx executors and the eslint plugin) still resolves by walking up to
28
+ * the primary clone. This field is that walk-up, asked of git instead of inferred.
29
+ */
30
+ mainRoot;
31
+ kind;
32
+ /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
33
+ redirected;
34
+ // eslint-disable-next-line @typescript-eslint/max-params -- five resolved paths plus the kind decided from them
35
+ constructor(shellCwd, effectiveCwd, root, governedRoot, mainRoot, kind) {
36
+ this.shellCwd = shellCwd;
37
+ this.effectiveCwd = effectiveCwd;
38
+ this.root = root;
39
+ this.governedRoot = governedRoot;
40
+ this.mainRoot = mainRoot;
41
+ this.kind = kind;
42
+ this.redirected = path.resolve(root) !== path.resolve(shellCwd);
43
+ }
44
+ }
45
+ exports.EffectiveTree = EffectiveTree;
46
+ class EffectiveTreeResolver {
47
+ scanner;
48
+ shell;
49
+ constructor(scanner = new command_scan_1.CommandScanner()) {
50
+ this.scanner = scanner;
51
+ this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
52
+ }
53
+ resolve(command, shellCwd, governedRoot) {
54
+ const effectiveCwd = this.effectiveCwd(command, shellCwd);
55
+ const kindAndRoot = this.classify(effectiveCwd, governedRoot);
56
+ return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.mainRoot, kindAndRoot.kind);
57
+ }
58
+ /**
59
+ * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
60
+ *
61
+ * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,
62
+ * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing
63
+ * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the
64
+ * guards. `cd a && cd b && git …` resolves left to right, matching the shell.
65
+ *
66
+ * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the
67
+ * guards handle it: `echo "cd sub && git push"` is ONE opaque segment whose first word is `echo`,
68
+ * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.
69
+ */
70
+ effectiveCwd(command, shellCwd) {
71
+ let effective = shellCwd;
72
+ for (const segment of this.scanner.commandSegments(command)) {
73
+ const words = this.shell.effectiveWords(segment);
74
+ if (words[0] !== 'cd' && words[0] !== 'pushd')
75
+ break;
76
+ if (words[1] !== undefined)
77
+ effective = path.resolve(effective, words[1]);
78
+ }
79
+ return effective;
80
+ }
81
+ /**
82
+ * Is this command's location UNAMBIGUOUS — or should it be rejected outright? Returns the reason a
83
+ * `cd` in it cannot be resolved, or null when there is nothing wrong.
84
+ *
85
+ * `cd <literal path> && <work>` is the ONE shape that moves where a command is judged, because it
86
+ * is the one shape effectiveCwd() can resolve. Everything else fell back to the shell cwd, which
87
+ * is SAFE (fails closed, nothing smuggled) but silent, and silent is what cost the time:
88
+ *
89
+ * `cd "$DIR" && git push` — `path.resolve(cwd, '$DIR')` is a directory that does not
90
+ * exist, not the tree that was meant.
91
+ * `D=/x; cd "$D"; git push` — a bare `VAR=value` segment tokenizes to NO words, so the
92
+ * leading run ends before the `cd` is reached.
93
+ * `git fetch && cd /x && git push` — bash really does run the push in /x; the guard judged it
94
+ * from the shell cwd and blocked it.
95
+ * `git push && cd /x` — the push already ran at the root, whatever the trailing
96
+ * `cd` says.
97
+ *
98
+ * Naming these in the block MESSAGE (the previous two PRs) helped, but left the agent to notice a
99
+ * paragraph on an otherwise-normal block. Rejecting is the simpler contract and the one the human
100
+ * asked for: ONE legal shape, everything else refused with the fix spelled out. No verdict changes
101
+ * — every command this rejects was already being judged from the shell cwd — so this trades a
102
+ * silent misdirect for a loud one-line rule, and deletes the near-miss taxonomy entirely.
103
+ *
104
+ * Expanding `$VAR` here is still the fix NOT taken. The resolver decides ONE location for a whole
105
+ * line, so a `cd` that counts retroactively is exactly the `… && cd <exempt-tree>` scope escape.
106
+ *
107
+ * A command containing a HEREDOC (`<<`) is exempt: CommandScanner does not model heredoc bodies,
108
+ * so a commit message or doc that merely CONTAINS `cd /x && …` tokenizes as if it were code, and
109
+ * rejecting that would block ordinary writing. Skipping the rejection cannot open a hole — the
110
+ * location still falls back to the shell cwd exactly as before.
111
+ */
112
+ misplacedCd(command) {
113
+ if (HEREDOC.test(command))
114
+ return null;
115
+ const segments = this.scanner.commandSegments(command).map((segment) => this.shell.effectiveWords(segment));
116
+ // The leading run effectiveCwd() actually consumed — a `cd` at or after this index did not count.
117
+ let consumed = 0;
118
+ while (consumed < segments.length && isCd(segments[consumed]))
119
+ consumed++;
120
+ for (let i = 0; i < segments.length; i++) {
121
+ if (!isCd(segments[i]))
122
+ continue;
123
+ const offender = `\`${segments[i].join(' ')}\``;
124
+ if (i >= consumed) {
125
+ return segments.slice(0, i).some(isRealCommand)
126
+ ? `${offender} comes after another command — a \`cd\` only counts at the FRONT of the line${this.acceptedNote(segments, consumed)}`
127
+ : `${offender} — a \`VAR=…\` assignment precedes it, which ends the scan`;
128
+ }
129
+ const target = segments[i][1];
130
+ if (target !== undefined && VARIABLE_TARGET.test(target)) {
131
+ return `${offender} — its target is not a literal path (a \`$VAR\`, \`~\` or \`$(…)\` the guard cannot expand)`;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ /**
137
+ * "…and the `cd` you are looking at was FINE."
138
+ *
139
+ * Naming the offender is only half the cure. A command shaped `cd <abs literal> && … && cd sub && …`
140
+ * begins with a perfectly compliant `cd`, so a reader told "a `cd` must come FIRST" audits the FRONT
141
+ * of the line, finds a `cd` that IS first and IS literal, and concludes the guard is broken. That
142
+ * happened: a human read this deny, checked the leading `cd`, and started filing a bug against the
143
+ * rule (2026-08-11). Working out which of the two `cd`s was meant took a diff of the allowed retry
144
+ * against the denied attempts.
145
+ *
146
+ * `consumed` already marks the boundary exactly — `segments[0..consumed-1]` are the `cd`s that
147
+ * counted — so the accepted side costs nothing to state and is what stops the reader auditing the
148
+ * wrong one.
149
+ */
150
+ acceptedNote(segments, consumed) {
151
+ if (consumed === 0)
152
+ return '';
153
+ if (consumed === 1)
154
+ return ` (the leading \`${segments[0].join(' ')}\` WAS accepted — this is a SECOND \`cd\`)`;
155
+ return ` (the leading ${consumed} \`cd\`s WERE accepted — this is a later one)`;
156
+ }
157
+ /**
158
+ * The remedy for a command judged in the wrong directory — `cd '<root>' && <the work>`, with the
159
+ * command's OWN leading `cd` run REPLACED rather than prefixed.
160
+ *
161
+ * A block's remedy must not leave the block's condition true. `atRoot()` alone prefixes, and
162
+ * `effectiveCwd()` resolves the leading run of `cd`s LEFT TO RIGHT — so prefixing `cd '<root>' &&`
163
+ * onto `cd <elsewhere> && git status` still lands in `<elsewhere>`, the identical block fires on
164
+ * the remedy, and the retry prints it with the prefix doubled, then tripled. Structurally
165
+ * non-convergent, and observed in the field against an agent worktree.
166
+ *
167
+ * Only the LEADING run is dropped, because only the leading run moved where the command was judged.
168
+ * A mid-line `cd` is part of the work and is carried through untouched (it is separately rejected by
169
+ * `misplacedCd`).
170
+ */
171
+ remedyAtRoot(root, command) {
172
+ return (0, rules_config_1.atRoot)(root, this.withoutLeadingCds(command));
173
+ }
174
+ withoutLeadingCds(command) {
175
+ let rest = command;
176
+ for (const segment of this.scanner.commandSegments(command)) {
177
+ const words = this.shell.effectiveWords(segment);
178
+ if (words[0] !== 'cd' && words[0] !== 'pushd')
179
+ break;
180
+ const at = rest.indexOf(segment);
181
+ if (at < 0)
182
+ break;
183
+ rest = rest.slice(at + segment.length).replace(LEADING_SEPARATOR, '');
184
+ }
185
+ // A line that is NOTHING but `cd`s has no work to steer; hand it back whole rather than emit an
186
+ // empty remedy.
187
+ return rest.trim() === '' ? command : rest.trim();
188
+ }
189
+ classify(effectiveCwd, governedRoot) {
190
+ // GONE, not merely un-gitted. git answers `null` for both "not a repo" and "no such directory",
191
+ // and collapsing the two is what made a reaped worktree read as an ordinary subdirectory of the
192
+ // governed root — with a remedy that `cd`s straight back into the deleted path. One statSync,
193
+ // ahead of the git calls, keeps them apart. The root is the GOVERNED root because that is the
194
+ // only tree left to steer anyone to.
195
+ if (!fs.existsSync(effectiveCwd))
196
+ return new TreeClassification('missing', governedRoot, governedRoot);
197
+ const dirs = tooling_common_1.dotWebpieces.gitDirs(effectiveCwd);
198
+ // Not a git repo at all. Inside the governed tree that can only be a directory git declined to
199
+ // answer for, so it stays `primary` exactly as before; outside it is the `cd /tmp && …` case.
200
+ if (dirs === null) {
201
+ return this.isInside(effectiveCwd, governedRoot)
202
+ ? new TreeClassification('primary', governedRoot, governedRoot)
203
+ : new TreeClassification('outside', governedRoot, governedRoot);
204
+ }
205
+ const treeRoot = tooling_common_1.dotWebpieces.treeRoot(effectiveCwd) ?? governedRoot;
206
+ // `<git-common-dir>/..`, off the SAME memoized rev-parse pair `gitDirs` just answered with — so
207
+ // this costs no extra process, which matters on the hook's blocking path.
208
+ const mainRoot = tooling_common_1.dotWebpieces.primaryRoot(effectiveCwd);
209
+ const ours = tooling_common_1.dotWebpieces.gitDirs(governedRoot);
210
+ // ONE test for "is this our repo": the shared git dir. It is identical for every checkout of one
211
+ // repo and different for a nested clone, wherever either happens to sit on disk.
212
+ if (ours === null || !sameDir(dirs.commonDir, ours.commonDir)) {
213
+ return new TreeClassification('foreign', treeRoot, mainRoot);
214
+ }
215
+ // GIT DECIDES, and nothing else. `isLinkedWorktree` is `--git-dir !== --git-common-dir`, an
216
+ // answer about the CHECKOUT — so a worktree is `worktree` whether the command was typed from the
217
+ // primary clone (`cd <wt> && …`) or by an agent living in it. The old extra clause
218
+ // (`|| sameDir(treeRoot, governedRoot)`) made a resident agent's OWN tree read `primary`,
219
+ // silently retiring row 8 for every worktree-isolated subagent — see this module's header.
220
+ if (!dirs.isLinkedWorktree)
221
+ return new TreeClassification('primary', treeRoot, mainRoot);
222
+ return new TreeClassification('worktree', treeRoot, mainRoot);
223
+ }
224
+ /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
225
+ isInside(dir, root) {
226
+ const relative = path.relative(path.resolve(root), path.resolve(dir));
227
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
228
+ }
229
+ }
230
+ exports.EffectiveTreeResolver = EffectiveTreeResolver;
231
+ // The two segment shapes unresolvedCd() sorts by. A segment with NO words at all is a bare `VAR=value`
232
+ // assignment (CommandScanner strips assignments as command prefixes), which is neither.
233
+ // webpieces-disable no-function-outside-class -- pure predicates over one segment's words, siblings of the module-scope helpers below
234
+ function isCd(words) {
235
+ return words[0] === 'cd' || words[0] === 'pushd';
236
+ }
237
+ // webpieces-disable no-function-outside-class -- sibling of isCd()
238
+ function isRealCommand(words) {
239
+ return words.length > 0 && !isCd(words);
240
+ }
241
+ // `<<EOF` / `<<'EOF'` / `<<-EOF`. NOT `<` or `<<<` alone — a herestring has no multi-line body, so it
242
+ // cannot carry prose that tokenizes as commands.
243
+ const HEREDOC = /<<-?\s*['"]?[A-Za-z_]/;
244
+ // A `cd` target that is not a literal path: `$DIR`, `${DIR}`, `~`, or a `$(…)`/backtick substitution.
245
+ // `~` is here because path.resolve() does not expand it either — the shell does, and the hook never
246
+ // sees a shell.
247
+ const VARIABLE_TARGET = /[$`]|^~/;
248
+ // The separator joining a leading `cd` to what follows it, stripped when withoutLeadingCds() drops the
249
+ // `cd`. `&&`, `||`, `;` and a bare newline are the shapes CommandScanner splits a leading run on.
250
+ const LEADING_SEPARATOR = /^\s*(?:&&|\|\||;|\n)\s*/;
251
+ /** Data-only carrier for the three values classify() decides together. */
252
+ class TreeClassification {
253
+ kind;
254
+ root;
255
+ /** The primary clone behind `root` — see EffectiveTree.mainRoot for why it is not `governedRoot`. */
256
+ mainRoot;
257
+ constructor(kind, root, mainRoot) {
258
+ this.kind = kind;
259
+ this.root = root;
260
+ this.mainRoot = mainRoot;
261
+ }
262
+ }
263
+ // Two absolute paths naming the same directory. There is no filesystem access here — both sides are
264
+ // already git's own answers or a resolved root.
265
+ // webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers
266
+ function sameDir(a, b) {
267
+ return path.resolve(a) === path.resolve(b);
268
+ }
269
+ //# sourceMappingURL=effective-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/hook-runtime/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 } from '@webpieces/rules-config';\nimport { dotWebpieces } from '@webpieces/tooling-common';\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 const offender = `\\`${segments[i].join(' ')}\\``;\n if (i >= consumed) {\n return segments.slice(0, i).some(isRealCommand)\n ? `${offender} comes after another command — a \\`cd\\` only counts at the FRONT of the line${this.acceptedNote(segments, consumed)}`\n : `${offender} — 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 `${offender} — its target is not a literal path (a \\`$VAR\\`, \\`~\\` or \\`$(…)\\` the guard cannot expand)`;\n }\n }\n return null;\n }\n\n /**\n * \"…and the `cd` you are looking at was FINE.\"\n *\n * Naming the offender is only half the cure. A command shaped `cd <abs literal> && … && cd sub && …`\n * begins with a perfectly compliant `cd`, so a reader told \"a `cd` must come FIRST\" audits the FRONT\n * of the line, finds a `cd` that IS first and IS literal, and concludes the guard is broken. That\n * happened: a human read this deny, checked the leading `cd`, and started filing a bug against the\n * rule (2026-08-11). Working out which of the two `cd`s was meant took a diff of the allowed retry\n * against the denied attempts.\n *\n * `consumed` already marks the boundary exactly — `segments[0..consumed-1]` are the `cd`s that\n * counted — so the accepted side costs nothing to state and is what stops the reader auditing the\n * wrong one.\n */\n private acceptedNote(segments: readonly (readonly string[])[], consumed: number): string {\n if (consumed === 0) return '';\n if (consumed === 1) return ` (the leading \\`${segments[0].join(' ')}\\` WAS accepted — this is a SECOND \\`cd\\`)`;\n return ` (the leading ${consumed} \\`cd\\`s WERE accepted — this is a later one)`;\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// Two absolute paths naming the same directory. There is no filesystem access here — both sides are\n// already git's own answers or a resolved root.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its pure helpers\nfunction sameDir(a: string, b: string): boolean {\n return path.resolve(a) === path.resolve(b);\n}\n"]}
@@ -0,0 +1,23 @@
1
+ import { ExcludePaths } from '@webpieces/rules-config';
2
+ import { EffectiveTree } from './effective-tree';
3
+ import { GovernedPath } from './target-tree';
4
+ import type { Rule } from './types';
5
+ /**
6
+ * L1's FILTER — which rules have jurisdiction over one path — and the one helper that builds its
7
+ * argument for the bash surface.
8
+ *
9
+ * Lifted out of runner.ts, which the file-size rule had outgrown. It is a natural seam rather than a
10
+ * page-count fix: this is the only place `excludePaths` and the hard-coded `.webpieces/` skip are
11
+ * consulted, and runner.ts is otherwise the four tool ENTRY POINTS and their L0/L1 preamble.
12
+ */
13
+ export declare function filterByExcludedPaths(rules: readonly Rule[], governed: GovernedPath, ex: ExcludePaths): readonly Rule[];
14
+ /**
15
+ * The bash surface's GovernedPath, built from the tree EffectiveTreeResolver has already classified —
16
+ * so it costs no extra git call on the hook's blocking path.
17
+ *
18
+ * Both spellings of the command's effective cwd: relative to the governed root for the excludePaths
19
+ * globs, and relative to the OWNING tree for the `.webpieces/` skip — which for
20
+ * `cd <worktree>/.webpieces && …` is the worktree's state dir, not the primary's. Both are '' for a
21
+ * command with no leading `cd`, which matches no glob and is not the state dir.
22
+ */
23
+ export declare function bashGovernedPath(tree: EffectiveTree, workspaceRoot: string): GovernedPath;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.filterByExcludedPaths = filterByExcludedPaths;
4
+ exports.bashGovernedPath = bashGovernedPath;
5
+ const tslib_1 = require("tslib");
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const rules_config_1 = require("@webpieces/rules-config");
8
+ const glob_1 = require("./glob");
9
+ const target_tree_1 = require("./target-tree");
10
+ /**
11
+ * L1's FILTER — which rules have jurisdiction over one path — and the one helper that builds its
12
+ * argument for the bash surface.
13
+ *
14
+ * Lifted out of runner.ts, which the file-size rule had outgrown. It is a natural seam rather than a
15
+ * page-count fix: this is the only place `excludePaths` and the hard-coded `.webpieces/` skip are
16
+ * consulted, and runner.ts is otherwise the four tool ENTRY POINTS and their L0/L1 preamble.
17
+ */
18
+ // Drop every rule excluded for this path (webpieces.config.json → excludePaths). ONE glob list: a path
19
+ // listed there is hands-off for code-style rules and file-scoped guards alike, because webpieces either
20
+ // governs a path or it does not. Per-rule carve-outs live in the rule's own `excludePaths`.
21
+ // This is L1's FILTER (not a table row) — see guards/L1-location.md.
22
+ //
23
+ // It takes a GovernedPath and not a bare string because the two questions below are asked against
24
+ // DIFFERENT roots, and answering both from the governed-root spelling is issue #851's secondary defect:
25
+ // the excludePaths globs are authored against the governed root, while `.webpieces/` is the state dir OF
26
+ // A TREE and a linked worktree has its own. One argument carrying both spellings is what stops a caller
27
+ // picking the wrong one; see GovernedPath.
28
+ // webpieces-disable no-function-outside-class -- L1's filter, a pure predicate over one path and one glob list; it is called from four module-scope entry points in runner.ts and a class here would be a namespace with no state
29
+ function filterByExcludedPaths(rules, governed, ex) {
30
+ // webpieces' OWN gitignored state dir is never governed, config or no config. Ahead of the list on
31
+ // purpose — see isWebpiecesStateDir for why it is code and not a seeded glob. Asked about the
32
+ // OWNING tree's spelling, so `<primary>/.claude/worktrees/agent-X/.webpieces/...` is exempt for the
33
+ // identical reason `<primary>/.webpieces/...` is: nothing under it is tracked, reviewable or
34
+ // revertable in the tree it belongs to.
35
+ if ((0, rules_config_1.isWebpiecesStateDir)(governed.treeRelativePath))
36
+ return [];
37
+ if (ex.paths.some((p) => (0, glob_1.globMatches)(p, governed.relativePath)))
38
+ return [];
39
+ return rules;
40
+ }
41
+ /**
42
+ * The bash surface's GovernedPath, built from the tree EffectiveTreeResolver has already classified —
43
+ * so it costs no extra git call on the hook's blocking path.
44
+ *
45
+ * Both spellings of the command's effective cwd: relative to the governed root for the excludePaths
46
+ * globs, and relative to the OWNING tree for the `.webpieces/` skip — which for
47
+ * `cd <worktree>/.webpieces && …` is the worktree's state dir, not the primary's. Both are '' for a
48
+ * command with no leading `cd`, which matches no glob and is not the state dir.
49
+ */
50
+ // webpieces-disable no-function-outside-class -- the bash-side constructor for the value above, beside it
51
+ function bashGovernedPath(tree, workspaceRoot) {
52
+ return new target_tree_1.GovernedPath(path.relative(workspaceRoot, tree.effectiveCwd), path.relative(tree.root, tree.effectiveCwd), tree.root);
53
+ }
54
+ //# sourceMappingURL=excluded-paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"excluded-paths.js","sourceRoot":"","sources":["../../../../../../packages/tooling/hook-runtime/src/core/excluded-paths.ts"],"names":[],"mappings":";;AA6BA,sDASC;AAYD,4CAMC;;AAxDD,mDAA6B;AAE7B,0DAA4E;AAG5E,iCAAqC;AACrC,+CAA6C;AAG7C;;;;;;;GAOG;AAEH,uGAAuG;AACvG,wGAAwG;AACxG,4FAA4F;AAC5F,qEAAqE;AACrE,EAAE;AACF,kGAAkG;AAClG,wGAAwG;AACxG,yGAAyG;AACzG,wGAAwG;AACxG,2CAA2C;AAC3C,kOAAkO;AAClO,SAAgB,qBAAqB,CAAC,KAAsB,EAAE,QAAsB,EAAE,EAAgB;IAClG,mGAAmG;IACnG,8FAA8F;IAC9F,oGAAoG;IACpG,6FAA6F;IAC7F,wCAAwC;IACxC,IAAI,IAAA,kCAAmB,EAAC,QAAQ,CAAC,gBAAgB,CAAC;QAAE,OAAO,EAAE,CAAC;IAC9D,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAS,EAAW,EAAE,CAAC,IAAA,kBAAW,EAAC,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC,CAAC;QAAE,OAAO,EAAE,CAAC;IAC5F,OAAO,KAAK,CAAC;AACjB,CAAC;AAED;;;;;;;;GAQG;AACH,0GAA0G;AAC1G,SAAgB,gBAAgB,CAAC,IAAmB,EAAE,aAAqB;IACvE,OAAO,IAAI,0BAAY,CACnB,IAAI,CAAC,QAAQ,CAAC,aAAa,EAAE,IAAI,CAAC,YAAY,CAAC,EAC/C,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,EAC3C,IAAI,CAAC,IAAI,CACZ,CAAC;AACN,CAAC","sourcesContent":["import * as path from 'path';\n\nimport { ExcludePaths, isWebpiecesStateDir } from '@webpieces/rules-config';\n\nimport { EffectiveTree } from './effective-tree';\nimport { globMatches } from './glob';\nimport { GovernedPath } from './target-tree';\nimport type { Rule } from './types';\n\n/**\n * L1's FILTER — which rules have jurisdiction over one path — and the one helper that builds its\n * argument for the bash surface.\n *\n * Lifted out of runner.ts, which the file-size rule had outgrown. It is a natural seam rather than a\n * page-count fix: this is the only place `excludePaths` and the hard-coded `.webpieces/` skip are\n * consulted, and runner.ts is otherwise the four tool ENTRY POINTS and their L0/L1 preamble.\n */\n\n// Drop every rule excluded for this path (webpieces.config.json → excludePaths). ONE glob list: a path\n// listed there is hands-off for code-style rules and file-scoped guards alike, because webpieces either\n// governs a path or it does not. Per-rule carve-outs live in the rule's own `excludePaths`.\n// This is L1's FILTER (not a table row) — see guards/L1-location.md.\n//\n// It takes a GovernedPath and not a bare string because the two questions below are asked against\n// DIFFERENT roots, and answering both from the governed-root spelling is issue #851's secondary defect:\n// the excludePaths globs are authored against the governed root, while `.webpieces/` is the state dir OF\n// A TREE and a linked worktree has its own. One argument carrying both spellings is what stops a caller\n// picking the wrong one; see GovernedPath.\n// webpieces-disable no-function-outside-class -- L1's filter, a pure predicate over one path and one glob list; it is called from four module-scope entry points in runner.ts and a class here would be a namespace with no state\nexport function filterByExcludedPaths(rules: readonly Rule[], governed: GovernedPath, ex: ExcludePaths): readonly Rule[] {\n // webpieces' OWN gitignored state dir is never governed, config or no config. Ahead of the list on\n // purpose — see isWebpiecesStateDir for why it is code and not a seeded glob. Asked about the\n // OWNING tree's spelling, so `<primary>/.claude/worktrees/agent-X/.webpieces/...` is exempt for the\n // identical reason `<primary>/.webpieces/...` is: nothing under it is tracked, reviewable or\n // revertable in the tree it belongs to.\n if (isWebpiecesStateDir(governed.treeRelativePath)) return [];\n if (ex.paths.some((p: string): boolean => globMatches(p, governed.relativePath))) return [];\n return rules;\n}\n\n/**\n * The bash surface's GovernedPath, built from the tree EffectiveTreeResolver has already classified —\n * so it costs no extra git call on the hook's blocking path.\n *\n * Both spellings of the command's effective cwd: relative to the governed root for the excludePaths\n * globs, and relative to the OWNING tree for the `.webpieces/` skip — which for\n * `cd <worktree>/.webpieces && …` is the worktree's state dir, not the primary's. Both are '' for a\n * command with no leading `cd`, which matches no glob and is not the state dir.\n */\n// webpieces-disable no-function-outside-class -- the bash-side constructor for the value above, beside it\nexport function bashGovernedPath(tree: EffectiveTree, workspaceRoot: string): GovernedPath {\n return new GovernedPath(\n path.relative(workspaceRoot, tree.effectiveCwd),\n path.relative(tree.root, tree.effectiveCwd),\n tree.root,\n );\n}\n"]}