@codyswann/lisa 4.37.3 → 4.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/all/copy-contents/.gitattributes +9 -0
  2. package/all/copy-overwrite/scripts/lisa-work-item.mjs +209 -8
  3. package/dist/core/learnings-merge-driver.d.ts +8 -0
  4. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  5. package/dist/core/learnings-merge-driver.js +17 -0
  6. package/dist/core/learnings-merge-driver.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  11. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.js +37 -5
  13. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  14. package/package.json +5 -4
  15. package/plugins/lisa/.claude-plugin/plugin.json +37 -1
  16. package/plugins/lisa/.codex-plugin/hooks.json +36 -0
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +8 -3
  19. package/plugins/lisa/hooks/discharge-work-item-gates.sh +54 -0
  20. package/plugins/lisa/hooks/withdrawn-rulings.mjs +525 -0
  21. package/plugins/lisa/hooks/withdrawn-rulings.sh +24 -0
  22. package/plugins/lisa/rules/eager/withdrawn-rulings.md +69 -0
  23. package/plugins/lisa/rules/reference/withdrawn-rulings.md +114 -0
  24. package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +8 -3
  25. package/plugins/lisa-agy/plugin.json +1 -1
  26. package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +8 -3
  27. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +28 -1
  33. package/plugins/lisa-copilot/hooks/discharge-work-item-gates.sh +54 -0
  34. package/plugins/lisa-copilot/hooks/withdrawn-rulings.mjs +525 -0
  35. package/plugins/lisa-copilot/hooks/withdrawn-rulings.sh +24 -0
  36. package/plugins/lisa-copilot/rules/eager/withdrawn-rulings.md +69 -0
  37. package/plugins/lisa-copilot/rules/reference/withdrawn-rulings.md +114 -0
  38. package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +8 -3
  39. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cursor/hooks/discharge-work-item-gates.sh +54 -0
  41. package/plugins/lisa-cursor/hooks/hooks.json +14 -0
  42. package/plugins/lisa-cursor/hooks/withdrawn-rulings.mjs +525 -0
  43. package/plugins/lisa-cursor/hooks/withdrawn-rulings.sh +24 -0
  44. package/plugins/lisa-cursor/rules/withdrawn-rulings-reference.mdc +119 -0
  45. package/plugins/lisa-cursor/rules/withdrawn-rulings.mdc +74 -0
  46. package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +8 -3
  47. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-agy/plugin.json +1 -1
  50. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  60. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  65. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  70. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-agy/plugin.json +1 -1
  75. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  80. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  85. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/src/base/.claude-plugin/plugin.json +36 -0
  88. package/plugins/src/base/hooks/discharge-work-item-gates.sh +54 -0
  89. package/plugins/src/base/hooks/withdrawn-rulings.mjs +525 -0
  90. package/plugins/src/base/hooks/withdrawn-rulings.sh +24 -0
  91. package/plugins/src/base/rules/eager/withdrawn-rulings.md +69 -0
  92. package/plugins/src/base/rules/reference/withdrawn-rulings.md +114 -0
  93. package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +8 -3
  94. package/scripts/lib/hook-registration-audit.mjs +612 -0
  95. package/scripts/lib/per-agent-hook-filter.mjs +19 -0
  96. 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
+ }
@@ -80,6 +80,25 @@ const SCRIPT_RULES = {
80
80
  agy: true,
81
81
  copilot: true,
82
82
  },
83
+ // Fires after a `gh pr create` / `gh pr edit` and checks the two work-item
84
+ // gates a push could not reach. Nothing in it is Claude-specific: it reads a
85
+ // shell command out of the tool payload and runs a node script.
86
+ //
87
+ // Documented gap: agy gets it nowhere, and not through this table. agy's
88
+ // plugin hooks are wired by the AGY_PLUGIN_HOOKS map in
89
+ // `generate-agy-plugin-artifacts.mjs`, which carries PreToolUse
90
+ // `run_command` entries only — there is no PostToolUse surface to attach to,
91
+ // so the flag below is documentation rather than a switch. The compensating
92
+ // rung is shared rather than agent-layer, which is the layer AGENTS.md
93
+ // requires: the pre-push report names both gates as UNRESOLVED with the
94
+ // discharge command on every agent, and CI enforces both regardless.
95
+ "discharge-work-item-gates.sh": {
96
+ claude: true,
97
+ codex: true,
98
+ cursor: true,
99
+ agy: false,
100
+ copilot: true,
101
+ },
83
102
  // Portable everywhere the agent has a session-start event, because the
84
103
  // question it answers — can this agent reach the credentials its work needs
85
104
  // — is not vendor-specific. It only reads config and asks the provider for