@webpieces/ai-hook-rules 0.4.623 → 0.4.625
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/README.md +40 -33
- package/package.json +2 -2
- package/src/adapters/hook-core.js +7 -15
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/hook-registration.d.ts +121 -57
- package/src/bin/hook-registration.js +122 -92
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/managed-env.d.ts +17 -13
- package/src/bin/managed-env.js +17 -13
- package/src/bin/managed-env.js.map +1 -1
- package/src/bin/setup.d.ts +1 -2
- package/src/bin/setup.js +32 -50
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-audit-log.js +12 -1
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-deny-reason.js +10 -8
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.js +3 -3
- package/src/bin/shim.js.map +1 -1
- package/src/bin/upgrade-shim.d.ts +2 -2
- package/src/bin/upgrade-shim.js +58 -30
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/decision-log.d.ts +3 -3
- package/src/core/decision-log.js +8 -8
- package/src/core/decision-log.js.map +1 -1
- package/src/core/effective-tree.d.ts +5 -2
- package/src/core/effective-tree.js +1 -1
- package/src/core/effective-tree.js.map +1 -1
- package/src/core/l0-matrix.js +9 -9
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l1-doc.js +29 -68
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.d.ts +17 -9
- package/src/core/l1-rows.js +18 -13
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/log-stream.d.ts +6 -4
- package/src/core/log-stream.js +6 -4
- package/src/core/log-stream.js.map +1 -1
- package/src/core/log-streams.d.ts +13 -3
- package/src/core/log-streams.js +15 -5
- package/src/core/log-streams.js.map +1 -1
- package/src/core/runner.d.ts +1 -2
- package/src/core/runner.js +29 -24
- package/src/core/runner.js.map +1 -1
- package/src/core/version-sync.d.ts +67 -0
- package/src/core/version-sync.js +148 -0
- package/src/core/version-sync.js.map +1 -0
- package/src/core/webpieces-versions.d.ts +83 -0
- package/src/core/webpieces-versions.js +169 -0
- package/src/core/webpieces-versions.js.map +1 -0
- package/templates/ai-hook.sh +15 -4
- package/templates/claude-settings-hook.json +3 -13
- package/src/bin/guarantee-root.d.ts +0 -95
- package/src/bin/guarantee-root.js +0 -297
- package/src/bin/guarantee-root.js.map +0 -1
- package/src/core/coordinator-worktree.d.ts +0 -61
- package/src/core/coordinator-worktree.js +0 -94
- package/src/core/coordinator-worktree.js.map +0 -1
- package/templates/guarantee-root.sh +0 -113
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VersionSyncGuard = void 0;
|
|
4
|
+
const read_only_inspection_1 = require("./read-only-inspection");
|
|
5
|
+
const webpieces_versions_1 = require("./webpieces-versions");
|
|
6
|
+
/**
|
|
7
|
+
* L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.
|
|
8
|
+
*
|
|
9
|
+
* ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────
|
|
10
|
+
* The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every
|
|
11
|
+
* tree. That is not a new imposition — it is what was always happening, because a linked worktree has no
|
|
12
|
+
* `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now
|
|
13
|
+
* says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT
|
|
14
|
+
* release is being linted, validated and built by a release it never asked for.
|
|
15
|
+
*
|
|
16
|
+
* This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the
|
|
17
|
+
* replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking
|
|
18
|
+
* (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent
|
|
19
|
+
* whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone
|
|
20
|
+
* (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.
|
|
21
|
+
*
|
|
22
|
+
* ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────
|
|
23
|
+
* A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent
|
|
24
|
+
* is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,
|
|
25
|
+
* nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and
|
|
26
|
+
* a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message
|
|
27
|
+
* that names all the versions and all their files, is the whole point. Any future proposal to soften
|
|
28
|
+
* this to a warning must answer: what stops the five-install loop instead?
|
|
29
|
+
*
|
|
30
|
+
* ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
* Two structurally independent escapes, and neither depends on an allowlist regex staying in step:
|
|
32
|
+
* 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never
|
|
33
|
+
* reaches this guard at all. No allowlist entry can break it because none is involved.
|
|
34
|
+
* 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner
|
|
35
|
+
* the same way `webpieces.config.json` already is, so the cure is typable from inside the block.
|
|
36
|
+
* Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.
|
|
37
|
+
*/
|
|
38
|
+
class VersionSyncGuard {
|
|
39
|
+
inspection = new read_only_inspection_1.ReadOnlyInspectionScan();
|
|
40
|
+
versions = new webpieces_versions_1.WebpiecesVersions();
|
|
41
|
+
/**
|
|
42
|
+
* True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.
|
|
43
|
+
* This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer
|
|
44
|
+
* lands in the audit log even when nothing blocks.
|
|
45
|
+
*/
|
|
46
|
+
skewed(tree) {
|
|
47
|
+
if (tree.kind !== 'worktree')
|
|
48
|
+
return false;
|
|
49
|
+
return !this.quartetFor(tree).inSync;
|
|
50
|
+
}
|
|
51
|
+
/** The deny report, or null to allow. */
|
|
52
|
+
block(command, tree) {
|
|
53
|
+
if (tree.kind !== 'worktree')
|
|
54
|
+
return null;
|
|
55
|
+
if (this.inspection.isReadOnlyInspection(command))
|
|
56
|
+
return null;
|
|
57
|
+
if (this.isCureOrLook(command))
|
|
58
|
+
return null;
|
|
59
|
+
const quartet = this.quartetFor(tree);
|
|
60
|
+
if (quartet.inSync)
|
|
61
|
+
return null;
|
|
62
|
+
return this.report(tree, quartet);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked
|
|
66
|
+
* — or how you look at the tree first.
|
|
67
|
+
*
|
|
68
|
+
* `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT ("the guards exist to police
|
|
69
|
+
* git, and read-only git is not a line worth drawing while flying blind"), which is right for the
|
|
70
|
+
* guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both
|
|
71
|
+
* trees. Without this carve-out the guard would deny the exact command it tells the reader to run —
|
|
72
|
+
* the single failure shape this repo has been burned by most often, and the reason the deny text is
|
|
73
|
+
* allowed to promise "STILL ALLOWED HERE: ... pnpm install, git pull/fetch".
|
|
74
|
+
*
|
|
75
|
+
* Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of
|
|
76
|
+
* them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,
|
|
77
|
+
* because those are the operations that would be judged by the wrong release.
|
|
78
|
+
*/
|
|
79
|
+
isCureOrLook(command) {
|
|
80
|
+
const words = command.trim().split(/\s+/);
|
|
81
|
+
const head = words[0] ?? '';
|
|
82
|
+
const sub = words[1] ?? '';
|
|
83
|
+
if (head === 'git' || head === 'gh') {
|
|
84
|
+
// `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.
|
|
85
|
+
const subcommand = sub === '-C' ? (words[3] ?? '') : sub;
|
|
86
|
+
return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);
|
|
87
|
+
}
|
|
88
|
+
return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');
|
|
89
|
+
}
|
|
90
|
+
/** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */
|
|
91
|
+
quartetFor(tree) {
|
|
92
|
+
return this.versions.quartet(tree.governedRoot, tree.root);
|
|
93
|
+
}
|
|
94
|
+
// Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text
|
|
95
|
+
// if each one argues its case. State the skew, show every version WITH its file, give the git cure
|
|
96
|
+
// first, then the two structural escapes, then what is still allowed.
|
|
97
|
+
report(tree, quartet) {
|
|
98
|
+
return [
|
|
99
|
+
`❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,
|
|
100
|
+
'',
|
|
101
|
+
...this.versionLines(tree, quartet),
|
|
102
|
+
'',
|
|
103
|
+
` The guard hooks are ABSOLUTE, so the MAIN tree governs every tree. This worktree would be`,
|
|
104
|
+
` linted, validated and built by a release its own manifest does not ask for.`,
|
|
105
|
+
'',
|
|
106
|
+
` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,
|
|
107
|
+
` 1. \`git -C ${tree.governedRoot} pull\` and \`git -C ${tree.root} pull\` onto the same main,`,
|
|
108
|
+
` then ONE \`cd '${tree.governedRoot}' && pnpm install\`. A worktree needs no install of its own.`,
|
|
109
|
+
` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,
|
|
110
|
+
` 3. Or, if this tree genuinely needs a different version, use a separate CLONE, not a`,
|
|
111
|
+
` worktree: a clone gets its own node_modules and its own governance; a worktree cannot.`,
|
|
112
|
+
'',
|
|
113
|
+
` STILL ALLOWED HERE: every Read, read-only inspection, \`pnpm install\`, \`git pull\`/\`fetch\`,`,
|
|
114
|
+
` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,
|
|
115
|
+
` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,
|
|
116
|
+
` session's own governor. If you are a SUBAGENT, you cannot fix the main tree: report to your`,
|
|
117
|
+
` coordinator that one of you must move to the other's version.`,
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
// Every version WITH the file it came from. An agent that is told "they disagree" without being told
|
|
121
|
+
// WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists
|
|
122
|
+
// to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell
|
|
123
|
+
// "this one is absent" from "I forgot to look".
|
|
124
|
+
versionLines(tree, quartet) {
|
|
125
|
+
const lines = [
|
|
126
|
+
` main pin ${this.show(quartet.main.pinned)} ${tree.governedRoot}/pnpm-workspace.yaml`,
|
|
127
|
+
` main installed ${this.show(quartet.main.installed)} ${tree.governedRoot}/node_modules/${webpieces_versions_1.UMBRELLA_PACKAGE}`,
|
|
128
|
+
` ^ the binary judging this very call`,
|
|
129
|
+
` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,
|
|
130
|
+
];
|
|
131
|
+
if (quartet.worktree.installed !== null) {
|
|
132
|
+
lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${webpieces_versions_1.UMBRELLA_PACKAGE}`);
|
|
133
|
+
lines.push(' ^ what nx, vitest and eslint load IN this tree');
|
|
134
|
+
}
|
|
135
|
+
const others = this.versions.otherWorktrees(tree.governedRoot, tree.root);
|
|
136
|
+
if (others.length > 0) {
|
|
137
|
+
lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);
|
|
138
|
+
lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');
|
|
139
|
+
lines.push(' serializing the work in the main tree.');
|
|
140
|
+
}
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
show(version) {
|
|
144
|
+
return (version ?? '-').padEnd(10);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.VersionSyncGuard = VersionSyncGuard;
|
|
148
|
+
//# sourceMappingURL=version-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version-sync.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/version-sync.ts"],"names":[],"mappings":";;;AACA,iEAAgE;AAChE,6DAA2F;AAE3F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAa,gBAAgB;IACR,UAAU,GAAG,IAAI,6CAAsB,EAAE,CAAC;IAC1C,QAAQ,GAAG,IAAI,sCAAiB,EAAE,CAAC;IAEpD;;;;OAIG;IACH,MAAM,CAAC,IAAmB;QACtB,IAAI,IAAI,CAAC,IAAI,KAAK,UAAU;YAAE,OAAO,KAAK,CAAC;QAC3C,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC;IACzC,CAAC;IAED,yCAAyC;IACzC,KAAK,CAAC,OAAe,EAAE,IAAmB;QACtC,IAAI,IAAI,CAAC,IAAI,KAAK,UAAU;YAAE,OAAO,IAAI,CAAC;QAC1C,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,0GAA0G;IAC1G,UAAU,CAAC,IAAmB;QAC1B,OAAO,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/D,CAAC;IAED,mGAAmG;IACnG,mGAAmG;IACnG,sEAAsE;IAC9D,MAAM,CAAC,IAAmB,EAAE,OAAuB;QACvD,OAAO;YACH,gGAAgG;YAChG,EAAE;YACF,GAAG,IAAI,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC;YACnC,EAAE;YACF,8FAA8F;YAC9F,gFAAgF;YAChF,EAAE;YACF,4FAA4F;YAC5F,oBAAoB,IAAI,CAAC,YAAY,wBAAwB,IAAI,CAAC,IAAI,6BAA6B;YACnG,0BAA0B,IAAI,CAAC,YAAY,8DAA8D;YACzG,+EAA+E;YAC/E,2FAA2F;YAC3F,gGAAgG;YAChG,EAAE;YACF,oGAAoG;YACpG,6EAA6E;YAC7E,2FAA2F;YAC3F,gGAAgG;YAChG,kEAAkE;SACrE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,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,YAAY,sBAAsB;YAChG,qBAAqB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,YAAY,iBAAiB,qCAAgB,EAAE;YAChH,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,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1E,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;AA9GD,4CA8GC","sourcesContent":["import { EffectiveTree } from './effective-tree';\nimport { ReadOnlyInspectionScan } from './read-only-inspection';\nimport { UMBRELLA_PACKAGE, VersionQuartet, WebpiecesVersions } from './webpieces-versions';\n\n/**\n * L1 row 8 — a tree may not be worked in while its `@webpieces` version disagrees with the MAIN tree's.\n *\n * ─── WHY THIS EXISTS, and what it replaces ─────────────────────────────────────────────────────────\n * The guard hooks are registered ABSOLUTE (`$CLAUDE_PROJECT_DIR/...`), so the MAIN tree governs every\n * tree. That is not a new imposition — it is what was always happening, because a linked worktree has no\n * `node_modules` and ai-hook.sh's upward walk already executed the main tree's binary. The design now\n * says so out loud, which makes ONE case newly important: a worktree whose branch pins a DIFFERENT\n * release is being linted, validated and built by a release it never asked for.\n *\n * This guard makes that case LOUD instead of silent. It replaces `CoordinatorWorktreeGuard`, and the\n * replacement is strictly better on the axis that matters: the old guard keyed off WHO was asking\n * (coordinator vs subagent), and agent identity was measured untrustworthy — a worktree-isolated agent\n * whose tree is auto-reaped at a turn boundary silently resumes with its cwd on the primary clone\n * (reproduced twice, 2026-08-10). This guard keys off the PATH the command acts on, which cannot lie.\n *\n * ─── IT EXISTS TO STOP A LOOP, not to enforce tidiness ─────────────────────────────────────────────\n * A main/worktree manifest mismatch is exactly the shape that produced the founding incident: an agent\n * is shown a fault measured against one tree, runs the prescribed cure in another, the cure succeeds,\n * nothing the guard measures changes, and the guard re-denies. Five identical no-op `pnpm install`s and\n * a fabricated theory about the harness later, a human had to untangle it. Firing EARLY, with a message\n * that names all the versions and all their files, is the whole point. Any future proposal to soften\n * this to a warning must answer: what stops the five-install loop instead?\n *\n * ─── Never a deadlock ──────────────────────────────────────────────────────────────────────────────\n * Two structurally independent escapes, and neither depends on an allowlist regex staying in step:\n * 1. WORK IN THE MAIN TREE — a main-tree-targeted command cannot classify as `worktree`, so it never\n * reaches this guard at all. No allowlist entry can break it because none is involved.\n * 2. EDIT THE MANIFESTS — `pnpm-workspace.yaml` / `package.json` edits are carved out in the runner\n * the same way `webpieces.config.json` already is, so the cure is typable from inside the block.\n * Reads and read-only inspection are never blocked either, so an agent can always look before it fixes.\n */\nexport class VersionSyncGuard {\n private readonly inspection = new ReadOnlyInspectionScan();\n private readonly versions = new WebpiecesVersions();\n\n /**\n * True when this tree is a linked worktree whose webpieces version disagrees with the main tree's.\n * This is the `V` dimension of the L1 matrix; the runner asks it for EVERY Bash call so the answer\n * lands in the audit log even when nothing blocks.\n */\n skewed(tree: EffectiveTree): boolean {\n if (tree.kind !== 'worktree') return false;\n return !this.quartetFor(tree).inSync;\n }\n\n /** The deny report, or null to allow. */\n block(command: string, tree: EffectiveTree): string | null {\n if (tree.kind !== 'worktree') return null;\n if (this.inspection.isReadOnlyInspection(command)) return null;\n if (this.isCureOrLook(command)) return null;\n const quartet = this.quartetFor(tree);\n if (quartet.inSync) return null;\n return this.report(tree, quartet);\n }\n\n /**\n * Commands that must pass EVEN WHILE THIS GUARD IS BLOCKING, because they are how you get unblocked\n * — or how you look at the tree first.\n *\n * `ReadOnlyInspectionScan` deliberately excludes git and gh OUTRIGHT (\"the guards exist to police\n * git, and read-only git is not a line worth drawing while flying blind\"), which is right for the\n * guards that police git but WRONG here: this guard's own prescribed cure is `git pull` in both\n * trees. Without this carve-out the guard would deny the exact command it tells the reader to run —\n * the single failure shape this repo has been burned by most often, and the reason the deny text is\n * allowed to promise \"STILL ALLOWED HERE: ... pnpm install, git pull/fetch\".\n *\n * Deliberately NARROW: fetching, pulling and installing cannot make a skew worse, and every one of\n * them moves the tree toward agreement. Anything that BUILDS, TESTS or COMMITS is still blocked,\n * because those are the operations that would be judged by the wrong release.\n */\n private isCureOrLook(command: string): boolean {\n const words = command.trim().split(/\\s+/);\n const head = words[0] ?? '';\n const sub = words[1] ?? '';\n if (head === 'git' || head === 'gh') {\n // `git -C <dir> <sub>` names its own directory; take the first non-flag word after it.\n const subcommand = sub === '-C' ? (words[3] ?? '') : sub;\n return ['pull', 'fetch', 'status', 'log', 'diff', 'show', 'branch', 'rev-parse', 'worktree'].includes(subcommand);\n }\n return (head === 'pnpm' || head === 'npm') && (sub === 'install' || sub === 'i');\n }\n\n /** Public so the runner can log all four versions on ALLOW as well as on BLOCK (audit, not just deny). */\n quartetFor(tree: EffectiveTree): VersionQuartet {\n return this.versions.quartet(tree.governedRoot, tree.root);\n }\n\n // Short on purpose — L0 ran a deliberate message diet and these blocks regress into a wall of text\n // if each one argues its case. State the skew, show every version WITH its file, give the git cure\n // first, then the two structural escapes, then what is still allowed.\n private report(tree: EffectiveTree, quartet: VersionQuartet): string {\n return [\n `❌ @webpieces version SKEW — this worktree and the main tree disagree, so work here is blocked.`,\n '',\n ...this.versionLines(tree, quartet),\n '',\n ` The guard hooks are ABSOLUTE, so the MAIN tree governs every tree. This worktree would be`,\n ` linted, validated and built by a release its own manifest does not ask for.`,\n '',\n ` FIX (usually just git — the pin is TRACKED, so the same commit gives the same version):`,\n ` 1. \\`git -C ${tree.governedRoot} pull\\` and \\`git -C ${tree.root} pull\\` onto the same main,`,\n ` then ONE \\`cd '${tree.governedRoot}' && pnpm install\\`. A worktree needs no install of its own.`,\n ` 2. Or work in the MAIN tree instead — it is never blocked by this guard.`,\n ` 3. Or, if this tree genuinely needs a different version, use a separate CLONE, not a`,\n ` worktree: a clone gets its own node_modules and its own governance; a worktree cannot.`,\n '',\n ` STILL ALLOWED HERE: every Read, read-only inspection, \\`pnpm install\\`, \\`git pull\\`/\\`fetch\\`,`,\n ` and edits to pnpm-workspace.yaml / package.json / webpieces.config.json.`,\n ` Do NOT lower the MAIN tree's pin to match — that downgrades every tree, including this`,\n ` session's own governor. If you are a SUBAGENT, you cannot fix the main tree: report to your`,\n ` coordinator that one of you must move to the other's version.`,\n ].join('\\n');\n }\n\n // Every version WITH the file it came from. An agent that is told \"they disagree\" without being told\n // WHICH FILE to edit re-derives it by grepping, which is exactly the turn-burning this guard exists\n // to prevent. Unreadable legs are printed as `-` rather than omitted, so the reader can tell\n // \"this one is absent\" from \"I forgot to look\".\n private versionLines(tree: EffectiveTree, quartet: VersionQuartet): readonly string[] {\n const lines = [\n ` main pin ${this.show(quartet.main.pinned)} ${tree.governedRoot}/pnpm-workspace.yaml`,\n ` main installed ${this.show(quartet.main.installed)} ${tree.governedRoot}/node_modules/${UMBRELLA_PACKAGE}`,\n ` ^ the binary judging this very call`,\n ` this worktree ${this.show(quartet.worktree.pinned)} ${tree.root}/pnpm-workspace.yaml`,\n ];\n if (quartet.worktree.installed !== null) {\n lines.push(` its installed ${this.show(quartet.worktree.installed)} ${tree.root}/node_modules/${UMBRELLA_PACKAGE}`);\n lines.push(' ^ what nx, vitest and eslint load IN this tree');\n }\n const others = this.versions.otherWorktrees(tree.governedRoot, tree.root);\n if (others.length > 0) {\n lines.push(` NOTE ${others.length} other worktree(s) exist and are governed the same way — if they are`);\n lines.push(' skewed too, their agents are already mis-governed. Consider clones, or');\n lines.push(' serializing the work in the main tree.');\n }\n return lines;\n }\n\n private show(version: string | null): string {\n return (version ?? '-').padEnd(10);\n }\n}\n"]}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** The umbrella package every consumer pins. One `catalog:` entry pins its children in lockstep. */
|
|
2
|
+
export declare const UMBRELLA_PACKAGE = "@webpieces/nx-webpieces-rules";
|
|
3
|
+
/**
|
|
4
|
+
* ONE tree's answer to "which @webpieces does this tree declare, and which does it have installed".
|
|
5
|
+
* Data-only (per CLAUDE.md, classes for data). `null` means "could not be determined" — which is NOT
|
|
6
|
+
* the same as disagreeing, and every caller must treat it as "no opinion", never as skew.
|
|
7
|
+
*/
|
|
8
|
+
export declare class TreeVersions {
|
|
9
|
+
readonly root: string;
|
|
10
|
+
/** The catalog pin in this tree's `pnpm-workspace.yaml`. Tracked in git, so it is per-branch. */
|
|
11
|
+
readonly pinned: string | null;
|
|
12
|
+
/** The version actually installed under this tree's own `node_modules`, when it has one. */
|
|
13
|
+
readonly installed: string | null;
|
|
14
|
+
constructor(root: string,
|
|
15
|
+
/** The catalog pin in this tree's `pnpm-workspace.yaml`. Tracked in git, so it is per-branch. */
|
|
16
|
+
pinned: string | null,
|
|
17
|
+
/** The version actually installed under this tree's own `node_modules`, when it has one. */
|
|
18
|
+
installed: string | null);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The 3-or-4 webpieces versions in play when a worktree is involved, and whether they agree.
|
|
22
|
+
*
|
|
23
|
+
* THREE always — main pin, worktree pin, main install — and a FOURTH when the worktree has its own
|
|
24
|
+
* `node_modules`, which happens the moment anyone runs `pnpm add <anything>` in it. That fourth is the
|
|
25
|
+
* uncommon one, and it is NOT optional to check: nx, vitest and the eslint plugin all run IN that tree
|
|
26
|
+
* and load THAT copy. (It does not decide who *judges* the tree — with the guard hooks registered
|
|
27
|
+
* absolutely, the judging binary is always the main tree's — but it decides who *builds, lints and
|
|
28
|
+
* tests* it, and nothing else looks at it.)
|
|
29
|
+
*/
|
|
30
|
+
export declare class VersionQuartet {
|
|
31
|
+
readonly main: TreeVersions;
|
|
32
|
+
readonly worktree: TreeVersions;
|
|
33
|
+
constructor(main: TreeVersions, worktree: TreeVersions);
|
|
34
|
+
/** Every version that was actually readable, deduped — the set that must have exactly one member. */
|
|
35
|
+
get distinct(): readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* True when every version we could read agrees.
|
|
38
|
+
*
|
|
39
|
+
* FAILS OPEN on purpose: if nothing could be read (`distinct` is empty) this is `true`. A guard that
|
|
40
|
+
* cannot measure must not block — the repo's worst incidents are guards that fired on a state they
|
|
41
|
+
* could not diagnose, leaving an agent with no reachable cure.
|
|
42
|
+
*/
|
|
43
|
+
get inSync(): boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads the webpieces versions a tree declares and installs.
|
|
47
|
+
*
|
|
48
|
+
* WHY A DEDICATED READER rather than reusing the sh drift guard's scraping: that one compares a pin to
|
|
49
|
+
* an install WITHIN ONE TREE, which is a different question. This is the CROSS-TREE generalisation, and
|
|
50
|
+
* it is the only thing that can catch the case the absolute-registration design deliberately accepts —
|
|
51
|
+
* a worktree being judged by the main tree's release while its own manifest asks for another.
|
|
52
|
+
*
|
|
53
|
+
* Every read is best-effort and returns `null` rather than throwing: this runs on the hook's BLOCKING
|
|
54
|
+
* path, so an unreadable file must degrade to "no opinion", never to a fault.
|
|
55
|
+
*/
|
|
56
|
+
export declare class WebpiecesVersions {
|
|
57
|
+
private readonly byRoot;
|
|
58
|
+
/** Both trees' versions, ready to compare. */
|
|
59
|
+
quartet(mainRoot: string, worktreeRoot: string): VersionQuartet;
|
|
60
|
+
/** One tree's declared + installed versions, memoized. */
|
|
61
|
+
forTree(root: string): TreeVersions;
|
|
62
|
+
/**
|
|
63
|
+
* The catalog pin, scraped from `pnpm-workspace.yaml`.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately a narrow regex 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), and the catalog
|
|
67
|
+
* entry has one shape — `'@webpieces/nx-webpieces-rules': 0.4.616`, optionally unquoted. A RANGE
|
|
68
|
+
* (`^`, `~`, `workspace:*`) is returned as null, not as a version: a range cannot be compared for
|
|
69
|
+
* equality, and treating it as skew would block every consumer who pins loosely.
|
|
70
|
+
*/
|
|
71
|
+
private readPin;
|
|
72
|
+
/** The version under this tree's OWN node_modules, or null when it has none (the normal worktree). */
|
|
73
|
+
private readInstalled;
|
|
74
|
+
private readText;
|
|
75
|
+
/**
|
|
76
|
+
* Every OTHER linked worktree of this repo, so a block can name the ones that are ALSO skewed.
|
|
77
|
+
*
|
|
78
|
+
* A skew is never a two-tree problem: if worktree A is aligned and B is not, the agents working in B
|
|
79
|
+
* are already mis-governed and nothing has told them. Best-effort — an empty answer means "could not
|
|
80
|
+
* enumerate", and callers must never read that as "there are no other worktrees".
|
|
81
|
+
*/
|
|
82
|
+
otherWorktrees(mainRoot: string, exclude: string): readonly string[];
|
|
83
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebpiecesVersions = exports.VersionQuartet = exports.TreeVersions = exports.UMBRELLA_PACKAGE = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
8
|
+
const to_error_1 = require("./to-error");
|
|
9
|
+
/** The umbrella package every consumer pins. One `catalog:` entry pins its children in lockstep. */
|
|
10
|
+
exports.UMBRELLA_PACKAGE = '@webpieces/nx-webpieces-rules';
|
|
11
|
+
/** `pnpm-workspace.yaml`'s catalog is the ONE place a webpieces version is declared. */
|
|
12
|
+
const WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
13
|
+
/**
|
|
14
|
+
* ONE tree's answer to "which @webpieces does this tree declare, and which does it have installed".
|
|
15
|
+
* Data-only (per CLAUDE.md, classes for data). `null` means "could not be determined" — which is NOT
|
|
16
|
+
* the same as disagreeing, and every caller must treat it as "no opinion", never as skew.
|
|
17
|
+
*/
|
|
18
|
+
class TreeVersions {
|
|
19
|
+
root;
|
|
20
|
+
pinned;
|
|
21
|
+
installed;
|
|
22
|
+
constructor(root,
|
|
23
|
+
/** The catalog pin in this tree's `pnpm-workspace.yaml`. Tracked in git, so it is per-branch. */
|
|
24
|
+
pinned,
|
|
25
|
+
/** The version actually installed under this tree's own `node_modules`, when it has one. */
|
|
26
|
+
installed) {
|
|
27
|
+
this.root = root;
|
|
28
|
+
this.pinned = pinned;
|
|
29
|
+
this.installed = installed;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.TreeVersions = TreeVersions;
|
|
33
|
+
/**
|
|
34
|
+
* The 3-or-4 webpieces versions in play when a worktree is involved, and whether they agree.
|
|
35
|
+
*
|
|
36
|
+
* THREE always — main pin, worktree pin, main install — and a FOURTH when the worktree has its own
|
|
37
|
+
* `node_modules`, which happens the moment anyone runs `pnpm add <anything>` in it. That fourth is the
|
|
38
|
+
* uncommon one, and it is NOT optional to check: nx, vitest and the eslint plugin all run IN that tree
|
|
39
|
+
* and load THAT copy. (It does not decide who *judges* the tree — with the guard hooks registered
|
|
40
|
+
* absolutely, the judging binary is always the main tree's — but it decides who *builds, lints and
|
|
41
|
+
* tests* it, and nothing else looks at it.)
|
|
42
|
+
*/
|
|
43
|
+
class VersionQuartet {
|
|
44
|
+
main;
|
|
45
|
+
worktree;
|
|
46
|
+
constructor(main, worktree) {
|
|
47
|
+
this.main = main;
|
|
48
|
+
this.worktree = worktree;
|
|
49
|
+
}
|
|
50
|
+
/** Every version that was actually readable, deduped — the set that must have exactly one member. */
|
|
51
|
+
get distinct() {
|
|
52
|
+
const all = [this.main.pinned, this.main.installed, this.worktree.pinned, this.worktree.installed];
|
|
53
|
+
const seen = [];
|
|
54
|
+
for (const v of all) {
|
|
55
|
+
if (v !== null && !seen.includes(v))
|
|
56
|
+
seen.push(v);
|
|
57
|
+
}
|
|
58
|
+
return seen;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True when every version we could read agrees.
|
|
62
|
+
*
|
|
63
|
+
* FAILS OPEN on purpose: if nothing could be read (`distinct` is empty) this is `true`. A guard that
|
|
64
|
+
* cannot measure must not block — the repo's worst incidents are guards that fired on a state they
|
|
65
|
+
* could not diagnose, leaving an agent with no reachable cure.
|
|
66
|
+
*/
|
|
67
|
+
get inSync() {
|
|
68
|
+
return this.distinct.length <= 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.VersionQuartet = VersionQuartet;
|
|
72
|
+
/**
|
|
73
|
+
* Reads the webpieces versions a tree declares and installs.
|
|
74
|
+
*
|
|
75
|
+
* WHY A DEDICATED READER rather than reusing the sh drift guard's scraping: that one compares a pin to
|
|
76
|
+
* an install WITHIN ONE TREE, which is a different question. This is the CROSS-TREE generalisation, and
|
|
77
|
+
* it is the only thing that can catch the case the absolute-registration design deliberately accepts —
|
|
78
|
+
* a worktree being judged by the main tree's release while its own manifest asks for another.
|
|
79
|
+
*
|
|
80
|
+
* Every read is best-effort and returns `null` rather than throwing: this runs on the hook's BLOCKING
|
|
81
|
+
* path, so an unreadable file must degrade to "no opinion", never to a fault.
|
|
82
|
+
*/
|
|
83
|
+
class WebpiecesVersions {
|
|
84
|
+
// root -> answer. The hook resolves the same two roots many times per invocation; git and fs are
|
|
85
|
+
// both far too expensive to repeat on a blocking path.
|
|
86
|
+
byRoot = new Map();
|
|
87
|
+
/** Both trees' versions, ready to compare. */
|
|
88
|
+
quartet(mainRoot, worktreeRoot) {
|
|
89
|
+
return new VersionQuartet(this.forTree(mainRoot), this.forTree(worktreeRoot));
|
|
90
|
+
}
|
|
91
|
+
/** One tree's declared + installed versions, memoized. */
|
|
92
|
+
forTree(root) {
|
|
93
|
+
const cached = this.byRoot.get(root);
|
|
94
|
+
if (cached !== undefined)
|
|
95
|
+
return cached;
|
|
96
|
+
const answer = new TreeVersions(root, this.readPin(root), this.readInstalled(root));
|
|
97
|
+
this.byRoot.set(root, answer);
|
|
98
|
+
return answer;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The catalog pin, scraped from `pnpm-workspace.yaml`.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately a narrow regex 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), and the catalog
|
|
105
|
+
* entry has one shape — `'@webpieces/nx-webpieces-rules': 0.4.616`, optionally unquoted. A RANGE
|
|
106
|
+
* (`^`, `~`, `workspace:*`) is returned as null, not as a version: a range cannot be compared for
|
|
107
|
+
* equality, and treating it as skew would block every consumer who pins loosely.
|
|
108
|
+
*/
|
|
109
|
+
readPin(root) {
|
|
110
|
+
const file = path.join(root, WORKSPACE_FILE);
|
|
111
|
+
const text = this.readText(file);
|
|
112
|
+
if (text === null)
|
|
113
|
+
return null;
|
|
114
|
+
const escaped = exports.UMBRELLA_PACKAGE.replace('/', '\\/');
|
|
115
|
+
const match = new RegExp(`['"]?${escaped}['"]?\\s*:\\s*['"]?([^'"\\s#]+)`).exec(text);
|
|
116
|
+
if (match === null)
|
|
117
|
+
return null;
|
|
118
|
+
const value = match[1];
|
|
119
|
+
return /^[0-9]/.test(value) ? value : null;
|
|
120
|
+
}
|
|
121
|
+
/** The version under this tree's OWN node_modules, or null when it has none (the normal worktree). */
|
|
122
|
+
readInstalled(root) {
|
|
123
|
+
const manifest = path.join(root, 'node_modules', exports.UMBRELLA_PACKAGE, 'package.json');
|
|
124
|
+
const text = this.readText(manifest);
|
|
125
|
+
if (text === null)
|
|
126
|
+
return null;
|
|
127
|
+
const match = /"version"\s*:\s*"([^"]+)"/.exec(text);
|
|
128
|
+
return match === null ? null : match[1];
|
|
129
|
+
}
|
|
130
|
+
// Best-effort by design: this runs on the hook's BLOCKING path, so an unreadable or half-written
|
|
131
|
+
// manifest must degrade to "no opinion" rather than fault. A null here can only ever make the guard
|
|
132
|
+
// quieter, never noisier — VersionQuartet.inSync fails open on an empty read.
|
|
133
|
+
readText(file) {
|
|
134
|
+
// 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
|
|
135
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
136
|
+
try {
|
|
137
|
+
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const error = (0, to_error_1.toError)(err);
|
|
141
|
+
void error;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Every OTHER linked worktree of this repo, so a block can name the ones that are ALSO skewed.
|
|
147
|
+
*
|
|
148
|
+
* A skew is never a two-tree problem: if worktree A is aligned and B is not, the agents working in B
|
|
149
|
+
* are already mis-governed and nothing has told them. Best-effort — an empty answer means "could not
|
|
150
|
+
* enumerate", and callers must never read that as "there are no other worktrees".
|
|
151
|
+
*/
|
|
152
|
+
otherWorktrees(mainRoot, exclude) {
|
|
153
|
+
const result = (0, child_process_1.spawnSync)('git', ['-C', mainRoot, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' });
|
|
154
|
+
if (result.status !== 0)
|
|
155
|
+
return [];
|
|
156
|
+
const roots = [];
|
|
157
|
+
for (const line of (result.stdout ?? '').split('\n')) {
|
|
158
|
+
if (!line.startsWith('worktree '))
|
|
159
|
+
continue;
|
|
160
|
+
const dir = line.slice('worktree '.length).trim();
|
|
161
|
+
if (dir !== '' && path.resolve(dir) !== path.resolve(mainRoot) && path.resolve(dir) !== path.resolve(exclude)) {
|
|
162
|
+
roots.push(dir);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return roots;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
exports.WebpiecesVersions = WebpiecesVersions;
|
|
169
|
+
//# sourceMappingURL=webpieces-versions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webpieces-versions.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/webpieces-versions.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,+CAAyB;AACzB,mDAA6B;AAE7B,yCAAqC;AAErC,oGAAoG;AACvF,QAAA,gBAAgB,GAAG,+BAA+B,CAAC;AAEhE,wFAAwF;AACxF,MAAM,cAAc,GAAG,qBAAqB,CAAC;AAE7C;;;;GAIG;AACH,MAAa,YAAY;IAER;IAEA;IAEA;IALb,YACa,IAAY;IACrB,iGAAiG;IACxF,MAAqB;IAC9B,4FAA4F;IACnF,SAAwB;QAJxB,SAAI,GAAJ,IAAI,CAAQ;QAEZ,WAAM,GAAN,MAAM,CAAe;QAErB,cAAS,GAAT,SAAS,CAAe;IAClC,CAAC;CACP;AARD,oCAQC;AAED;;;;;;;;;GASG;AACH,MAAa,cAAc;IAEV;IACA;IAFb,YACa,IAAkB,EAClB,QAAsB;QADtB,SAAI,GAAJ,IAAI,CAAc;QAClB,aAAQ,GAAR,QAAQ,CAAc;IAChC,CAAC;IAEJ,qGAAqG;IACrG,IAAI,QAAQ;QACR,MAAM,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QACnG,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,KAAK,MAAM,CAAC,IAAI,GAAG,EAAE,CAAC;YAClB,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACtD,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;OAMG;IACH,IAAI,MAAM;QACN,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,IAAI,CAAC,CAAC;IACrC,CAAC;CACJ;AA1BD,wCA0BC;AAED;;;;;;;;;;GAUG;AACH,MAAa,iBAAiB;IAC1B,iGAAiG;IACjG,uDAAuD;IACtC,MAAM,GAAG,IAAI,GAAG,EAAwB,CAAC;IAE1D,8CAA8C;IAC9C,OAAO,CAAC,QAAgB,EAAE,YAAoB;QAC1C,OAAO,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC;IAClF,CAAC;IAED,0DAA0D;IAC1D,OAAO,CAAC,IAAY;QAChB,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACrC,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO,MAAM,CAAC;QACxC,MAAM,MAAM,GAAG,IAAI,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;QACpF,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC9B,OAAO,MAAM,CAAC;IAClB,CAAC;IAED;;;;;;;;OAQG;IACK,OAAO,CAAC,IAAY;QACxB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;QAC7C,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACjC,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC/B,MAAM,OAAO,GAAG,wBAAgB,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACrD,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,QAAQ,OAAO,iCAAiC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtF,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAChC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACvB,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/C,CAAC;IAED,sGAAsG;IAC9F,aAAa,CAAC,IAAY;QAC9B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,EAAE,wBAAgB,EAAE,cAAc,CAAC,CAAC;QACnF,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QACrC,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC/B,MAAM,KAAK,GAAG,2BAA2B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACrD,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC5C,CAAC;IAED,iGAAiG;IACjG,oGAAoG;IACpG,8EAA8E;IACtE,QAAQ,CAAC,IAAY;QACzB,gMAAgM;QAChM,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACtE,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACH,cAAc,CAAC,QAAgB,EAAE,OAAe;QAC5C,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3G,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QACnC,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACnD,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC;gBAAE,SAAS;YAC5C,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;YAClD,IAAI,GAAG,KAAK,EAAE,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC5G,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACL,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;CACJ;AAnFD,8CAmFC","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"]}
|
package/templates/ai-hook.sh
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
# the hook has a stable entry point even when node_modules is absent. Safe to delete along with the
|
|
8
8
|
# matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.
|
|
9
9
|
#
|
|
10
|
-
# Usage (wired into .claude/settings.json,
|
|
11
|
-
# sh "
|
|
10
|
+
# Usage (wired into .claude/settings.json, ABSOLUTE so the MAIN tree governs every tree):
|
|
11
|
+
# sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" <bin-name>
|
|
12
12
|
BIN_NAME="$1"
|
|
13
13
|
shift
|
|
14
14
|
# Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the
|
|
@@ -113,7 +113,7 @@ WP_INSTALL_CMD="pnpm install"
|
|
|
113
113
|
WP_BORROW_NOTE=""
|
|
114
114
|
if [ "$BIN_ROOT" != "$ROOT" ]; then
|
|
115
115
|
WP_INSTALL_CMD="cd $ROOT && pnpm install"
|
|
116
|
-
WP_BORROW_NOTE=" NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up
|
|
116
|
+
WP_BORROW_NOTE=" NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. For a linked WORKTREE that is the DESIGNED state, not a gap - the guard hooks are registered absolute, so the main tree governs every tree and a worktree needs no install of its own. What matters is that the two trees agree on the VERSION, and because the pin is tracked in git the reliable way to get that is the same git hash in both, then ONE 'pnpm install' in the main tree. Installing HERE is legitimate too (adding a dependency does it), but then this tree's own @webpieces must match the main tree's. If you genuinely need a DIFFERENT version, use a separate clone rather than a worktree."
|
|
117
117
|
fi
|
|
118
118
|
# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must
|
|
119
119
|
# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.
|
|
@@ -187,7 +187,18 @@ wp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label
|
|
|
187
187
|
_wp_sz="$(wc -c < "$_wp_f" 2>/dev/null | tr -d ' ')"
|
|
188
188
|
case "$_wp_sz" in ''|*[!0-9]*) _wp_sz=0 ;; esac
|
|
189
189
|
[ "$_wp_sz" -gt 524288 ] && mv -f "$_wp_f" "$_wp_sd/${_wp_pfx}.1.log" 2>/dev/null
|
|
190
|
-
|
|
190
|
+
# shim= and bin= are the two facts this log could not previously answer, and they are the ones that
|
|
191
|
+
# decide whether a tree was governed by its OWN release or a borrowed one:
|
|
192
|
+
# shim= WHICH COPY OF ai-hook.sh RAN — $ROOT, resolved from $0. The file is TRACKED, so every
|
|
193
|
+
# worktree carries the version at ITS commit; settings.json registers it ABSOLUTE, so the copy
|
|
194
|
+
# that runs is the SESSION ROOT's. Logged rather than assumed.
|
|
195
|
+
# bin= WHICH TREE SUPPLIED THE BINARY — $BIN_ROOT, the upward walk's answer. A fresh linked
|
|
196
|
+
# worktree has no node_modules, so this is normally the PRIMARY even when shim= is not:
|
|
197
|
+
# per-tree governance is the script and the config, never the enforcement code.
|
|
198
|
+
# Until these existed, no log at any layer recorded either (L-1 logged neither, L0 logged neither,
|
|
199
|
+
# only L1 logged root=/projectDir=), so "which hook governed this call" had to be inferred. Compare
|
|
200
|
+
# shim= against bin= to see a borrow, and either against the tree to see a straddle.
|
|
201
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)" "$BIN_NAME" "$TOOL" "tree=$WP_TREE" "shim=$ROOT" "bin=$BIN_ROOT" "fault=$1" "$2" "$CMD_LOG" >> "$_wp_f"
|
|
191
202
|
} 2>/dev/null || true
|
|
192
203
|
}
|
|
193
204
|
BROKEN_BIN=""
|
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "The webpieces
|
|
3
|
-
"_comment_env": "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1 is the FOURTH webpieces-managed thing, and it is guard integrity rather than ergonomics: it pins the Bash cwd back to the project root after every Bash call, so the RELATIVE hooks above always resolve, and because settings `env` is INHERITED it puts the main agent and every subagent on the same cwd — same hook resolution, same guard verdict. The trade, said out loud: the cwd reset becomes silent and unconditional, so a deliberate `cd` no longer persists between Bash calls — chain instead (`cd <dir> && <cmd>`). `pnpm exec wp-upgrade-shim` regenerates all four of these and reports which it changed.",
|
|
2
|
+
"_comment": "The webpieces PreToolUse hook wiring, plus the managed env entry. Install with `wp-install-ai-hooks` (which lets you pick a location per hook); this file just shows the shape. THREE managed surfaces are enforced together and `wp-upgrade-shim` repairs all three: (1) the committed .claude/webpieces/ai-hook.sh, (2) these registration entries, (3) the env entry below. The Rules hook validates code-style on Write/Edit/MultiEdit; the Guards hook protects git/PR/branch and matches Write|Edit|MultiEdit|Bash|Read - Read carries no guard but the guards hook owns the per-invocation audit log, and a Read is never blocked. BOTH hooks are registered ABSOLUTE via $CLAUDE_PROJECT_DIR, so the MAIN tree governs every tree. This replaced a three-hook RELATIVE form whose third hook (guarantee-root.sh, L-1) existed only to keep the relative path resolvable, and paid for it by denying every `cd` into a subdirectory; an absolute path resolves from any cwd, so that hook is deleted and `wp-upgrade-shim` removes any stale entry and the file. A linked worktree never had its own release anyway - it has no node_modules, so the shim's upward walk already ran the main tree's binary. When a worktree PINS a different @webpieces, L1 row 8 (trinary-version-skew) blocks and names the fix. CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR pins the Bash cwd to the project root so a verdict never depends on where an earlier cd left the shell; settings env is inherited, so every subagent gets the same cwd and the same verdict.",
|
|
4
3
|
"env": {
|
|
5
4
|
"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1"
|
|
6
5
|
},
|
|
7
6
|
"hooks": {
|
|
8
7
|
"PreToolUse": [
|
|
9
|
-
{
|
|
10
|
-
"matcher": "Bash",
|
|
11
|
-
"hooks": [
|
|
12
|
-
{
|
|
13
|
-
"type": "command",
|
|
14
|
-
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh\""
|
|
15
|
-
}
|
|
16
|
-
]
|
|
17
|
-
},
|
|
18
8
|
{
|
|
19
9
|
"matcher": "Write|Edit|MultiEdit",
|
|
20
10
|
"hooks": [
|
|
21
11
|
{
|
|
22
12
|
"type": "command",
|
|
23
|
-
"command": "sh \"
|
|
13
|
+
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" wp-ai-rules-hook"
|
|
24
14
|
}
|
|
25
15
|
]
|
|
26
16
|
},
|
|
@@ -29,7 +19,7 @@
|
|
|
29
19
|
"hooks": [
|
|
30
20
|
{
|
|
31
21
|
"type": "command",
|
|
32
|
-
"command": "sh \"
|
|
22
|
+
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" wp-ai-guards-hook"
|
|
33
23
|
}
|
|
34
24
|
]
|
|
35
25
|
}
|