@webpieces/ai-hook-rules 0.4.638 → 0.4.640
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.640",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.640"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -1,46 +1,5 @@
|
|
|
1
1
|
import { EffectiveTree } from './effective-tree';
|
|
2
2
|
import { VersionQuartet } from './webpieces-versions';
|
|
3
|
-
/**
|
|
4
|
-
* L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.
|
|
5
|
-
*
|
|
6
|
-
* ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────
|
|
7
|
-
* The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every
|
|
8
|
-
* tree. That is not a new imposition — it is what was always happening, because a linked worktree has no
|
|
9
|
-
* `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now
|
|
10
|
-
* says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT
|
|
11
|
-
* release is being linted, validated and built by a release it never asked for.
|
|
12
|
-
*
|
|
13
|
-
* This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the
|
|
14
|
-
* replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking
|
|
15
|
-
* (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent
|
|
16
|
-
* whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone
|
|
17
|
-
* (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.
|
|
18
|
-
*
|
|
19
|
-
* ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────
|
|
20
|
-
* A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent
|
|
21
|
-
* is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,
|
|
22
|
-
* nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and
|
|
23
|
-
* a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message
|
|
24
|
-
* that names all the versions and all their files, is the whole point. Any future proposal to soften
|
|
25
|
-
* this to a warning must answer: what stops the five-install loop instead?
|
|
26
|
-
*
|
|
27
|
-
* ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────
|
|
28
|
-
* Two structurally independent escapes, and neither depends on an allowlist regex staying in step:
|
|
29
|
-
* 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never
|
|
30
|
-
* reaches this guard at all. No allowlist entry can break it because none is involved.
|
|
31
|
-
* 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner
|
|
32
|
-
* the same way `webpieces.config.json` already is, so the cure is typable from inside the block.
|
|
33
|
-
* Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.
|
|
34
|
-
*
|
|
35
|
-
* ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────
|
|
36
|
-
* The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload
|
|
37
|
-
* cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.
|
|
38
|
-
* So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against
|
|
39
|
-
* `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's
|
|
40
|
-
* `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and
|
|
41
|
-
* it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own
|
|
42
|
-
* install, a main clone on 0.4.616, and not one word from this guard.
|
|
43
|
-
*/
|
|
44
3
|
export declare class VersionSyncGuard {
|
|
45
4
|
private readonly inspection;
|
|
46
5
|
private readonly versions;
|
|
@@ -81,6 +40,41 @@ export declare class VersionSyncGuard {
|
|
|
81
40
|
/** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */
|
|
82
41
|
quartetFor(tree: EffectiveTree): VersionQuartet;
|
|
83
42
|
private report;
|
|
43
|
+
/**
|
|
44
|
+
* The cure list, which is NOT the same list in both directions.
|
|
45
|
+
*
|
|
46
|
+
* The ordinary skew is two trees sitting on different commits of main, and there `git pull` both +
|
|
47
|
+
* `pnpm install` genuinely converges them — the pin is tracked, so the same hash gives the same
|
|
48
|
+
* version. That cure is WRONG, and worse than useless, when the branch bumped the pin ON PURPOSE:
|
|
49
|
+
* pulling would revert the deliverable, and an install cannot move a pin in either tree. Printing
|
|
50
|
+
* the git cure first in that case is what sent a real upgrade agent round the loop below.
|
|
51
|
+
*/
|
|
52
|
+
private fixLines;
|
|
53
|
+
/**
|
|
54
|
+
* THE SUBAGENT CANNOT REACH THE MAIN TREE, so the message it is handed has to be the message it
|
|
55
|
+
* FORWARDS. This used to be one sentence — "report to your coordinator that one of you must move to
|
|
56
|
+
* the other's version" — with no command, no direction and nothing pasteable, and the result was a
|
|
57
|
+
* subagent that correctly diagnosed the block, correctly escalated, and handed its coordinator a
|
|
58
|
+
* request too vague to act on. Worse, the obvious guess ("ask the coordinator to run `pnpm install`
|
|
59
|
+
* in main") is a NO-OP on a bump: it reinstalls main's own pin and nothing moves.
|
|
60
|
+
*
|
|
61
|
+
* So the escalation is rendered as literal text to forward, with the versions and the direction
|
|
62
|
+
* already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.
|
|
63
|
+
*/
|
|
64
|
+
private escalationLines;
|
|
65
|
+
/**
|
|
66
|
+
* Did THIS BRANCH change the pin, as opposed to the two trees having drifted onto different commits?
|
|
67
|
+
*
|
|
68
|
+
* Only answerable now that both pin legs actually resolve — before the catalog reader followed YAML
|
|
69
|
+
* anchors they both read null on the repos that pin via an anchor, so every skew looked alike and the
|
|
70
|
+
* report could only ever print the one generic cure.
|
|
71
|
+
*
|
|
72
|
+
* Two git spawns worst case, on the BLOCK path only (this is never reached on an allow), and
|
|
73
|
+
* best-effort: a git failure answers "not a deliberate bump", which falls back to the generic cure
|
|
74
|
+
* that was the only text this report had before.
|
|
75
|
+
*/
|
|
76
|
+
private isDeliberateBump;
|
|
77
|
+
private touchesWorkspaceFile;
|
|
84
78
|
private versionLines;
|
|
85
79
|
private show;
|
|
86
80
|
}
|
package/src/core/version-sync.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.VersionSyncGuard = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
5
6
|
const path = tslib_1.__importStar(require("path"));
|
|
6
7
|
const read_only_inspection_1 = require("./read-only-inspection");
|
|
7
8
|
const webpieces_versions_1 = require("./webpieces-versions");
|
|
@@ -46,6 +47,8 @@ const webpieces_versions_1 = require("./webpieces-versions");
|
|
|
46
47
|
* it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own
|
|
47
48
|
* install, a main clone on 0.4.616, and not one word from this guard.
|
|
48
49
|
*/
|
|
50
|
+
/** The one file a pin lives in — named here so the "did this branch bump it" check cannot drift from it. */
|
|
51
|
+
const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';
|
|
49
52
|
class VersionSyncGuard {
|
|
50
53
|
inspection = new read_only_inspection_1.ReadOnlyInspectionScan();
|
|
51
54
|
versions = new webpieces_versions_1.WebpiecesVersions();
|
|
@@ -119,6 +122,7 @@ class VersionSyncGuard {
|
|
|
119
122
|
// if each one argues its case. State the skew, show every version WITH its file, give the git cure
|
|
120
123
|
// first, then the two structural escapes, then what is still allowed.
|
|
121
124
|
report(tree, quartet) {
|
|
125
|
+
const bump = this.isDeliberateBump(tree, quartet);
|
|
122
126
|
return [
|
|
123
127
|
`❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,
|
|
124
128
|
'',
|
|
@@ -127,6 +131,39 @@ class VersionSyncGuard {
|
|
|
127
131
|
` Whichever tree's hooks are live, one of these two releases lints, validates and builds`,
|
|
128
132
|
` this worktree — and it may be the one this manifest does not ask for.`,
|
|
129
133
|
'',
|
|
134
|
+
...this.fixLines(tree, quartet, bump),
|
|
135
|
+
'',
|
|
136
|
+
...this.escalationLines(tree, quartet, bump),
|
|
137
|
+
'',
|
|
138
|
+
` STILL ALLOWED HERE: every Read, read-only inspection, \`pnpm install\`, \`git pull\`/\`fetch\`,`,
|
|
139
|
+
` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,
|
|
140
|
+
` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,
|
|
141
|
+
` session's own governor.`,
|
|
142
|
+
].join('\n');
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The cure list, which is NOT the same list in both directions.
|
|
146
|
+
*
|
|
147
|
+
* The ordinary skew is two trees sitting on different commits of main, and there `git pull` both +
|
|
148
|
+
* `pnpm install` genuinely converges them — the pin is tracked, so the same hash gives the same
|
|
149
|
+
* version. That cure is WRONG, and worse than useless, when the branch bumped the pin ON PURPOSE:
|
|
150
|
+
* pulling would revert the deliverable, and an install cannot move a pin in either tree. Printing
|
|
151
|
+
* the git cure first in that case is what sent a real upgrade agent round the loop below.
|
|
152
|
+
*/
|
|
153
|
+
fixLines(tree, quartet, bump) {
|
|
154
|
+
if (bump) {
|
|
155
|
+
return [
|
|
156
|
+
` THIS BRANCH BUMPED THE PIN ON PURPOSE (${this.show(quartet.main.pinned).trim()} → ${this.show(quartet.worktree.pinned).trim()}), so the usual cures do NOT apply:`,
|
|
157
|
+
` • \`pnpm install\` cannot help in EITHER tree — an install materializes a pin, never moves one.`,
|
|
158
|
+
` • \`git pull\` here would revert the bump, which is the whole deliverable.`,
|
|
159
|
+
` • Wiping this tree's node_modules does NOT help — the two PINS still disagree, and the`,
|
|
160
|
+
` L0 drift guard blocks in this guard's place.`,
|
|
161
|
+
` Two ways out, and BOTH need the main tree:`,
|
|
162
|
+
` 1. Redo this task in the MAIN tree — a version bump cannot be done in a worktree at all.`,
|
|
163
|
+
` 2. Or raise the MAIN tree's pin to ${this.show(quartet.worktree.pinned).trim()} and \`pnpm install\` there, then continue here.`,
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
return [
|
|
130
167
|
` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,
|
|
131
168
|
` 1. \`git -C ${tree.mainRoot} pull\` and \`git -C ${tree.root} pull\` onto the same main,`,
|
|
132
169
|
` then \`pnpm install\` in each tree that has a node_modules. A worktree MAY have its`,
|
|
@@ -135,13 +172,56 @@ class VersionSyncGuard {
|
|
|
135
172
|
` 3. Or, if this tree genuinely needs a DIFFERENT version, use a separate CLONE, not a`,
|
|
136
173
|
` worktree: a clone gets its own governance. (This is the answer to "I need a different`,
|
|
137
174
|
` version", never to "I need to install here" — installing here is fine.)`,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* THE SUBAGENT CANNOT REACH THE MAIN TREE, so the message it is handed has to be the message it
|
|
179
|
+
* FORWARDS. This used to be one sentence — "report to your coordinator that one of you must move to
|
|
180
|
+
* the other's version" — with no command, no direction and nothing pasteable, and the result was a
|
|
181
|
+
* subagent that correctly diagnosed the block, correctly escalated, and handed its coordinator a
|
|
182
|
+
* request too vague to act on. Worse, the obvious guess ("ask the coordinator to run `pnpm install`
|
|
183
|
+
* in main") is a NO-OP on a bump: it reinstalls main's own pin and nothing moves.
|
|
184
|
+
*
|
|
185
|
+
* So the escalation is rendered as literal text to forward, with the versions and the direction
|
|
186
|
+
* already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.
|
|
187
|
+
*/
|
|
188
|
+
escalationLines(tree, quartet, bump) {
|
|
189
|
+
const ask = bump
|
|
190
|
+
? [
|
|
191
|
+
` > A \`pnpm install\` in main will NOT fix this — main's PIN has to move. Pick one:`,
|
|
192
|
+
` > (a) I redo this task in the MAIN tree (a version bump cannot be done in a worktree), or`,
|
|
193
|
+
` > (b) you raise main's catalog pin to ${this.show(quartet.worktree.pinned).trim()} and \`pnpm install\` there, and I continue here.`,
|
|
194
|
+
]
|
|
195
|
+
: [` > Please \`git -C ${tree.mainRoot} pull\` then \`pnpm install\` there, so both trees are on`, ` > the same release. I cannot reach that tree from here.`];
|
|
196
|
+
return [
|
|
197
|
+
` SUBAGENT? You cannot fix the main tree from here. Forward this to your coordinator verbatim:`,
|
|
198
|
+
` > My worktree ${tree.root} is on @webpieces ${this.show(quartet.worktree.pinned).trim()};`,
|
|
199
|
+
` > the main tree ${tree.mainRoot} is on ${this.show(quartet.main.pinned).trim()}.`,
|
|
200
|
+
...ask,
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Did THIS BRANCH change the pin, as opposed to the two trees having drifted onto different commits?
|
|
205
|
+
*
|
|
206
|
+
* Only answerable now that both pin legs actually resolve — before the catalog reader followed YAML
|
|
207
|
+
* anchors they both read null on the repos that pin via an anchor, so every skew looked alike and the
|
|
208
|
+
* report could only ever print the one generic cure.
|
|
209
|
+
*
|
|
210
|
+
* Two git spawns worst case, on the BLOCK path only (this is never reached on an allow), and
|
|
211
|
+
* best-effort: a git failure answers "not a deliberate bump", which falls back to the generic cure
|
|
212
|
+
* that was the only text this report had before.
|
|
213
|
+
*/
|
|
214
|
+
isDeliberateBump(tree, quartet) {
|
|
215
|
+
if (quartet.main.pinned === null || quartet.worktree.pinned === null)
|
|
216
|
+
return false;
|
|
217
|
+
if (quartet.main.pinned === quartet.worktree.pinned)
|
|
218
|
+
return false;
|
|
219
|
+
return this.touchesWorkspaceFile(tree.root, ['status', '--porcelain', '--', WORKSPACE_MANIFEST])
|
|
220
|
+
|| this.touchesWorkspaceFile(tree.root, ['diff', '--name-only', 'origin/main...HEAD', '--', WORKSPACE_MANIFEST]);
|
|
221
|
+
}
|
|
222
|
+
touchesWorkspaceFile(root, args) {
|
|
223
|
+
const result = (0, child_process_1.spawnSync)('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
224
|
+
return result.status === 0 && (result.stdout ?? '').trim() !== '';
|
|
145
225
|
}
|
|
146
226
|
// Every version WITH the file it came from. An agent that is told "they disagree" without being told
|
|
147
227
|
// WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n *\n * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────\n * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload\n * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.\n * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against\n * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's\n * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and\n * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own\n * install, a main clone on 0.4.616, and not one word from this guard.\n */\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (!this.applies(tree)) return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (!this.applies(tree)) return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /**\n * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:\n *\n * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can\n * never reach the manifests. (In the primary clone this is also structural escape #1: \"do the\n * work in the main tree\" needs no allowlist entry to keep working.)\n * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the\n * single-tree pin-vs-install question the L0 drift guard already owns.\n */\n private applies(tree: EffectiveTree): boolean {\n return tree.kind === 'worktree'\n && path.resolve(tree.mainRoot) !== path.resolve(tree.root);\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.mainRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` Whichever tree's hooks are live, one of these two releases lints, validates and builds`,\n ` this worktree — and it may be the one this manifest does not ask for.`,\n '',\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.mainRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then \\`pnpm install\\` in each tree that has a node_modules. A worktree MAY have its`,\n ` own; what it may not have is a DIFFERENT @webpieces version from the main tree.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a DIFFERENT version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own governance. (This is the answer to \"I need a different`,\n ` version\", never to \"I need to install here\" — installing here is fine.)`,\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor. If you are a SUBAGENT, you cannot fix the main tree: report to your`,\n ` coordinator that one of you must move to the other's version.`,\n ].join('\\n');\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.mainRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.mainRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.mainRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,mDAA6B;AAG7B,iEAAgE;AAChE,6DAA2F;AAE3F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,4GAA4G;AAC5G,MAAM,kBAAkB,GAAG,qBAAqB,CAAC;AAEjD,MAAa,gBAAgB;IACR,UAAU,GAAG,IAAI,6CAAsB,EAAE,CAAC;IAC1C,QAAQ,GAAG,IAAI,sCAAiB,EAAE,CAAC;IAEpD;;;;OAIG;IACH,MAAM,CAAC,IAAmB;QACtB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC;IACzC,CAAC;IAED,yCAAyC;IACzC,KAAK,CAAC,OAAe,EAAE,IAAmB;QACtC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACrC,IAAI,IAAI,CAAC,UAAU,CAAC,oBAAoB,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAC/D,IAAI,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAC5C,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;QACtC,IAAI,OAAO,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QAChC,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACtC,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACK,YAAY,CAAC,OAAe;QAChC,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3B,IAAI,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAClC,uFAAuF;YACvF,MAAM,UAAU,GAAG,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;YACzD,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;QACtH,CAAC;QACD,OAAO,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC;IACrF,CAAC;IAED;;;;;;;;OAQG;IACK,OAAO,CAAC,IAAmB;QAC/B,OAAO,IAAI,CAAC,IAAI,KAAK,UAAU;eACxB,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnE,CAAC;IAED,0GAA0G;IAC1G,UAAU,CAAC,IAAmB;QAC1B,OAAO,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IAC3D,CAAC;IAED,mGAAmG;IACnG,mGAAmG;IACnG,sEAAsE;IAC9D,MAAM,CAAC,IAAmB,EAAE,OAAuB;QACvD,MAAM,IAAI,GAAG,IAAI,CAAC,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAClD,OAAO;YACH,gGAAgG;YAChG,EAAE;YACF,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC;YACnC,EAAE;YACF,2FAA2F;YAC3F,0EAA0E;YAC1E,EAAE;YACF,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;YACrC,EAAE;YACF,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;YAC5C,EAAE;YACF,oGAAoG;YACpG,6EAA6E;YAC7E,2FAA2F;YAC3F,4BAA4B;SAC/B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IAED;;;;;;;;OAQG;IACK,QAAQ,CAAC,IAAmB,EAAE,OAAuB,EAAE,IAAa;QACxE,IAAI,IAAI,EAAE,CAAC;YACP,OAAO;gBACH,6CAA6C,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,qCAAqC;gBACtK,sGAAsG;gBACtG,iFAAiF;gBACjF,6FAA6F;gBAC7F,qDAAqD;gBACrD,+CAA+C;gBAC/C,+FAA+F;gBAC/F,2CAA2C,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,kDAAkD;aACzI,CAAC;QACN,CAAC;QACD,OAAO;YACH,4FAA4F;YAC5F,oBAAoB,IAAI,CAAC,QAAQ,wBAAwB,IAAI,CAAC,IAAI,6BAA6B;YAC/F,6FAA6F;YAC7F,yFAAyF;YACzF,+EAA+E;YAC/E,2FAA2F;YAC3F,+FAA+F;YAC/F,iFAAiF;SACpF,CAAC;IACN,CAAC;IAED;;;;;;;;;;OAUG;IACK,eAAe,CAAC,IAAmB,EAAE,OAAuB,EAAE,IAAa;QAC/E,MAAM,GAAG,GAAG,IAAI;YACZ,CAAC,CAAC;gBACI,yFAAyF;gBACzF,iGAAiG;gBACjG,+CAA+C,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,mDAAmD;aAC9I;YACH,CAAC,CAAC,CAAC,0BAA0B,IAAI,CAAC,QAAQ,2DAA2D,EAAE,8DAA8D,CAAC,CAAC;QAC3K,OAAO;YACH,iGAAiG;YACjG,sBAAsB,IAAI,CAAC,IAAI,qBAAqB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG;YAChG,wBAAwB,IAAI,CAAC,QAAQ,UAAU,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG;YACvF,GAAG,GAAG;SACT,CAAC;IACN,CAAC;IAED;;;;;;;;;;OAUG;IACK,gBAAgB,CAAC,IAAmB,EAAE,OAAuB;QACjE,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,KAAK,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,MAAM,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QACnF,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QAClE,OAAO,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,aAAa,EAAE,IAAI,EAAE,kBAAkB,CAAC,CAAC;eACzF,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,oBAAoB,EAAE,IAAI,EAAE,kBAAkB,CAAC,CAAC,CAAC;IACzH,CAAC;IAEO,oBAAoB,CAAC,IAAY,EAAE,IAAuB;QAC9D,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7E,OAAO,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;IACtE,CAAC;IAED,qGAAqG;IACrG,oGAAoG;IACpG,6FAA6F;IAC7F,gDAAgD;IACxC,YAAY,CAAC,IAAmB,EAAE,OAAuB;QAC7D,MAAM,KAAK,GAAG;YACV,qBAAqB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,QAAQ,sBAAsB;YAC5F,qBAAqB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,QAAQ,iBAAiB,qCAAgB,EAAE;YAC5G,uDAAuD;YACvD,qBAAqB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,sBAAsB;SAC/F,CAAC;QACF,IAAI,OAAO,CAAC,QAAQ,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;YACtC,KAAK,CAAC,IAAI,CAAC,qBAAqB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,IAAI,iBAAiB,qCAAgB,EAAE,CAAC,CAAC;YACzH,KAAK,CAAC,IAAI,CAAC,kEAAkE,CAAC,CAAC;QACnF,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACtE,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,KAAK,CAAC,IAAI,CAAC,WAAW,MAAM,CAAC,MAAM,sEAAsE,CAAC,CAAC;YAC3G,KAAK,CAAC,IAAI,CAAC,gFAAgF,CAAC,CAAC;YAC7F,KAAK,CAAC,IAAI,CAAC,gDAAgD,CAAC,CAAC;QACjE,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAEO,IAAI,CAAC,OAAsB;QAC/B,OAAO,CAAC,OAAO,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACvC,CAAC;CACJ;AA7MD,4CA6MC","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as path from 'path';\n\nimport { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n *\n * ─── The MAIN tree is `tree.mainRoot`, never `tree.governedRoot` ───────────────────────────────────\n * The two differ for exactly the reader this guard is for. `governedRoot` is walked up from the payload\n * cwd to the nearest `webpieces.config.json`, and that file is TRACKED — a linked worktree has its own.\n * So for an agent resident in a worktree `governedRoot` IS the worktree, and comparing it against\n * `tree.root` compared the tree with ITSELF: trivially in sync, guard silent. `mainRoot` is git's\n * `<git-common-dir>/..`, i.e. the clone whose `node_modules` actually supplies the judging binary, and\n * it is the same answer from every checkout. Measured 2026-08-10: a worktree on 0.4.624 with its own\n * install, a main clone on 0.4.616, and not one word from this guard.\n */\n/** The one file a pin lives in — named here so the \"did this branch bump it\" check cannot drift from it. */\nconst WORKSPACE_MANIFEST = 'pnpm-workspace.yaml';\n\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (!this.applies(tree)) return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (!this.applies(tree)) return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /**\n * Is there a cross-tree comparison to make at all? TWO cheap conditions, no file read behind either:\n *\n * • K is `worktree` — git's `--git-dir ≠ --git-common-dir`, so a repo with no linked worktrees can\n * never reach the manifests. (In the primary clone this is also structural escape #1: \"do the\n * work in the main tree\" needs no allowlist entry to keep working.)\n * • the two roots are DIFFERENT directories — a tree compared with itself is not a skew, it is the\n * single-tree pin-vs-install question the L0 drift guard already owns.\n */\n private applies(tree: EffectiveTree): boolean {\n return tree.kind === 'worktree'\n && path.resolve(tree.mainRoot) !== path.resolve(tree.root);\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.mainRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n const bump = this.isDeliberateBump(tree, quartet);\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` Whichever tree's hooks are live, one of these two releases lints, validates and builds`,\n ` this worktree — and it may be the one this manifest does not ask for.`,\n '',\n ...this.fixLines(tree, quartet, bump),\n '',\n ...this.escalationLines(tree, quartet, bump),\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor.`,\n ].join('\\n');\n }\n\n /**\n * The cure list, which is NOT the same list in both directions.\n *\n * The ordinary skew is two trees sitting on different commits of main, and there `git pull` both +\n * `pnpm install` genuinely converges them — the pin is tracked, so the same hash gives the same\n * version. That cure is WRONG, and worse than useless, when the branch bumped the pin ON PURPOSE:\n * pulling would revert the deliverable, and an install cannot move a pin in either tree. Printing\n * the git cure first in that case is what sent a real upgrade agent round the loop below.\n */\n private fixLines(tree: EffectiveTree, quartet: VersionQuartet, bump: boolean): readonly string[] {\n if (bump) {\n return [\n ` THIS BRANCH BUMPED THE PIN ON PURPOSE (${this.show(quartet.main.pinned).trim()} → ${this.show(quartet.worktree.pinned).trim()}), so the usual cures do NOT apply:`,\n ` • \\`pnpm install\\` cannot help in EITHER tree — an install materializes a pin, never moves one.`,\n ` • \\`git pull\\` here would revert the bump, which is the whole deliverable.`,\n ` • Wiping this tree's node_modules does NOT help — the two PINS still disagree, and the`,\n ` L0 drift guard blocks in this guard's place.`,\n ` Two ways out, and BOTH need the main tree:`,\n ` 1. Redo this task in the MAIN tree — a version bump cannot be done in a worktree at all.`,\n ` 2. Or raise the MAIN tree's pin to ${this.show(quartet.worktree.pinned).trim()} and \\`pnpm install\\` there, then continue here.`,\n ];\n }\n return [\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.mainRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then \\`pnpm install\\` in each tree that has a node_modules. A worktree MAY have its`,\n ` own; what it may not have is a DIFFERENT @webpieces version from the main tree.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a DIFFERENT version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own governance. (This is the answer to \"I need a different`,\n ` version\", never to \"I need to install here\" — installing here is fine.)`,\n ];\n }\n\n /**\n * THE SUBAGENT CANNOT REACH THE MAIN TREE, so the message it is handed has to be the message it\n * FORWARDS. This used to be one sentence — \"report to your coordinator that one of you must move to\n * the other's version\" — with no command, no direction and nothing pasteable, and the result was a\n * subagent that correctly diagnosed the block, correctly escalated, and handed its coordinator a\n * request too vague to act on. Worse, the obvious guess (\"ask the coordinator to run `pnpm install`\n * in main\") is a NO-OP on a bump: it reinstalls main's own pin and nothing moves.\n *\n * So the escalation is rendered as literal text to forward, with the versions and the direction\n * already filled in. A human cannot sit with every agent; the deny has to carry the whole ask.\n */\n private escalationLines(tree: EffectiveTree, quartet: VersionQuartet, bump: boolean): readonly string[] {\n const ask = bump\n ? [\n ` > A \\`pnpm install\\` in main will NOT fix this — main's PIN has to move. Pick one:`,\n ` > (a) I redo this task in the MAIN tree (a version bump cannot be done in a worktree), or`,\n ` > (b) you raise main's catalog pin to ${this.show(quartet.worktree.pinned).trim()} and \\`pnpm install\\` there, and I continue here.`,\n ]\n : [` > Please \\`git -C ${tree.mainRoot} pull\\` then \\`pnpm install\\` there, so both trees are on`, ` > the same release. I cannot reach that tree from here.`];\n return [\n ` SUBAGENT? You cannot fix the main tree from here. Forward this to your coordinator verbatim:`,\n ` > My worktree ${tree.root} is on @webpieces ${this.show(quartet.worktree.pinned).trim()};`,\n ` > the main tree ${tree.mainRoot} is on ${this.show(quartet.main.pinned).trim()}.`,\n ...ask,\n ];\n }\n\n /**\n * Did THIS BRANCH change the pin, as opposed to the two trees having drifted onto different commits?\n *\n * Only answerable now that both pin legs actually resolve — before the catalog reader followed YAML\n * anchors they both read null on the repos that pin via an anchor, so every skew looked alike and the\n * report could only ever print the one generic cure.\n *\n * Two git spawns worst case, on the BLOCK path only (this is never reached on an allow), and\n * best-effort: a git failure answers \"not a deliberate bump\", which falls back to the generic cure\n * that was the only text this report had before.\n */\n private isDeliberateBump(tree: EffectiveTree, quartet: VersionQuartet): boolean {\n if (quartet.main.pinned === null || quartet.worktree.pinned === null) return false;\n if (quartet.main.pinned === quartet.worktree.pinned) return false;\n return this.touchesWorkspaceFile(tree.root, ['status', '--porcelain', '--', WORKSPACE_MANIFEST])\n || this.touchesWorkspaceFile(tree.root, ['diff', '--name-only', 'origin/main...HEAD', '--', WORKSPACE_MANIFEST]);\n }\n\n private touchesWorkspaceFile(root: string, args: readonly string[]): boolean {\n const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });\n return result.status === 0 && (result.stdout ?? '').trim() !== '';\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.mainRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.mainRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.mainRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}
|
|
@@ -62,13 +62,28 @@ export declare class WebpiecesVersions {
|
|
|
62
62
|
/**
|
|
63
63
|
* The catalog pin, scraped from `pnpm-workspace.yaml`.
|
|
64
64
|
*
|
|
65
|
-
* Deliberately a narrow
|
|
66
|
-
* loads on the hook path, where a broken tree is exactly the case that matters)
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
65
|
+
* Deliberately a narrow scraper rather than a YAML parser: this module must stay dependency-free (it
|
|
66
|
+
* loads on the hook path, where a broken tree is exactly the case that matters). A RANGE (`^`, `~`,
|
|
67
|
+
* `workspace:*`) is returned as null, not as a version: a range cannot be compared for equality, and
|
|
68
|
+
* treating it as skew would block every consumer who pins loosely.
|
|
69
|
+
*
|
|
70
|
+
* ANCHORS AND ALIASES ARE NOT OPTIONAL TO SUPPORT. The scraper originally assumed one shape —
|
|
71
|
+
* `'@webpieces/nx-webpieces-rules': 0.4.616` — and a consumer repo that keeps the whole `@webpieces`
|
|
72
|
+
* family in lockstep the obvious way writes the version ONCE and aliases it:
|
|
73
|
+
*
|
|
74
|
+
* catalog:
|
|
75
|
+
* '@webpieces/core-context': &wp 0.4.634
|
|
76
|
+
* '@webpieces/nx-webpieces-rules': *wp
|
|
77
|
+
*
|
|
78
|
+
* There the umbrella's own value is `*wp`, which does not start with a digit, so the pin read as
|
|
79
|
+
* null and the whole TRINARY compare silently degraded to installed-vs-installed — the guard's third
|
|
80
|
+
* leg gone with no error, on exactly the repos that pin most carefully. So both halves are resolved
|
|
81
|
+
* here: a `&name` anchor DEFINED on the umbrella's line is stepped over, and a `*name` alias is
|
|
82
|
+
* looked up against the anchor definition anywhere in the file.
|
|
70
83
|
*/
|
|
71
84
|
private readPin;
|
|
85
|
+
/** One catalog value — literal, `&anchor literal`, or `*alias` — reduced to a plain version or null. */
|
|
86
|
+
private resolveValue;
|
|
72
87
|
/** The version under this tree's OWN node_modules, or null when it has none (the normal worktree). */
|
|
73
88
|
private readInstalled;
|
|
74
89
|
private readText;
|
|
@@ -100,11 +100,24 @@ class WebpiecesVersions {
|
|
|
100
100
|
/**
|
|
101
101
|
* The catalog pin, scraped from `pnpm-workspace.yaml`.
|
|
102
102
|
*
|
|
103
|
-
* Deliberately a narrow
|
|
104
|
-
* loads on the hook path, where a broken tree is exactly the case that matters)
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
103
|
+
* Deliberately a narrow scraper rather than a YAML parser: this module must stay dependency-free (it
|
|
104
|
+
* loads on the hook path, where a broken tree is exactly the case that matters). A RANGE (`^`, `~`,
|
|
105
|
+
* `workspace:*`) is returned as null, not as a version: a range cannot be compared for equality, and
|
|
106
|
+
* treating it as skew would block every consumer who pins loosely.
|
|
107
|
+
*
|
|
108
|
+
* ANCHORS AND ALIASES ARE NOT OPTIONAL TO SUPPORT. The scraper originally assumed one shape —
|
|
109
|
+
* `'@webpieces/nx-webpieces-rules': 0.4.616` — and a consumer repo that keeps the whole `@webpieces`
|
|
110
|
+
* family in lockstep the obvious way writes the version ONCE and aliases it:
|
|
111
|
+
*
|
|
112
|
+
* catalog:
|
|
113
|
+
* '@webpieces/core-context': &wp 0.4.634
|
|
114
|
+
* '@webpieces/nx-webpieces-rules': *wp
|
|
115
|
+
*
|
|
116
|
+
* There the umbrella's own value is `*wp`, which does not start with a digit, so the pin read as
|
|
117
|
+
* null and the whole TRINARY compare silently degraded to installed-vs-installed — the guard's third
|
|
118
|
+
* leg gone with no error, on exactly the repos that pin most carefully. So both halves are resolved
|
|
119
|
+
* here: a `&name` anchor DEFINED on the umbrella's line is stepped over, and a `*name` alias is
|
|
120
|
+
* looked up against the anchor definition anywhere in the file.
|
|
108
121
|
*/
|
|
109
122
|
readPin(root) {
|
|
110
123
|
const file = path.join(root, WORKSPACE_FILE);
|
|
@@ -112,11 +125,35 @@ class WebpiecesVersions {
|
|
|
112
125
|
if (text === null)
|
|
113
126
|
return null;
|
|
114
127
|
const escaped = exports.UMBRELLA_PACKAGE.replace('/', '\\/');
|
|
115
|
-
|
|
128
|
+
// `[ \t]*`, never `\s*`: under the `m` flag `\s` matches newlines, so a leading `\s*` would let
|
|
129
|
+
// the "indent" run across blank lines. Harmless for this grammar, and not a habit worth keeping
|
|
130
|
+
// in the reader whose whole bug history is a regex that matched more than it meant to.
|
|
131
|
+
const match = new RegExp(`^[ \\t]*['"]?${escaped}['"]?[ \\t]*:[ \\t]*(.*)$`, 'm').exec(text);
|
|
116
132
|
if (match === null)
|
|
117
133
|
return null;
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
return this.resolveValue(match[1], text);
|
|
135
|
+
}
|
|
136
|
+
/** One catalog value — literal, `&anchor literal`, or `*alias` — reduced to a plain version or null. */
|
|
137
|
+
resolveValue(rawValue, text) {
|
|
138
|
+
// `&wp 0.4.634` on the umbrella's own line: the anchor NAMES the value, it is not the value.
|
|
139
|
+
const value = rawValue.replace(/^&\S+[ \t]+/, '').trim();
|
|
140
|
+
const token = /^['"]?([^'"\s#]+)/.exec(value)?.[1] ?? '';
|
|
141
|
+
if (token.startsWith('*')) {
|
|
142
|
+
const anchor = token.slice(1);
|
|
143
|
+
// A YAML anchor name is a plain identifier. Refusing anything else keeps the name out of a
|
|
144
|
+
// RegExp it could otherwise inject into — and an unreadable pin is already a safe answer.
|
|
145
|
+
if (!/^[A-Za-z0-9_-]+$/.test(anchor))
|
|
146
|
+
return null;
|
|
147
|
+
// Resolve against the DEFINITION site, which is another key's value elsewhere in the catalog.
|
|
148
|
+
// The match is pinned to `<key>: &anchor <value>` on a NON-COMMENT line, because the repos
|
|
149
|
+
// that use an anchor also EXPLAIN it right above the catalog — "defined ONCE via the &wp YAML
|
|
150
|
+
// anchor below" — and a bare `&wp` search happily reads the word "YAML" out of that prose.
|
|
151
|
+
const defined = new RegExp(`^[ \\t]*['"]?[^#'"\\s:][^:\\n]*['"]?[ \\t]*:[ \\t]*&${anchor}[ \\t]+['"]?([^'"\\s#]+)`, 'm').exec(text);
|
|
152
|
+
if (defined === null)
|
|
153
|
+
return null;
|
|
154
|
+
return /^[0-9]/.test(defined[1]) ? defined[1] : null;
|
|
155
|
+
}
|
|
156
|
+
return /^[0-9]/.test(token) ? token : null;
|
|
120
157
|
}
|
|
121
158
|
/** The version under this tree's OWN node_modules, or null when it has none (the normal worktree). */
|
|
122
159
|
readInstalled(root) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webpieces-versions.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/webpieces-versions.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from './to-error';\n\n/** The umbrella package every consumer pins. One `catalog:` entry pins its children in lockstep. */\nexport const UMBRELLA_PACKAGE = '@webpieces/nx-webpieces-rules';\n\n/** `pnpm-workspace.yaml`'s catalog is the ONE place a webpieces version is declared. */\nconst WORKSPACE_FILE = 'pnpm-workspace.yaml';\n\n/**\n * ONE tree's answer to \"which @webpieces does this tree declare, and which does it have installed\".\n * Data-only (per CLAUDE.md, classes for data). `null` means \"could not be determined\" — which is NOT\n * the same as disagreeing, and every caller must treat it as \"no opinion\", never as skew.\n */\nexport class TreeVersions {\n constructor(\n readonly root: string,\n /** The catalog pin in this tree's `pnpm-workspace.yaml`. Tracked in git, so it is per-branch. */\n readonly pinned: string | null,\n /** The version actually installed under this tree's own `node_modules`, when it has one. */\n readonly installed: string | null,\n ) {}\n}\n\n/**\n * The 3-or-4 webpieces versions in play when a worktree is involved, and whether they agree.\n *\n * THREE always — main pin, worktree pin, main install — and a FOURTH when the worktree has its own\n * `node_modules`, which happens the moment anyone runs `pnpm add <anything>` in it. That fourth is the\n * uncommon one, and it is NOT optional to check: nx, vitest and the eslint plugin all run IN that tree\n * and load THAT copy. (It does not decide who *judges* the tree — with the guard hooks registered\n * absolutely, the judging binary is always the main tree's — but it decides who *builds, lints and\n * tests* it, and nothing else looks at it.)\n */\nexport class VersionQuartet {\n constructor(\n readonly main: TreeVersions,\n readonly worktree: TreeVersions,\n ) {}\n\n /** Every version that was actually readable, deduped — the set that must have exactly one member. */\n get distinct(): readonly string[] {\n const all = [this.main.pinned, this.main.installed, this.worktree.pinned, this.worktree.installed];\n const seen: string[] = [];\n for (const v of all) {\n if (v !== null && !seen.includes(v)) seen.push(v);\n }\n return seen;\n }\n\n /**\n * True when every version we could read agrees.\n *\n * FAILS OPEN on purpose: if nothing could be read (`distinct` is empty) this is `true`. A guard that\n * cannot measure must not block — the repo's worst incidents are guards that fired on a state they\n * could not diagnose, leaving an agent with no reachable cure.\n */\n get inSync(): boolean {\n return this.distinct.length <= 1;\n }\n}\n\n/**\n * Reads the webpieces versions a tree declares and installs.\n *\n * WHY A DEDICATED READER rather than reusing the sh drift guard's scraping: that one compares a pin to\n * an install WITHIN ONE TREE, which is a different question. This is the CROSS-TREE generalisation, and\n * it is the only thing that can catch the case the absolute-registration design deliberately accepts —\n * a worktree being judged by the main tree's release while its own manifest asks for another.\n *\n * Every read is best-effort and returns `null` rather than throwing: this runs on the hook's BLOCKING\n * path, so an unreadable file must degrade to \"no opinion\", never to a fault.\n */\nexport class WebpiecesVersions {\n // root -> answer. The hook resolves the same two roots many times per invocation; git and fs are\n // both far too expensive to repeat on a blocking path.\n private readonly byRoot = new Map<string, TreeVersions>();\n\n /** Both trees' versions, ready to compare. */\n quartet(mainRoot: string, worktreeRoot: string): VersionQuartet {\n return new VersionQuartet(this.forTree(mainRoot), this.forTree(worktreeRoot));\n }\n\n /** One tree's declared + installed versions, memoized. */\n forTree(root: string): TreeVersions {\n const cached = this.byRoot.get(root);\n if (cached !== undefined) return cached;\n const answer = new TreeVersions(root, this.readPin(root), this.readInstalled(root));\n this.byRoot.set(root, answer);\n return answer;\n }\n\n /**\n * The catalog pin, scraped from `pnpm-workspace.yaml`.\n *\n * Deliberately a narrow regex rather than a YAML parser: this module must stay dependency-free (it\n * loads on the hook path, where a broken tree is exactly the case that matters), and the catalog\n * entry has one shape — `'@webpieces/nx-webpieces-rules': 0.4.616`, optionally unquoted. A RANGE\n * (`^`, `~`, `workspace:*`) is returned as null, not as a version: a range cannot be compared for\n * equality, and treating it as skew would block every consumer who pins loosely.\n */\n private readPin(root: string): string | null {\n const file = path.join(root, WORKSPACE_FILE);\n const text = this.readText(file);\n if (text === null) return null;\n const escaped = UMBRELLA_PACKAGE.replace('/', '\\\\/');\n const match = new RegExp(`['\"]?${escaped}['\"]?\\\\s*:\\\\s*['\"]?([^'\"\\\\s#]+)`).exec(text);\n if (match === null) return null;\n const value = match[1];\n return /^[0-9]/.test(value) ? value : null;\n }\n\n /** The version under this tree's OWN node_modules, or null when it has none (the normal worktree). */\n private readInstalled(root: string): string | null {\n const manifest = path.join(root, 'node_modules', UMBRELLA_PACKAGE, 'package.json');\n const text = this.readText(manifest);\n if (text === null) return null;\n const match = /\"version\"\\s*:\\s*\"([^\"]+)\"/.exec(text);\n return match === null ? null : match[1];\n }\n\n // Best-effort by design: this runs on the hook's BLOCKING path, so an unreadable or half-written\n // manifest must degrade to \"no opinion\" rather than fault. A null here can only ever make the guard\n // quieter, never noisier — VersionQuartet.inSync fails open on an empty read.\n private readText(file: string): string | null {\n // webpieces-disable no-unmanaged-exceptions -- a manifest read on a PreToolUse blocking path has no chokepoint above it; letting it throw would fail every tool call over an unrelated fs error\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * Every OTHER linked worktree of this repo, so a block can name the ones that are ALSO skewed.\n *\n * A skew is never a two-tree problem: if worktree A is aligned and B is not, the agents working in B\n * are already mis-governed and nothing has told them. Best-effort — an empty answer means \"could not\n * enumerate\", and callers must never read that as \"there are no other worktrees\".\n */\n otherWorktrees(mainRoot: string, exclude: string): readonly string[] {\n const result = spawnSync('git', ['-C', mainRoot, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' });\n if (result.status !== 0) return [];\n const roots: string[] = [];\n for (const line of (result.stdout ?? '').split('\\n')) {\n if (!line.startsWith('worktree ')) continue;\n const dir = line.slice('worktree '.length).trim();\n if (dir !== '' && path.resolve(dir) !== path.resolve(mainRoot) && path.resolve(dir) !== path.resolve(exclude)) {\n roots.push(dir);\n }\n }\n return roots;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"webpieces-versions.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/webpieces-versions.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from './to-error';\n\n/** The umbrella package every consumer pins. One `catalog:` entry pins its children in lockstep. */\nexport const UMBRELLA_PACKAGE = '@webpieces/nx-webpieces-rules';\n\n/** `pnpm-workspace.yaml`'s catalog is the ONE place a webpieces version is declared. */\nconst WORKSPACE_FILE = 'pnpm-workspace.yaml';\n\n/**\n * ONE tree's answer to \"which @webpieces does this tree declare, and which does it have installed\".\n * Data-only (per CLAUDE.md, classes for data). `null` means \"could not be determined\" — which is NOT\n * the same as disagreeing, and every caller must treat it as \"no opinion\", never as skew.\n */\nexport class TreeVersions {\n constructor(\n readonly root: string,\n /** The catalog pin in this tree's `pnpm-workspace.yaml`. Tracked in git, so it is per-branch. */\n readonly pinned: string | null,\n /** The version actually installed under this tree's own `node_modules`, when it has one. */\n readonly installed: string | null,\n ) {}\n}\n\n/**\n * The 3-or-4 webpieces versions in play when a worktree is involved, and whether they agree.\n *\n * THREE always — main pin, worktree pin, main install — and a FOURTH when the worktree has its own\n * `node_modules`, which happens the moment anyone runs `pnpm add <anything>` in it. That fourth is the\n * uncommon one, and it is NOT optional to check: nx, vitest and the eslint plugin all run IN that tree\n * and load THAT copy. (It does not decide who *judges* the tree — with the guard hooks registered\n * absolutely, the judging binary is always the main tree's — but it decides who *builds, lints and\n * tests* it, and nothing else looks at it.)\n */\nexport class VersionQuartet {\n constructor(\n readonly main: TreeVersions,\n readonly worktree: TreeVersions,\n ) {}\n\n /** Every version that was actually readable, deduped — the set that must have exactly one member. */\n get distinct(): readonly string[] {\n const all = [this.main.pinned, this.main.installed, this.worktree.pinned, this.worktree.installed];\n const seen: string[] = [];\n for (const v of all) {\n if (v !== null && !seen.includes(v)) seen.push(v);\n }\n return seen;\n }\n\n /**\n * True when every version we could read agrees.\n *\n * FAILS OPEN on purpose: if nothing could be read (`distinct` is empty) this is `true`. A guard that\n * cannot measure must not block — the repo's worst incidents are guards that fired on a state they\n * could not diagnose, leaving an agent with no reachable cure.\n */\n get inSync(): boolean {\n return this.distinct.length <= 1;\n }\n}\n\n/**\n * Reads the webpieces versions a tree declares and installs.\n *\n * WHY A DEDICATED READER rather than reusing the sh drift guard's scraping: that one compares a pin to\n * an install WITHIN ONE TREE, which is a different question. This is the CROSS-TREE generalisation, and\n * it is the only thing that can catch the case the absolute-registration design deliberately accepts —\n * a worktree being judged by the main tree's release while its own manifest asks for another.\n *\n * Every read is best-effort and returns `null` rather than throwing: this runs on the hook's BLOCKING\n * path, so an unreadable file must degrade to \"no opinion\", never to a fault.\n */\nexport class WebpiecesVersions {\n // root -> answer. The hook resolves the same two roots many times per invocation; git and fs are\n // both far too expensive to repeat on a blocking path.\n private readonly byRoot = new Map<string, TreeVersions>();\n\n /** Both trees' versions, ready to compare. */\n quartet(mainRoot: string, worktreeRoot: string): VersionQuartet {\n return new VersionQuartet(this.forTree(mainRoot), this.forTree(worktreeRoot));\n }\n\n /** One tree's declared + installed versions, memoized. */\n forTree(root: string): TreeVersions {\n const cached = this.byRoot.get(root);\n if (cached !== undefined) return cached;\n const answer = new TreeVersions(root, this.readPin(root), this.readInstalled(root));\n this.byRoot.set(root, answer);\n return answer;\n }\n\n /**\n * The catalog pin, scraped from `pnpm-workspace.yaml`.\n *\n * Deliberately a narrow scraper rather than a YAML parser: this module must stay dependency-free (it\n * loads on the hook path, where a broken tree is exactly the case that matters). A RANGE (`^`, `~`,\n * `workspace:*`) is returned as null, not as a version: a range cannot be compared for equality, and\n * treating it as skew would block every consumer who pins loosely.\n *\n * ANCHORS AND ALIASES ARE NOT OPTIONAL TO SUPPORT. The scraper originally assumed one shape —\n * `'@webpieces/nx-webpieces-rules': 0.4.616` — and a consumer repo that keeps the whole `@webpieces`\n * family in lockstep the obvious way writes the version ONCE and aliases it:\n *\n * catalog:\n * '@webpieces/core-context': &wp 0.4.634\n * '@webpieces/nx-webpieces-rules': *wp\n *\n * There the umbrella's own value is `*wp`, which does not start with a digit, so the pin read as\n * null and the whole TRINARY compare silently degraded to installed-vs-installed — the guard's third\n * leg gone with no error, on exactly the repos that pin most carefully. So both halves are resolved\n * here: a `&name` anchor DEFINED on the umbrella's line is stepped over, and a `*name` alias is\n * looked up against the anchor definition anywhere in the file.\n */\n private readPin(root: string): string | null {\n const file = path.join(root, WORKSPACE_FILE);\n const text = this.readText(file);\n if (text === null) return null;\n const escaped = UMBRELLA_PACKAGE.replace('/', '\\\\/');\n // `[ \\t]*`, never `\\s*`: under the `m` flag `\\s` matches newlines, so a leading `\\s*` would let\n // the \"indent\" run across blank lines. Harmless for this grammar, and not a habit worth keeping\n // in the reader whose whole bug history is a regex that matched more than it meant to.\n const match = new RegExp(`^[ \\\\t]*['\"]?${escaped}['\"]?[ \\\\t]*:[ \\\\t]*(.*)$`, 'm').exec(text);\n if (match === null) return null;\n return this.resolveValue(match[1], text);\n }\n\n /** One catalog value — literal, `&anchor literal`, or `*alias` — reduced to a plain version or null. */\n private resolveValue(rawValue: string, text: string): string | null {\n // `&wp 0.4.634` on the umbrella's own line: the anchor NAMES the value, it is not the value.\n const value = rawValue.replace(/^&\\S+[ \\t]+/, '').trim();\n const token = /^['\"]?([^'\"\\s#]+)/.exec(value)?.[1] ?? '';\n if (token.startsWith('*')) {\n const anchor = token.slice(1);\n // A YAML anchor name is a plain identifier. Refusing anything else keeps the name out of a\n // RegExp it could otherwise inject into — and an unreadable pin is already a safe answer.\n if (!/^[A-Za-z0-9_-]+$/.test(anchor)) return null;\n // Resolve against the DEFINITION site, which is another key's value elsewhere in the catalog.\n // The match is pinned to `<key>: &anchor <value>` on a NON-COMMENT line, because the repos\n // that use an anchor also EXPLAIN it right above the catalog — \"defined ONCE via the &wp YAML\n // anchor below\" — and a bare `&wp` search happily reads the word \"YAML\" out of that prose.\n const defined = new RegExp(`^[ \\\\t]*['\"]?[^#'\"\\\\s:][^:\\\\n]*['\"]?[ \\\\t]*:[ \\\\t]*&${anchor}[ \\\\t]+['\"]?([^'\"\\\\s#]+)`, 'm').exec(text);\n if (defined === null) return null;\n return /^[0-9]/.test(defined[1]) ? defined[1] : null;\n }\n return /^[0-9]/.test(token) ? token : null;\n }\n\n /** The version under this tree's OWN node_modules, or null when it has none (the normal worktree). */\n private readInstalled(root: string): string | null {\n const manifest = path.join(root, 'node_modules', UMBRELLA_PACKAGE, 'package.json');\n const text = this.readText(manifest);\n if (text === null) return null;\n const match = /\"version\"\\s*:\\s*\"([^\"]+)\"/.exec(text);\n return match === null ? null : match[1];\n }\n\n // Best-effort by design: this runs on the hook's BLOCKING path, so an unreadable or half-written\n // manifest must degrade to \"no opinion\" rather than fault. A null here can only ever make the guard\n // quieter, never noisier — VersionQuartet.inSync fails open on an empty read.\n private readText(file: string): string | null {\n // webpieces-disable no-unmanaged-exceptions -- a manifest read on a PreToolUse blocking path has no chokepoint above it; letting it throw would fail every tool call over an unrelated fs error\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * Every OTHER linked worktree of this repo, so a block can name the ones that are ALSO skewed.\n *\n * A skew is never a two-tree problem: if worktree A is aligned and B is not, the agents working in B\n * are already mis-governed and nothing has told them. Best-effort — an empty answer means \"could not\n * enumerate\", and callers must never read that as \"there are no other worktrees\".\n */\n otherWorktrees(mainRoot: string, exclude: string): readonly string[] {\n const result = spawnSync('git', ['-C', mainRoot, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' });\n if (result.status !== 0) return [];\n const roots: string[] = [];\n for (const line of (result.stdout ?? '').split('\\n')) {\n if (!line.startsWith('worktree ')) continue;\n const dir = line.slice('worktree '.length).trim();\n if (dir !== '' && path.resolve(dir) !== path.resolve(mainRoot) && path.resolve(dir) !== path.resolve(exclude)) {\n roots.push(dir);\n }\n }\n return roots;\n }\n}\n"]}
|