@codyswann/lisa 4.37.3 → 4.38.0
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/all/copy-contents/.gitattributes +9 -0
- package/dist/core/learnings-merge-driver.d.ts +8 -0
- package/dist/core/learnings-merge-driver.d.ts.map +1 -1
- package/dist/core/learnings-merge-driver.js +17 -0
- package/dist/core/learnings-merge-driver.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +28 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +28 -1
- package/plugins/lisa/.codex-plugin/hooks.json +27 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/withdrawn-rulings.mjs +525 -0
- package/plugins/lisa/hooks/withdrawn-rulings.sh +24 -0
- package/plugins/lisa/rules/eager/withdrawn-rulings.md +69 -0
- package/plugins/lisa/rules/reference/withdrawn-rulings.md +114 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -1
- package/plugins/lisa-copilot/hooks/withdrawn-rulings.mjs +525 -0
- package/plugins/lisa-copilot/hooks/withdrawn-rulings.sh +24 -0
- package/plugins/lisa-copilot/rules/eager/withdrawn-rulings.md +69 -0
- package/plugins/lisa-copilot/rules/reference/withdrawn-rulings.md +114 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +10 -0
- package/plugins/lisa-cursor/hooks/withdrawn-rulings.mjs +525 -0
- package/plugins/lisa-cursor/hooks/withdrawn-rulings.sh +24 -0
- package/plugins/lisa-cursor/rules/withdrawn-rulings-reference.mdc +119 -0
- package/plugins/lisa-cursor/rules/withdrawn-rulings.mdc +74 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +27 -0
- package/plugins/src/base/hooks/withdrawn-rulings.mjs +525 -0
- package/plugins/src/base/hooks/withdrawn-rulings.sh +24 -0
- package/plugins/src/base/rules/eager/withdrawn-rulings.md +69 -0
- package/plugins/src/base/rules/reference/withdrawn-rulings.md +114 -0
- package/scripts/lib/hook-registration-audit.mjs +612 -0
- package/typescript/copy-contents/.husky/pre-push +98 -26
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit that every shipped hook script is actually registered to run.
|
|
3
|
+
*
|
|
4
|
+
* The defect this exists for is one level up from an inert guard: not a guard
|
|
5
|
+
* whose logic fails to bite, but a guard that is never invoked at all. A
|
|
6
|
+
* mangled guard fails loudly; an unregistered one is silent by construction,
|
|
7
|
+
* because nothing in the tree was looking at the question (CodySwannGT/lisa#3809).
|
|
8
|
+
*
|
|
9
|
+
* `check-plugins-sync.sh` already proves the generated ports reproduce from
|
|
10
|
+
* their generators. It cannot prove what this proves: regeneration reproduces a
|
|
11
|
+
* MISSING entry just as faithfully as a present one, so a generator whose own
|
|
12
|
+
* hook table is short, or a manifest array that lost a member in a list-append
|
|
13
|
+
* merge resolution, regenerates clean and passes every gate.
|
|
14
|
+
*
|
|
15
|
+
* Three kinds of file live in a `hooks/` directory and only one of them is
|
|
16
|
+
* required to be registered, so the classification is the load-bearing part.
|
|
17
|
+
* It is done by properties of the file, never by a hand-maintained list of
|
|
18
|
+
* exceptions — an exception list added to harden a guard becomes the bypass:
|
|
19
|
+
*
|
|
20
|
+
* - ENTRY POINT — a `.sh` that is not an adapter. Must be registered.
|
|
21
|
+
* - ADAPTER — `*.agy.sh`. Registered on the Antigravity surface ONLY,
|
|
22
|
+
* and deliberately absent from every other manifest.
|
|
23
|
+
* - SUPPORT — anything that is not `.sh` (`.mjs`, `.py`). Exempt from
|
|
24
|
+
* registration, but the exemption is not free: it must be
|
|
25
|
+
* referenced by a sibling in the same directory, so a dead
|
|
26
|
+
* support file cannot hide behind its extension.
|
|
27
|
+
*
|
|
28
|
+
* An entry point may be reached by any of three declared channels, all read
|
|
29
|
+
* from the artifact that does the reaching rather than restated here:
|
|
30
|
+
*
|
|
31
|
+
* 1. a plugin manifest (`plugin.json` / `hooks.json` under a port root),
|
|
32
|
+
* 2. the host enforcement dispatcher's guard loop
|
|
33
|
+
* (`all/copy-overwrite/scripts/lisa-enforcement-fallback.sh`),
|
|
34
|
+
* 3. a shipped skill or command that tells the agent to run it.
|
|
35
|
+
*
|
|
36
|
+
* A script reached by none of them must be declared unshipped for EVERY agent
|
|
37
|
+
* in the repository's own per-agent ship list. That declaration is not an
|
|
38
|
+
* escape hatch: it is checked in both directions, so declaring a script
|
|
39
|
+
* unshipped for an agent while that agent's manifest still registers it is
|
|
40
|
+
* itself a violation. The default for an undeclared script is "ships", which
|
|
41
|
+
* is what makes a newly added hook fail closed.
|
|
42
|
+
*
|
|
43
|
+
* @module scripts/lib/hook-registration-audit
|
|
44
|
+
*/
|
|
45
|
+
import fs from "node:fs";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
|
|
49
|
+
import { invokedAsScript } from "./invoked-as-script.mjs";
|
|
50
|
+
import { shouldShipScript } from "./per-agent-hook-filter.mjs";
|
|
51
|
+
|
|
52
|
+
/** Agents the per-agent ship list can speak about. */
|
|
53
|
+
const SHIP_LIST_AGENTS = Object.freeze([
|
|
54
|
+
"claude",
|
|
55
|
+
"codex",
|
|
56
|
+
"cursor",
|
|
57
|
+
"agy",
|
|
58
|
+
"copilot",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** Filenames a coding agent reads hook registrations out of. */
|
|
62
|
+
const MANIFEST_FILENAMES = Object.freeze(["plugin.json", "hooks.json"]);
|
|
63
|
+
|
|
64
|
+
/** Suffix that marks an Antigravity protocol adapter. */
|
|
65
|
+
const ADAPTER_SUFFIX = ".agy.sh";
|
|
66
|
+
|
|
67
|
+
/** Directory-name suffix of an Antigravity port. */
|
|
68
|
+
const AGY_PORT_SUFFIX = "-agy";
|
|
69
|
+
|
|
70
|
+
/** Host dispatcher whose guard loop is the second registration channel. */
|
|
71
|
+
const DISPATCHER_PATH = path.join(
|
|
72
|
+
"all",
|
|
73
|
+
"copy-overwrite",
|
|
74
|
+
"scripts",
|
|
75
|
+
"lisa-enforcement-fallback.sh"
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** Repository root, derived from this file's own location. */
|
|
79
|
+
export const REPO_ROOT = path.resolve(
|
|
80
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
81
|
+
"..",
|
|
82
|
+
".."
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify one file in a hooks directory.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} filename Basename as it sits on disk.
|
|
89
|
+
* @returns {"adapter"|"entry-point"|"support"|"manifest"}
|
|
90
|
+
*/
|
|
91
|
+
export function classifyHookFile(filename) {
|
|
92
|
+
if (MANIFEST_FILENAMES.includes(filename)) return "manifest";
|
|
93
|
+
if (filename.endsWith(ADAPTER_SUFFIX)) return "adapter";
|
|
94
|
+
if (filename.endsWith(".sh")) return "entry-point";
|
|
95
|
+
return "support";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every string that appears anywhere in a parsed JSON value.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
function collectStrings(value) {
|
|
105
|
+
if (typeof value === "string") return [value];
|
|
106
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
107
|
+
if (value && typeof value === "object") {
|
|
108
|
+
return Object.values(value).flatMap(collectStrings);
|
|
109
|
+
}
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Directory entries that are regular files, sorted, or [] when absent.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} dir
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
function listFiles(dir) {
|
|
120
|
+
if (!fs.existsSync(dir)) return [];
|
|
121
|
+
return fs
|
|
122
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
123
|
+
.filter(entry => entry.isFile())
|
|
124
|
+
.map(entry => entry.name)
|
|
125
|
+
.sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Recursively collect paths under `dir` whose basename passes `keep`.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} dir
|
|
132
|
+
* @param {(basename: string) => boolean} keep
|
|
133
|
+
* @param {number} [maxDepth]
|
|
134
|
+
* @returns {string[]}
|
|
135
|
+
*/
|
|
136
|
+
function findFiles(dir, keep, maxDepth = 6) {
|
|
137
|
+
if (!fs.existsSync(dir)) return [];
|
|
138
|
+
const found = [];
|
|
139
|
+
const walk = (current, depth) => {
|
|
140
|
+
if (depth > maxDepth) return;
|
|
141
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
142
|
+
const full = path.join(current, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
if (entry.name !== "node_modules") walk(full, depth + 1);
|
|
145
|
+
} else if (entry.isFile() && keep(entry.name)) {
|
|
146
|
+
found.push(full);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
walk(dir, 0);
|
|
151
|
+
return found.sort();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The manifest files under `root`, each with the hook basenames it registers.
|
|
156
|
+
*
|
|
157
|
+
* A SURFACE is one manifest file, not one port directory. `plugins/lisa/` alone
|
|
158
|
+
* carries two runtimes' registrations in two different files, so folding a port
|
|
159
|
+
* into a single set unions them — and a hook dropped from the Codex file goes
|
|
160
|
+
* on reading as registered because the Claude file beside it still names it.
|
|
161
|
+
* That is exactly the break this audit was filed for.
|
|
162
|
+
*
|
|
163
|
+
* Shape-agnostic on purpose: five agent runtimes read five different manifest
|
|
164
|
+
* schemas, and a check coupled to any one of them would go quietly blind the
|
|
165
|
+
* first time a schema changed. Every string in every manifest is examined for a
|
|
166
|
+
* hook basename instead.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} root Absolute path to a plugin port or generator source.
|
|
169
|
+
* @param {string[]} hookNames Basenames to look for.
|
|
170
|
+
* @returns {Array<{ manifest: string, registers: Set<string> }>} One entry per
|
|
171
|
+
* manifest file that registers at least one hook.
|
|
172
|
+
*/
|
|
173
|
+
export function readManifestSurfaces(root, hookNames) {
|
|
174
|
+
const manifests = findFiles(root, name => MANIFEST_FILENAMES.includes(name));
|
|
175
|
+
const surfaces = [];
|
|
176
|
+
for (const manifest of manifests) {
|
|
177
|
+
let strings;
|
|
178
|
+
try {
|
|
179
|
+
strings = collectStrings(JSON.parse(fs.readFileSync(manifest, "utf8")));
|
|
180
|
+
} catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const registers = new Set(
|
|
184
|
+
hookNames.filter(name => strings.some(value => value.includes(name)))
|
|
185
|
+
);
|
|
186
|
+
if (registers.size > 0) {
|
|
187
|
+
surfaces.push({
|
|
188
|
+
manifest: path.relative(REPO_ROOT, manifest),
|
|
189
|
+
registers,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return surfaces;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Which agent reads a manifest, from the port directory's name and the
|
|
198
|
+
* manifest's own location. Codex is told apart from Claude by the
|
|
199
|
+
* `.codex-plugin/` directory, because both live inside the same port.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} portName
|
|
202
|
+
* @param {string} manifestRelPath
|
|
203
|
+
* @returns {"agy"|"cursor"|"copilot"|"codex"|"claude"}
|
|
204
|
+
*/
|
|
205
|
+
export function surfaceAgent(portName, manifestRelPath) {
|
|
206
|
+
if (portName.endsWith(AGY_PORT_SUFFIX)) return "agy";
|
|
207
|
+
if (portName.endsWith("-cursor")) return "cursor";
|
|
208
|
+
if (portName.endsWith("-copilot")) return "copilot";
|
|
209
|
+
if (manifestRelPath.split(path.sep).includes(".codex-plugin")) return "codex";
|
|
210
|
+
return "claude";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The Antigravity adapter that stands in for a canonical guard, by the naming
|
|
215
|
+
* protocol the adapters already follow (`x.sh` → `x.agy.sh`).
|
|
216
|
+
*
|
|
217
|
+
* @param {string} filename
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function adapterFor(filename) {
|
|
221
|
+
return `${filename.slice(0, -".sh".length)}${ADAPTER_SUFFIX}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Guard basenames the host enforcement dispatcher runs.
|
|
226
|
+
*
|
|
227
|
+
* Parsed out of the dispatcher's own `for guard in ...; do` loop rather than
|
|
228
|
+
* restated, so a guard dropped from the loop stops counting as registered here
|
|
229
|
+
* on the same edit.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} repoRoot
|
|
232
|
+
* @returns {Set<string>}
|
|
233
|
+
*/
|
|
234
|
+
export function readDispatcherGuards(repoRoot) {
|
|
235
|
+
const dispatcher = path.join(repoRoot, DISPATCHER_PATH);
|
|
236
|
+
if (!fs.existsSync(dispatcher)) return new Set();
|
|
237
|
+
const source = fs.readFileSync(dispatcher, "utf8");
|
|
238
|
+
const loop = /^for\s+guard\s+in\s+([\s\S]*?);\s*do$/m.exec(source);
|
|
239
|
+
if (!loop) return new Set();
|
|
240
|
+
return new Set(
|
|
241
|
+
loop[1]
|
|
242
|
+
.replace(/\\\n/g, " ")
|
|
243
|
+
.split(/\s+/)
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.map(guard => `${guard}.sh`)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Hook basenames a shipped skill or command instructs an agent to run.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} stackRoot Absolute path to a `plugins/src/<stack>` directory.
|
|
253
|
+
* @param {string[]} hookNames Basenames to look for.
|
|
254
|
+
* @returns {Set<string>}
|
|
255
|
+
*/
|
|
256
|
+
function readDocumentedInvocations(stackRoot, hookNames) {
|
|
257
|
+
const docs = [
|
|
258
|
+
...findFiles(path.join(stackRoot, "skills"), name => name.endsWith(".md")),
|
|
259
|
+
...findFiles(path.join(stackRoot, "commands"), name =>
|
|
260
|
+
name.endsWith(".md")
|
|
261
|
+
),
|
|
262
|
+
];
|
|
263
|
+
const invoked = new Set();
|
|
264
|
+
for (const doc of docs) {
|
|
265
|
+
const text = fs.readFileSync(doc, "utf8");
|
|
266
|
+
for (const name of hookNames) {
|
|
267
|
+
if (text.includes(name)) invoked.add(name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return invoked;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Which sibling files in the same hooks directory INVOKE `filename`.
|
|
275
|
+
*
|
|
276
|
+
* Invocation, not mention. A guard's refusal text routinely names other guards,
|
|
277
|
+
* and a mention-level test would exempt exactly the registered guards this
|
|
278
|
+
* audit exists to protect — the guard that lost its manifest entry would look
|
|
279
|
+
* like somebody's helper. Two properties separate the two: the reference has to
|
|
280
|
+
* sit on a line that is not a comment, and the basename has to be preceded by a
|
|
281
|
+
* path separator, which is what a script writes when it runs a sibling
|
|
282
|
+
* (`"$LISA_HOOK_DIR/lisa-edit-gate.sh"`) and not what prose writes when it
|
|
283
|
+
* cites one.
|
|
284
|
+
*
|
|
285
|
+
* References from `*.agy.sh` adapters do not count. An adapter delegating to
|
|
286
|
+
* the canonical guard is the registered path doing its job, not evidence that
|
|
287
|
+
* the guard is a library — counting it would exempt every guard that has an
|
|
288
|
+
* Antigravity port.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} hooksDir
|
|
291
|
+
* @param {string} filename
|
|
292
|
+
* @param {string[]} siblings
|
|
293
|
+
* @returns {string[]}
|
|
294
|
+
*/
|
|
295
|
+
function siblingsInvoking(hooksDir, filename, siblings) {
|
|
296
|
+
const invocation = new RegExp(
|
|
297
|
+
`/${filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
|
|
298
|
+
);
|
|
299
|
+
return siblings.filter(sibling => {
|
|
300
|
+
if (sibling === filename) return false;
|
|
301
|
+
if (sibling.endsWith(ADAPTER_SUFFIX)) return false;
|
|
302
|
+
let text;
|
|
303
|
+
try {
|
|
304
|
+
text = fs.readFileSync(path.join(hooksDir, sibling), "utf8");
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
return text
|
|
309
|
+
.split("\n")
|
|
310
|
+
.some(
|
|
311
|
+
line => !/^\s*(?:#|\*|\/\*|\/\/)/.test(line) && invocation.test(line)
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Enumerate the plugin ports — every directory under `plugins/` that is not the
|
|
318
|
+
* generator source. Derived from the directory listing so a port added later is
|
|
319
|
+
* audited without editing this module.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} repoRoot
|
|
322
|
+
* @returns {Array<{ name: string, root: string }>}
|
|
323
|
+
*/
|
|
324
|
+
export function enumeratePorts(repoRoot) {
|
|
325
|
+
const pluginsDir = path.join(repoRoot, "plugins");
|
|
326
|
+
if (!fs.existsSync(pluginsDir)) return [];
|
|
327
|
+
return fs
|
|
328
|
+
.readdirSync(pluginsDir, { withFileTypes: true })
|
|
329
|
+
.filter(entry => entry.isDirectory() && entry.name !== "src")
|
|
330
|
+
.map(entry => ({
|
|
331
|
+
name: entry.name,
|
|
332
|
+
root: path.join(pluginsDir, entry.name),
|
|
333
|
+
}))
|
|
334
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Enumerate the generator sources that carry hooks.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} repoRoot
|
|
341
|
+
* @returns {Array<{ stack: string, root: string, hooksDir: string }>}
|
|
342
|
+
*/
|
|
343
|
+
export function enumerateStacks(repoRoot) {
|
|
344
|
+
const srcDir = path.join(repoRoot, "plugins", "src");
|
|
345
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
346
|
+
return fs
|
|
347
|
+
.readdirSync(srcDir, { withFileTypes: true })
|
|
348
|
+
.filter(entry => entry.isDirectory())
|
|
349
|
+
.map(entry => ({
|
|
350
|
+
stack: entry.name,
|
|
351
|
+
root: path.join(srcDir, entry.name),
|
|
352
|
+
hooksDir: path.join(srcDir, entry.name, "hooks"),
|
|
353
|
+
}))
|
|
354
|
+
.filter(stack => fs.existsSync(stack.hooksDir))
|
|
355
|
+
.sort((a, b) => a.stack.localeCompare(b.stack));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether the per-agent ship list declares this script unshipped everywhere.
|
|
360
|
+
*
|
|
361
|
+
* @param {string} filename
|
|
362
|
+
* @returns {boolean}
|
|
363
|
+
*/
|
|
364
|
+
function declaredUnshipped(filename) {
|
|
365
|
+
return SHIP_LIST_AGENTS.every(agent => !shouldShipScript(filename, agent));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Audit hook registration across every generator source and every port.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} [repoRoot]
|
|
372
|
+
* @returns {{
|
|
373
|
+
* scanned: { stacks: number, ports: number, surfaces: number, entryPoints: number, adapters: number, supportModules: number },
|
|
374
|
+
* surfaces: string[],
|
|
375
|
+
* violations: Array<{ kind: string, hook: string, surface: string, detail: string }>,
|
|
376
|
+
* }}
|
|
377
|
+
*/
|
|
378
|
+
export function auditHookRegistration(repoRoot = REPO_ROOT) {
|
|
379
|
+
const ports = enumeratePorts(repoRoot);
|
|
380
|
+
const stacks = enumerateStacks(repoRoot);
|
|
381
|
+
const dispatcherGuards = readDispatcherGuards(repoRoot);
|
|
382
|
+
|
|
383
|
+
/** @type {Array<{ kind: string, hook: string, surface: string, detail: string }>} */
|
|
384
|
+
const violations = [];
|
|
385
|
+
const counts = { entryPoints: 0, adapters: 0, supportModules: 0 };
|
|
386
|
+
|
|
387
|
+
// Every hook basename anywhere in the repository, so a manifest can be read
|
|
388
|
+
// for names its own port no longer carries.
|
|
389
|
+
const allHookNames = new Set();
|
|
390
|
+
for (const dir of [
|
|
391
|
+
...stacks.map(stack => stack.hooksDir),
|
|
392
|
+
...ports.map(port => path.join(port.root, "hooks")),
|
|
393
|
+
]) {
|
|
394
|
+
for (const file of listFiles(dir)) {
|
|
395
|
+
if (classifyHookFile(file) !== "manifest") allHookNames.add(file);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const hookNames = [...allHookNames].sort();
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Every manifest in the repository that registers a hook, tagged with the
|
|
402
|
+
* port it belongs to and the agent that reads it.
|
|
403
|
+
* @type {Array<{ port: string, manifest: string, agent: string, registers: Set<string>, shipped: Set<string> }>}
|
|
404
|
+
*/
|
|
405
|
+
const surfaces = [];
|
|
406
|
+
for (const port of ports) {
|
|
407
|
+
const shipped = new Set(listFiles(path.join(port.root, "hooks")));
|
|
408
|
+
for (const surface of readManifestSurfaces(port.root, hookNames)) {
|
|
409
|
+
surfaces.push({
|
|
410
|
+
port: port.name,
|
|
411
|
+
manifest: surface.manifest,
|
|
412
|
+
agent: surfaceAgent(port.name, surface.manifest),
|
|
413
|
+
registers: surface.registers,
|
|
414
|
+
shipped,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 1. A manifest that names a script its port does not ship registers nothing.
|
|
420
|
+
for (const surface of surfaces) {
|
|
421
|
+
for (const hook of [...surface.registers].sort()) {
|
|
422
|
+
if (surface.shipped.has(hook)) continue;
|
|
423
|
+
violations.push({
|
|
424
|
+
kind: "registered-but-missing",
|
|
425
|
+
hook,
|
|
426
|
+
surface: surface.manifest,
|
|
427
|
+
detail: `${surface.manifest} registers ${hook}, but plugins/${surface.port}/hooks/${hook} does not exist — the entry points at nothing.`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for (const stack of stacks) {
|
|
433
|
+
const files = listFiles(stack.hooksDir);
|
|
434
|
+
const documented = readDocumentedInvocations(stack.root, files);
|
|
435
|
+
const sourceSurfaces = readManifestSurfaces(stack.root, files);
|
|
436
|
+
const sourceRegisters = new Set(
|
|
437
|
+
sourceSurfaces.flatMap(surface => [...surface.registers])
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
for (const file of files) {
|
|
441
|
+
const kind = classifyHookFile(file);
|
|
442
|
+
if (kind === "manifest") continue;
|
|
443
|
+
|
|
444
|
+
const registeredOn = surfaces.filter(surface =>
|
|
445
|
+
surface.registers.has(file)
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
if (kind === "support") {
|
|
449
|
+
counts.supportModules += 1;
|
|
450
|
+
// The extension exempts it from registration; a sibling that invokes it
|
|
451
|
+
// is what earns the exemption, so a dead support file cannot hide
|
|
452
|
+
// behind its extension.
|
|
453
|
+
const invokedBy = siblingsInvoking(stack.hooksDir, file, files);
|
|
454
|
+
if (invokedBy.length === 0) {
|
|
455
|
+
violations.push({
|
|
456
|
+
kind: "support-module-orphan",
|
|
457
|
+
hook: file,
|
|
458
|
+
surface: `plugins/src/${stack.stack}`,
|
|
459
|
+
detail: `${file} is exempt from manifest registration as a support module, but no sibling in plugins/src/${stack.stack}/hooks invokes it — nothing runs it on any surface.`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (kind === "adapter") {
|
|
466
|
+
counts.adapters += 1;
|
|
467
|
+
const agy = registeredOn.filter(surface => surface.agent === "agy");
|
|
468
|
+
if (agy.length === 0) {
|
|
469
|
+
violations.push({
|
|
470
|
+
kind: "adapter-unregistered",
|
|
471
|
+
hook: file,
|
|
472
|
+
surface: "agy",
|
|
473
|
+
detail: `${file} is an Antigravity protocol adapter in plugins/src/${stack.stack}/hooks, but no Antigravity manifest registers it. The Antigravity generator keeps its own hook table (scripts/generate-agy-plugin-artifacts.mjs), so an adapter missing from that table emits no Antigravity artifact and the build still succeeds.`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const offSurface = registeredOn.filter(
|
|
477
|
+
surface => surface.agent !== "agy"
|
|
478
|
+
);
|
|
479
|
+
if (offSurface.length > 0) {
|
|
480
|
+
const named = offSurface.map(surface => surface.manifest).join(", ");
|
|
481
|
+
violations.push({
|
|
482
|
+
kind: "adapter-off-surface",
|
|
483
|
+
hook: file,
|
|
484
|
+
surface: named,
|
|
485
|
+
detail: `${file} is an Antigravity-protocol adapter and belongs on the Antigravity surface only, but ${named} registers it.`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
counts.entryPoints += 1;
|
|
492
|
+
|
|
493
|
+
// A `.sh` can also be a support LIBRARY that sibling entry points source
|
|
494
|
+
// or exec. Same exemption as a `.mjs` support module, earned the same way
|
|
495
|
+
// — by a sibling that actually invokes it — so the shape is recognised
|
|
496
|
+
// without a list naming which files are libraries.
|
|
497
|
+
const libraryFor = siblingsInvoking(stack.hooksDir, file, files);
|
|
498
|
+
|
|
499
|
+
// 2. Reachable by at least one declared channel, or declared unshipped
|
|
500
|
+
// for every agent by the repository's own per-agent ship list.
|
|
501
|
+
const reachable =
|
|
502
|
+
registeredOn.length > 0 ||
|
|
503
|
+
dispatcherGuards.has(file) ||
|
|
504
|
+
documented.has(file) ||
|
|
505
|
+
libraryFor.length > 0;
|
|
506
|
+
if (!reachable && !declaredUnshipped(file)) {
|
|
507
|
+
violations.push({
|
|
508
|
+
kind: "unregistered-entry-point",
|
|
509
|
+
hook: file,
|
|
510
|
+
surface: `plugins/src/${stack.stack}`,
|
|
511
|
+
detail: `${file} is a hook entry point that no plugin manifest registers, the host enforcement dispatcher does not run, no sibling invokes, and no shipped skill or command calls. It ships and never fires.`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 3. The ship-list declaration is load-bearing in both directions, so it
|
|
516
|
+
// cannot be used as a quiet exemption: a script declared unshipped for
|
|
517
|
+
// an agent must not still be registered on that agent's surface.
|
|
518
|
+
for (const surface of registeredOn) {
|
|
519
|
+
if (shouldShipScript(file, surface.agent)) continue;
|
|
520
|
+
violations.push({
|
|
521
|
+
kind: "ship-list-contradiction",
|
|
522
|
+
hook: file,
|
|
523
|
+
surface: surface.manifest,
|
|
524
|
+
detail: `scripts/lib/per-agent-hook-filter.mjs declares ${file} unshipped for ${surface.agent}, but ${surface.manifest} registers it. One of the two is wrong.`,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 4. Every port is generated from the base Claude manifest, so an entry
|
|
529
|
+
// point registered on any port must be registered at the source. This
|
|
530
|
+
// is the arm that survives a list-append merge resolution dropping one
|
|
531
|
+
// member of a matcher array: the ports still carry it, the source does
|
|
532
|
+
// not, and the next build erases it everywhere.
|
|
533
|
+
if (registeredOn.length > 0 && !sourceRegisters.has(file)) {
|
|
534
|
+
violations.push({
|
|
535
|
+
kind: "source-registration-missing",
|
|
536
|
+
hook: file,
|
|
537
|
+
surface: `plugins/src/${stack.stack}`,
|
|
538
|
+
detail: `${file} is registered on ${registeredOn.map(surface => surface.manifest).join(", ")} but not in plugins/src/${stack.stack}'s own manifest, which every port is generated from. The next build drops it from every surface.`,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 5. Fan-out: a port that both ships the script and serves an agent the
|
|
543
|
+
// ship list says it ships to must register it. This is the arm that
|
|
544
|
+
// catches a generated port whose entry never landed while its source
|
|
545
|
+
// still has it.
|
|
546
|
+
if (!sourceRegisters.has(file)) continue;
|
|
547
|
+
for (const surface of surfaces) {
|
|
548
|
+
if (!surface.shipped.has(file)) continue;
|
|
549
|
+
if (!shouldShipScript(file, surface.agent)) continue;
|
|
550
|
+
// On Antigravity the canonical guard is reached through its adapter, so
|
|
551
|
+
// the adapter's registration is the guard's registration.
|
|
552
|
+
const registered =
|
|
553
|
+
surface.registers.has(file) ||
|
|
554
|
+
(surface.agent === "agy" && surface.registers.has(adapterFor(file)));
|
|
555
|
+
if (registered) continue;
|
|
556
|
+
violations.push({
|
|
557
|
+
kind: "port-registration-missing",
|
|
558
|
+
hook: file,
|
|
559
|
+
surface: surface.manifest,
|
|
560
|
+
detail: `plugins/${surface.port}/hooks/${file} ships and plugins/src/${stack.stack} registers it, but ${surface.manifest} does not — on ${surface.agent} it is present and inert.`,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
scanned: {
|
|
568
|
+
stacks: stacks.length,
|
|
569
|
+
ports: ports.length,
|
|
570
|
+
surfaces: surfaces.length,
|
|
571
|
+
...counts,
|
|
572
|
+
},
|
|
573
|
+
surfaces: surfaces.map(surface => surface.manifest),
|
|
574
|
+
violations: violations.sort(
|
|
575
|
+
(a, b) =>
|
|
576
|
+
a.kind.localeCompare(b.kind) ||
|
|
577
|
+
a.hook.localeCompare(b.hook) ||
|
|
578
|
+
a.surface.localeCompare(b.surface)
|
|
579
|
+
),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Human-readable rendering of an audit result.
|
|
585
|
+
*
|
|
586
|
+
* @param {ReturnType<typeof auditHookRegistration>} result
|
|
587
|
+
* @returns {string}
|
|
588
|
+
*/
|
|
589
|
+
export function formatAudit(result) {
|
|
590
|
+
const { scanned, violations } = result;
|
|
591
|
+
const header = `Scanned ${scanned.entryPoints} entry points, ${scanned.adapters} adapters and ${scanned.supportModules} support modules across ${scanned.stacks} generator sources and ${scanned.surfaces} manifests in ${scanned.ports} ports.`;
|
|
592
|
+
if (violations.length === 0)
|
|
593
|
+
return `${header}\nEvery shipped hook is registered.`;
|
|
594
|
+
const lines = violations.map(
|
|
595
|
+
violation =>
|
|
596
|
+
` [${violation.kind}] ${violation.hook} (${violation.surface})\n ${violation.detail}`
|
|
597
|
+
);
|
|
598
|
+
return `${header}\n${violations.length} violation(s):\n${lines.join("\n")}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Runnable by hand for the same answer the test asserts. The enforcing surface
|
|
602
|
+
// is the vitest suite (a check that only prints is a check nobody fails on), so
|
|
603
|
+
// this exits non-zero rather than reporting and returning 0.
|
|
604
|
+
if (invokedAsScript(import.meta.url)) {
|
|
605
|
+
const result = auditHookRegistration();
|
|
606
|
+
const report = formatAudit(result);
|
|
607
|
+
if (result.violations.length > 0) {
|
|
608
|
+
console.error(report);
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
console.log(report);
|
|
612
|
+
}
|