@monte3l/groundwork 0.0.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.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,916 @@
1
+ /**
2
+ * Grades this project's Claude Code harness (`.claude/` plus `CLAUDE.md`).
3
+ * `gradeHarness` reads everything once into a snapshot, then runs each rule
4
+ * over it. Structural rules catch wiring defects -- a hook registration that
5
+ * points at nothing, a skill with no frontmatter -- and fail the gate.
6
+ * Rubric rules encode Anthropic's published guidance and only ever warn.
7
+ *
8
+ * This file is the emitted twin of m3l-groundwork's own
9
+ * `packages/cli/src/harness/{rules,grade}.ts`; a parity test runs both over
10
+ * the real baseline and asserts identical findings.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
14
+ import { join, relative } from "node:path";
15
+ import { fieldList, fieldText, parseFrontmatter } from "./frontmatter.mjs";
16
+
17
+ /**
18
+ * Model ids and aliases considered current. Bump alongside a harness-guidance
19
+ * refresh sweep.
20
+ * @public Exported for the parity test that compares this file with its TypeScript twin in
21
+ * m3l-groundwork; nothing else in a bootstrapped project imports it.
22
+ */
23
+ export const CURRENT_MODELS = [
24
+ "inherit",
25
+ "opus",
26
+ "sonnet",
27
+ "haiku",
28
+ "fable",
29
+ "claude-opus-5",
30
+ "claude-sonnet-5",
31
+ "claude-fable-5-1",
32
+ "claude-haiku-4-5",
33
+ "claude-haiku-4-5-20251001",
34
+ ];
35
+
36
+ const SKILL_BODY_LINE_LIMIT = 500;
37
+ const DESCRIPTION_MIN = 40;
38
+ const DESCRIPTION_MAX = 1024;
39
+ const PROJECT_WALK_DEPTH = 8;
40
+ const BARE_ENTRY_POINT =
41
+ /process\.argv\[1\]\s*===\s*fileURLToPath\(import\.meta\.url\)/;
42
+ const HOOK_PATH = /\.claude\/hooks\/([A-Za-z0-9_.-]+)/g;
43
+ const CLAUDE_PATH = /\.claude\/[A-Za-z0-9_.*/-]+/g;
44
+ const REFERENCE_PATH = /\breferences\/[A-Za-z0-9_./-]+\.md/g;
45
+ const SKIP_DIR_NAMES = new Set([
46
+ "node_modules",
47
+ ".git",
48
+ "dist",
49
+ "build",
50
+ "coverage",
51
+ ".next",
52
+ ".turbo",
53
+ ".cache",
54
+ ".pnpm",
55
+ "out",
56
+ ".nx",
57
+ ]);
58
+
59
+ const isRecord = (value) =>
60
+ typeof value === "object" && value !== null && !Array.isArray(value);
61
+
62
+ // --- reading the project ---------------------------------------------------
63
+
64
+ /** Bounded recursive listing: skips dependency/build dirs, stops at `maxDepth`. */
65
+ function walkBounded(root, maxDepth) {
66
+ const results = [];
67
+ const visit = (dir, depth) => {
68
+ if (depth > maxDepth) return;
69
+ let entries;
70
+ try {
71
+ entries = readdirSync(dir, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ if (entry.isDirectory() && SKIP_DIR_NAMES.has(entry.name)) continue;
77
+ const path = join(dir, entry.name);
78
+ results.push({
79
+ path,
80
+ relPath: relative(root, path).split("\\").join("/"),
81
+ isDirectory: entry.isDirectory(),
82
+ });
83
+ if (entry.isDirectory()) visit(path, depth + 1);
84
+ }
85
+ };
86
+ visit(root, 0);
87
+ return results;
88
+ }
89
+
90
+ /** Strips `//` and block comments and trailing commas, leaving string literals alone. */
91
+ function stripJsoncNoise(content) {
92
+ let result = "";
93
+ let inString = false;
94
+ let inLineComment = false;
95
+ let inBlockComment = false;
96
+ for (let i = 0; i < content.length; i++) {
97
+ const ch = content[i];
98
+ const next = content[i + 1];
99
+ if (inLineComment) {
100
+ if (ch === "\n") {
101
+ inLineComment = false;
102
+ result += ch;
103
+ }
104
+ continue;
105
+ }
106
+ if (inBlockComment) {
107
+ if (ch === "*" && next === "/") {
108
+ inBlockComment = false;
109
+ i++;
110
+ }
111
+ continue;
112
+ }
113
+ if (inString) {
114
+ result += ch;
115
+ if (ch === "\\") {
116
+ result += next ?? "";
117
+ i++;
118
+ continue;
119
+ }
120
+ if (ch === '"') inString = false;
121
+ continue;
122
+ }
123
+ if (ch === '"') {
124
+ inString = true;
125
+ result += ch;
126
+ continue;
127
+ }
128
+ if (ch === "/" && next === "/") {
129
+ inLineComment = true;
130
+ i++;
131
+ continue;
132
+ }
133
+ if (ch === "/" && next === "*") {
134
+ inBlockComment = true;
135
+ i++;
136
+ continue;
137
+ }
138
+ result += ch;
139
+ }
140
+ return result.replace(/,(\s*[}\]])/g, "$1");
141
+ }
142
+
143
+ function readJsonc(path) {
144
+ if (!existsSync(path)) return { ok: false, error: `${path} does not exist` };
145
+ try {
146
+ return {
147
+ ok: true,
148
+ value: JSON.parse(stripJsoncNoise(readFileSync(path, "utf8"))),
149
+ };
150
+ } catch (error) {
151
+ return {
152
+ ok: false,
153
+ error: error instanceof Error ? error.message : String(error),
154
+ };
155
+ }
156
+ }
157
+
158
+ function readText(path) {
159
+ try {
160
+ return readFileSync(path, "utf8");
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ function loadSnapshot(root) {
167
+ const entries = walkBounded(root, PROJECT_WALK_DEPTH);
168
+ const claudeEntries = entries.filter((entry) =>
169
+ entry.relPath.startsWith(".claude/"),
170
+ );
171
+
172
+ const readEach = (pattern, strip) =>
173
+ new Map(
174
+ claudeEntries
175
+ .filter((entry) => !entry.isDirectory && pattern.test(entry.relPath))
176
+ .map((entry) => [
177
+ entry.relPath.slice(strip.length),
178
+ readText(entry.path) ?? "",
179
+ ]),
180
+ );
181
+
182
+ const skills = claudeEntries
183
+ .filter(
184
+ (entry) =>
185
+ entry.isDirectory && /^\.claude\/skills\/[^/]+$/.test(entry.relPath),
186
+ )
187
+ .map((dir) => {
188
+ const prefix = `${dir.relPath}/`;
189
+ const files = claudeEntries
190
+ .filter(
191
+ (entry) => !entry.isDirectory && entry.relPath.startsWith(prefix),
192
+ )
193
+ .map((entry) => entry.relPath.slice(prefix.length));
194
+ return {
195
+ name: dir.relPath.slice(".claude/skills/".length),
196
+ skillMd: files.includes("SKILL.md")
197
+ ? readText(join(dir.path, "SKILL.md"))
198
+ : undefined,
199
+ files,
200
+ };
201
+ });
202
+
203
+ const settingsPath = join(root, ".claude", "settings.json");
204
+ const settingsResult = readJsonc(settingsPath);
205
+ const settingsLocalResult = readJsonc(
206
+ join(root, ".claude", "settings.local.json"),
207
+ );
208
+
209
+ return {
210
+ settings: !existsSync(settingsPath)
211
+ ? { present: false, error: undefined, parsed: undefined }
212
+ : settingsResult.ok
213
+ ? { present: true, error: undefined, parsed: settingsResult.value }
214
+ : { present: true, error: settingsResult.error, parsed: undefined },
215
+ settingsLocal: settingsLocalResult.ok
216
+ ? settingsLocalResult.value
217
+ : undefined,
218
+ hooks: readEach(/^\.claude\/hooks\/[^/]+$/, ".claude/hooks/"),
219
+ agents: readEach(/^\.claude\/agents\/[^/]+\.md$/, ".claude/agents/"),
220
+ skills,
221
+ rules: readEach(/^\.claude\/rules\/[^/]+\.md$/, ".claude/rules/"),
222
+ claudeMd: readText(join(root, "CLAUDE.md")),
223
+ claudeTree: new Set(claudeEntries.map((entry) => entry.relPath)),
224
+ projectFiles: entries
225
+ .filter((entry) => !entry.isDirectory)
226
+ .map((entry) => entry.relPath),
227
+ };
228
+ }
229
+
230
+ // --- helpers ---------------------------------------------------------------
231
+
232
+ function hookRegistrations(settings) {
233
+ if (!isRecord(settings) || !isRecord(settings["hooks"])) return [];
234
+ const registrations = [];
235
+ for (const [event, entries] of Object.entries(settings["hooks"])) {
236
+ if (!Array.isArray(entries)) continue;
237
+ for (const entry of entries) {
238
+ if (!isRecord(entry) || !Array.isArray(entry["hooks"])) continue;
239
+ for (const hook of entry["hooks"]) {
240
+ if (!isRecord(hook) || typeof hook["command"] !== "string") continue;
241
+ registrations.push({
242
+ event,
243
+ command: hook["command"],
244
+ hasTimeout: typeof hook["timeout"] === "number",
245
+ });
246
+ }
247
+ }
248
+ }
249
+ return registrations;
250
+ }
251
+
252
+ function allRegistrations(snapshot) {
253
+ return [
254
+ ...hookRegistrations(snapshot.settings.parsed),
255
+ ...hookRegistrations(snapshot.settingsLocal),
256
+ ];
257
+ }
258
+
259
+ /** Every string anywhere inside a parsed JSON value. */
260
+ function collectStrings(value) {
261
+ if (typeof value === "string") return [value];
262
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
263
+ if (isRecord(value)) return Object.values(value).flatMap(collectStrings);
264
+ return [];
265
+ }
266
+
267
+ /**
268
+ * Hook files named by any command in settings -- `hooks` registrations and
269
+ * also top-level command keys such as `statusLine` -- named directly, since
270
+ * a wiring reference is a wiring reference wherever it sits.
271
+ */
272
+ function registeredHookFiles(snapshot) {
273
+ const names = new Set();
274
+ for (const text of [
275
+ ...collectStrings(snapshot.settings.parsed),
276
+ ...collectStrings(snapshot.settingsLocal),
277
+ ]) {
278
+ for (const match of text.matchAll(HOOK_PATH)) {
279
+ if (match[1] !== undefined) names.add(match[1]);
280
+ }
281
+ }
282
+ return names;
283
+ }
284
+
285
+ /**
286
+ * `registeredHookFiles` plus every hook file a reachable hook's source
287
+ * mentions by name -- a helper module imported by a registered script is
288
+ * wired in even though no settings command names it. Followed to a
289
+ * fixpoint, so a helper's own helpers count too.
290
+ */
291
+ function reachableHookFiles(snapshot) {
292
+ const reachable = registeredHookFiles(snapshot);
293
+ let grew = true;
294
+ while (grew) {
295
+ grew = false;
296
+ for (const name of snapshot.hooks.keys()) {
297
+ if (reachable.has(name)) continue;
298
+ const mentioned = [...reachable].some((other) =>
299
+ snapshot.hooks.get(other)?.includes(name),
300
+ );
301
+ if (mentioned) {
302
+ reachable.add(name);
303
+ grew = true;
304
+ }
305
+ }
306
+ }
307
+ return reachable;
308
+ }
309
+
310
+ /** Translates a Claude Code `paths` glob (`**`, `*`, `?`, `{a,b}`) into a RegExp. */
311
+ function globToRegExp(glob) {
312
+ const translate = (source) => {
313
+ let out = "";
314
+ for (let i = 0; i < source.length; i++) {
315
+ const ch = source[i] ?? "";
316
+ if (ch === "*") {
317
+ if (source[i + 1] === "*") {
318
+ i++;
319
+ if (source[i + 1] === "/") {
320
+ i++;
321
+ out += "(?:.*/)?";
322
+ } else {
323
+ out += ".*";
324
+ }
325
+ } else {
326
+ out += "[^/]*";
327
+ }
328
+ } else if (ch === "?") {
329
+ out += "[^/]";
330
+ } else if (ch === "{" && source.indexOf("}", i) !== -1) {
331
+ const close = source.indexOf("}", i);
332
+ out += `(?:${source
333
+ .slice(i + 1, close)
334
+ .split(",")
335
+ .map(translate)
336
+ .join("|")})`;
337
+ i = close;
338
+ } else {
339
+ out += ch.replace(/[.+^$()|[\]\\{}]/g, "\\$&");
340
+ }
341
+ }
342
+ return out;
343
+ };
344
+ return new RegExp(`^${translate(glob)}$`);
345
+ }
346
+
347
+ function bodyLineCount(body) {
348
+ return body.replace(/\n+$/, "").split("\n").length;
349
+ }
350
+
351
+ // --- rules -----------------------------------------------------------------
352
+
353
+ const settingsParses = {
354
+ id: "settings-parses",
355
+ level: "structural",
356
+ category: "settings",
357
+ check: (s) => ({
358
+ checked: s.settings.present ? 1 : 0,
359
+ failures:
360
+ s.settings.error === undefined
361
+ ? []
362
+ : [
363
+ {
364
+ subject: ".claude/settings.json",
365
+ message: `does not parse: ${s.settings.error}`,
366
+ },
367
+ ],
368
+ }),
369
+ };
370
+
371
+ const hookDangling = {
372
+ id: "hook-dangling",
373
+ level: "structural",
374
+ category: "hooks",
375
+ check: (s) => {
376
+ if (s.settings.error !== undefined) return { checked: 0, failures: [] };
377
+ const referenced = registeredHookFiles(s);
378
+ return {
379
+ checked: referenced.size,
380
+ failures: [...referenced]
381
+ .filter((name) => !s.hooks.has(name))
382
+ .map((name) => ({
383
+ subject: `.claude/hooks/${name}`,
384
+ message: "is named by a hook registration but does not exist on disk",
385
+ })),
386
+ };
387
+ },
388
+ };
389
+
390
+ const hookOrphan = {
391
+ id: "hook-orphan",
392
+ level: "structural",
393
+ category: "hooks",
394
+ check: (s) => {
395
+ if (s.settings.error !== undefined) return { checked: 0, failures: [] };
396
+ const referenced = reachableHookFiles(s);
397
+ const hookFiles = [...s.hooks.keys()].filter(
398
+ (name) => name.endsWith(".mjs") || name.endsWith(".js"),
399
+ );
400
+ return {
401
+ checked: hookFiles.length,
402
+ failures: hookFiles
403
+ .filter((name) => !referenced.has(name))
404
+ .map((name) => ({
405
+ subject: `.claude/hooks/${name}`,
406
+ message:
407
+ "exists but nothing runs it -- no settings.json command names it and no registered hook imports it",
408
+ })),
409
+ };
410
+ },
411
+ };
412
+
413
+ const hookEntrypoint = {
414
+ id: "hook-entrypoint",
415
+ level: "structural",
416
+ category: "hooks",
417
+ check: (s) => {
418
+ const sources = [...s.hooks].filter(([, source]) =>
419
+ source.includes("process.argv[1]"),
420
+ );
421
+ return {
422
+ checked: sources.length,
423
+ failures: sources
424
+ .filter(
425
+ ([, source]) =>
426
+ BARE_ENTRY_POINT.test(source) ||
427
+ !source.includes("realpathSync(process.argv[1])"),
428
+ )
429
+ .map(([name]) => ({
430
+ subject: `.claude/hooks/${name}`,
431
+ message:
432
+ "compares process.argv[1] to import.meta.url without realpathSync -- false under any symlinked path, so the hook fails open",
433
+ })),
434
+ };
435
+ },
436
+ };
437
+
438
+ const skillShape = {
439
+ id: "skill-shape",
440
+ level: "structural",
441
+ category: "skills",
442
+ check: (s) => {
443
+ const failures = [];
444
+ for (const skill of s.skills) {
445
+ const subject = `.claude/skills/${skill.name}`;
446
+ if (skill.skillMd === undefined) {
447
+ failures.push({ subject, message: "has no SKILL.md" });
448
+ continue;
449
+ }
450
+ const parsed = parseFrontmatter(skill.skillMd);
451
+ if (!parsed.ok) {
452
+ failures.push({ subject, message: `SKILL.md: ${parsed.error}` });
453
+ continue;
454
+ }
455
+ for (const problem of parsed.problems) {
456
+ failures.push({ subject, message: `SKILL.md: ${problem}` });
457
+ }
458
+ // `name` is optional for a skill (it defaults to the directory), so
459
+ // only a name that is present and different is a wiring defect.
460
+ const name = fieldText(parsed.fields, "name");
461
+ if (name !== undefined && name !== "" && name !== skill.name) {
462
+ failures.push({
463
+ subject,
464
+ message: `SKILL.md \`name\` is "${name}" but the directory is "${skill.name}"`,
465
+ });
466
+ }
467
+ }
468
+ return { checked: s.skills.length, failures };
469
+ },
470
+ };
471
+
472
+ const agentShape = {
473
+ id: "agent-shape",
474
+ level: "structural",
475
+ category: "agents",
476
+ check: (s) => {
477
+ const failures = [];
478
+ for (const [file, text] of s.agents) {
479
+ const subject = `.claude/agents/${file}`;
480
+ const parsed = parseFrontmatter(text);
481
+ if (!parsed.ok) {
482
+ failures.push({ subject, message: parsed.error });
483
+ continue;
484
+ }
485
+ for (const problem of parsed.problems) {
486
+ failures.push({ subject, message: problem });
487
+ }
488
+ const expected = file.replace(/\.md$/, "");
489
+ const name = fieldText(parsed.fields, "name");
490
+ if (name === undefined || name === "") {
491
+ failures.push({ subject, message: "has no `name`" });
492
+ } else if (name !== expected) {
493
+ failures.push({
494
+ subject,
495
+ message: `\`name\` is "${name}" but the file is "${file}"`,
496
+ });
497
+ }
498
+ const description = fieldText(parsed.fields, "description");
499
+ if (description === undefined || description === "") {
500
+ failures.push({ subject, message: "has no `description`" });
501
+ }
502
+ }
503
+ return { checked: s.agents.size, failures };
504
+ },
505
+ };
506
+
507
+ const ruleShape = {
508
+ id: "rule-shape",
509
+ level: "structural",
510
+ category: "rules",
511
+ check: (s) => {
512
+ const failures = [];
513
+ for (const [file, text] of s.rules) {
514
+ const subject = `.claude/rules/${file}`;
515
+ // A rule with no frontmatter at all is a legitimate unconditional rule.
516
+ if (!text.startsWith("---")) continue;
517
+ const parsed = parseFrontmatter(text);
518
+ if (!parsed.ok) {
519
+ failures.push({ subject, message: parsed.error });
520
+ continue;
521
+ }
522
+ for (const problem of parsed.problems) {
523
+ failures.push({ subject, message: problem });
524
+ }
525
+ const paths = fieldList(parsed.fields, "paths");
526
+ if (paths !== undefined && paths.length === 0) {
527
+ failures.push({ subject, message: "declares `paths` but it is empty" });
528
+ }
529
+ }
530
+ return { checked: s.rules.size, failures };
531
+ },
532
+ };
533
+
534
+ const claudeMdRefs = {
535
+ id: "claudemd-refs",
536
+ level: "structural",
537
+ category: "claude-md",
538
+ check: (s) => {
539
+ if (s.claudeMd === undefined) return { checked: 0, failures: [] };
540
+ const failures = [];
541
+ const refs = new Set();
542
+ for (const match of s.claudeMd.matchAll(CLAUDE_PATH)) {
543
+ const path = match[0].replace(/[.,;:]+$/, "").replace(/\/+$/, "");
544
+ if (path.includes("*") || path === ".claude/settings.local.json") {
545
+ continue;
546
+ }
547
+ if (path === ".claude") continue;
548
+ refs.add(path);
549
+ }
550
+ for (const path of refs) {
551
+ if (!s.claudeTree.has(path)) {
552
+ failures.push({
553
+ subject: "CLAUDE.md",
554
+ message: `names ${path}, which does not exist`,
555
+ });
556
+ }
557
+ }
558
+ for (const file of s.rules.keys()) {
559
+ if (!s.claudeMd.includes(`.claude/rules/${file}`)) {
560
+ failures.push({
561
+ subject: `.claude/rules/${file}`,
562
+ message: "exists but CLAUDE.md never names it",
563
+ });
564
+ }
565
+ }
566
+ return { checked: refs.size + s.rules.size, failures };
567
+ },
568
+ };
569
+
570
+ const skillBodySize = {
571
+ id: "skill-body-size",
572
+ level: "rubric",
573
+ category: "skills",
574
+ check: (s) => {
575
+ const failures = [];
576
+ let checked = 0;
577
+ for (const skill of s.skills) {
578
+ if (skill.skillMd === undefined) continue;
579
+ const parsed = parseFrontmatter(skill.skillMd);
580
+ if (!parsed.ok) continue;
581
+ checked++;
582
+ const lines = bodyLineCount(parsed.body);
583
+ if (lines > SKILL_BODY_LINE_LIMIT) {
584
+ failures.push({
585
+ subject: `.claude/skills/${skill.name}`,
586
+ message: `SKILL.md body is ${lines} lines; Anthropic's guidance is <= ${SKILL_BODY_LINE_LIMIT} -- move detail into references/`,
587
+ });
588
+ }
589
+ }
590
+ return { checked, failures };
591
+ },
592
+ };
593
+
594
+ const descriptionSubstance = {
595
+ id: "description-substance",
596
+ level: "rubric",
597
+ category: "skills",
598
+ check: (s) => {
599
+ const failures = [];
600
+ let checked = 0;
601
+ const examine = (subject, text, required) => {
602
+ const parsed = parseFrontmatter(text);
603
+ if (!parsed.ok) return;
604
+ const description = fieldText(parsed.fields, "description");
605
+ if (description === undefined || description === "") {
606
+ // Agents require one and shape-check it; a skill may omit it (Claude
607
+ // falls back to the first paragraph), which is a quality gap.
608
+ if (required) return;
609
+ checked++;
610
+ failures.push({
611
+ subject,
612
+ message:
613
+ "has no `description`, so Claude has nothing to match the skill against but its first paragraph",
614
+ });
615
+ return;
616
+ }
617
+ checked++;
618
+ if (description.length < DESCRIPTION_MIN) {
619
+ failures.push({
620
+ subject,
621
+ message: `description is ${description.length} chars -- too thin to trigger on reliably (>= ${DESCRIPTION_MIN})`,
622
+ });
623
+ } else if (description.length > DESCRIPTION_MAX) {
624
+ failures.push({
625
+ subject,
626
+ message: `description is ${description.length} chars; over ${DESCRIPTION_MAX} it is truncated in the skill listing`,
627
+ });
628
+ }
629
+ };
630
+ for (const skill of s.skills) {
631
+ if (skill.skillMd !== undefined) {
632
+ examine(`.claude/skills/${skill.name}`, skill.skillMd, false);
633
+ }
634
+ }
635
+ for (const [file, text] of s.agents) {
636
+ examine(`.claude/agents/${file}`, text, true);
637
+ }
638
+ return { checked, failures };
639
+ },
640
+ };
641
+
642
+ const modelPinCurrency = {
643
+ id: "model-pin-currency",
644
+ level: "rubric",
645
+ category: "agents",
646
+ check: (s) => {
647
+ const failures = [];
648
+ let checked = 0;
649
+ for (const [file, text] of s.agents) {
650
+ const parsed = parseFrontmatter(text);
651
+ if (!parsed.ok) continue;
652
+ checked++;
653
+ const model = fieldText(parsed.fields, "model");
654
+ const subject = `.claude/agents/${file}`;
655
+ if (model === undefined || model === "") {
656
+ failures.push({
657
+ subject,
658
+ message: "pins no `model`, so it inherits whatever the session runs",
659
+ });
660
+ } else if (!CURRENT_MODELS.includes(model)) {
661
+ failures.push({
662
+ subject,
663
+ message: `\`model: ${model}\` is not a known-current model id or alias`,
664
+ });
665
+ }
666
+ }
667
+ return { checked, failures };
668
+ },
669
+ };
670
+
671
+ const agentToolScope = {
672
+ id: "agent-tool-scope",
673
+ level: "rubric",
674
+ category: "agents",
675
+ check: (s) => {
676
+ const failures = [];
677
+ let checked = 0;
678
+ for (const [file, text] of s.agents) {
679
+ const parsed = parseFrontmatter(text);
680
+ if (!parsed.ok) continue;
681
+ checked++;
682
+ if (fieldText(parsed.fields, "tools") === undefined) {
683
+ failures.push({
684
+ subject: `.claude/agents/${file}`,
685
+ message: "declares no `tools`, so it inherits every tool",
686
+ });
687
+ }
688
+ }
689
+ return { checked, failures };
690
+ },
691
+ };
692
+
693
+ const hookTimeout = {
694
+ id: "hook-timeout",
695
+ level: "rubric",
696
+ category: "hooks",
697
+ check: (s) => {
698
+ const registrations = allRegistrations(s);
699
+ return {
700
+ checked: registrations.length,
701
+ failures: registrations
702
+ .filter((registration) => !registration.hasTimeout)
703
+ .map((registration) => ({
704
+ subject: `${registration.event} hook`,
705
+ message: `\`${registration.command}\` has no \`timeout\``,
706
+ })),
707
+ };
708
+ },
709
+ };
710
+
711
+ const ruleGlobsLive = {
712
+ id: "rule-globs-live",
713
+ level: "rubric",
714
+ category: "rules",
715
+ check: (s) => {
716
+ const failures = [];
717
+ let checked = 0;
718
+ for (const [file, text] of s.rules) {
719
+ const parsed = parseFrontmatter(text);
720
+ if (!parsed.ok) continue;
721
+ for (const glob of fieldList(parsed.fields, "paths") ?? []) {
722
+ checked++;
723
+ const pattern = globToRegExp(glob);
724
+ if (!s.projectFiles.some((path) => pattern.test(path))) {
725
+ failures.push({
726
+ subject: `.claude/rules/${file}`,
727
+ message: `\`paths\` glob "${glob}" matches no file in the project, so the rule never loads`,
728
+ });
729
+ }
730
+ }
731
+ }
732
+ return { checked, failures };
733
+ },
734
+ };
735
+
736
+ const skillReferencesResolve = {
737
+ id: "skill-references-resolve",
738
+ level: "rubric",
739
+ category: "skills",
740
+ check: (s) => {
741
+ const failures = [];
742
+ let checked = 0;
743
+ for (const skill of s.skills) {
744
+ if (skill.skillMd === undefined) continue;
745
+ const referenced = new Set(
746
+ [...skill.skillMd.matchAll(REFERENCE_PATH)].map((match) => match[0]),
747
+ );
748
+ for (const path of referenced) {
749
+ checked++;
750
+ if (!skill.files.includes(path)) {
751
+ failures.push({
752
+ subject: `.claude/skills/${skill.name}`,
753
+ message: `SKILL.md points at ${path}, which does not exist`,
754
+ });
755
+ }
756
+ }
757
+ }
758
+ return { checked, failures };
759
+ },
760
+ };
761
+
762
+ /**
763
+ * Every rule, structural first. Order is the order findings are reported in.
764
+ * @public Exported for the parity test that compares this file with its TypeScript twin in
765
+ * m3l-groundwork; nothing else in a bootstrapped project imports it.
766
+ */
767
+ export const RULES = [
768
+ settingsParses,
769
+ hookDangling,
770
+ hookOrphan,
771
+ hookEntrypoint,
772
+ skillShape,
773
+ agentShape,
774
+ ruleShape,
775
+ claudeMdRefs,
776
+ skillBodySize,
777
+ descriptionSubstance,
778
+ modelPinCurrency,
779
+ agentToolScope,
780
+ hookTimeout,
781
+ ruleGlobsLive,
782
+ skillReferencesResolve,
783
+ ];
784
+
785
+ const CATEGORIES = [
786
+ "settings",
787
+ "hooks",
788
+ "skills",
789
+ "agents",
790
+ "rules",
791
+ "claude-md",
792
+ ];
793
+
794
+ /**
795
+ * Feeds a grade to a `createReporter()` reporter: structural findings fail,
796
+ * rubric findings warn, and a summary line closes the run. Kept here rather
797
+ * than in each gate script so the repo's own wrapper and the emitted gate
798
+ * cannot report the same grade differently.
799
+ * @param {ReturnType<typeof gradeHarness>} grade
800
+ * @param {{ ok: (m: string) => void, warn: (m: string) => void, fail: (m: string) => void }} reporter
801
+ */
802
+ export function reportGrade(grade, reporter) {
803
+ for (const finding of grade.findings) {
804
+ const line = `[${finding.ruleId}] ${finding.subject} -- ${finding.message}`;
805
+ if (finding.level === "structural") {
806
+ reporter.fail(line);
807
+ } else {
808
+ reporter.warn(line);
809
+ }
810
+ }
811
+ const { checked, failed } = grade.structural;
812
+ if (failed === 0) {
813
+ reporter.ok(`harness wiring: ${checked} structural checks passed`);
814
+ }
815
+ const rubricChecked = Object.values(grade.rubric).reduce(
816
+ (sum, tally) => sum + tally.checked,
817
+ 0,
818
+ );
819
+ reporter.ok(
820
+ `harness rubric: ${(grade.rubricScore * 100).toFixed(0)}% over ${rubricChecked} checks (warnings never fail the gate)`,
821
+ );
822
+ }
823
+
824
+ /**
825
+ * Runs Anthropic's own `claude plugin validate --strict` over the skills and
826
+ * agents directories and relays what it reports. Its rules are Anthropic's,
827
+ * not ours, so they track the CLI without a hand-maintained list -- but they
828
+ * also change with the CLI version, so every finding is a warning and never
829
+ * fails the gate. Skipped quietly when the `claude` CLI is not installed
830
+ * (CI, a fresh machine).
831
+ * @param {string} rootDir
832
+ * @param {{ ok: (m: string) => void, warn: (m: string) => void }} reporter
833
+ */
834
+ export function reportOfficialValidation(rootDir, reporter) {
835
+ let ran = false;
836
+ let findings = 0;
837
+ for (const dir of [".claude/skills", ".claude/agents"]) {
838
+ const target = join(rootDir, dir);
839
+ if (!existsSync(target)) continue;
840
+ const result = spawnSync(
841
+ "claude",
842
+ ["plugin", "validate", "--strict", "--json", target],
843
+ { encoding: "utf8", timeout: 30_000 },
844
+ );
845
+ if (result.error?.code === "ENOENT") {
846
+ reporter.ok("claude CLI not found -- skipped Anthropic's own validator");
847
+ return;
848
+ }
849
+ let report;
850
+ try {
851
+ report = JSON.parse(result.stdout);
852
+ } catch {
853
+ reporter.warn(
854
+ `claude plugin validate gave no readable report for ${dir}`,
855
+ );
856
+ continue;
857
+ }
858
+ ran = true;
859
+ for (const entry of report.contents ?? []) {
860
+ for (const item of [...(entry.errors ?? []), ...(entry.warnings ?? [])]) {
861
+ findings++;
862
+ reporter.warn(
863
+ `[claude-validate] ${relative(rootDir, entry.file)} -- ${item.path}: ${item.message}`,
864
+ );
865
+ }
866
+ }
867
+ }
868
+ if (ran && findings === 0) {
869
+ reporter.ok("claude plugin validate: no findings");
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Runs every rule over the harness rooted at `rootDir`.
875
+ * @param {string} rootDir
876
+ */
877
+ export function gradeHarness(rootDir) {
878
+ const snapshot = loadSnapshot(rootDir);
879
+ const findings = [];
880
+ const structural = { checked: 0, failed: 0 };
881
+ const rubric = Object.fromEntries(
882
+ CATEGORIES.map((category) => [category, { checked: 0, failed: 0 }]),
883
+ );
884
+
885
+ for (const rule of RULES) {
886
+ const result = rule.check(snapshot);
887
+ const tally =
888
+ rule.level === "structural" ? structural : rubric[rule.category];
889
+ tally.checked += result.checked;
890
+ tally.failed += result.failures.length;
891
+ for (const failure of result.failures) {
892
+ findings.push({
893
+ ruleId: rule.id,
894
+ level: rule.level,
895
+ category: rule.category,
896
+ subject: failure.subject,
897
+ message: failure.message,
898
+ });
899
+ }
900
+ }
901
+
902
+ const rubricChecked = Object.values(rubric).reduce(
903
+ (sum, tally) => sum + tally.checked,
904
+ 0,
905
+ );
906
+ const rubricFailed = Object.values(rubric).reduce(
907
+ (sum, tally) => sum + tally.failed,
908
+ 0,
909
+ );
910
+ return {
911
+ findings,
912
+ structural,
913
+ rubric,
914
+ rubricScore: rubricChecked === 0 ? 1 : 1 - rubricFailed / rubricChecked,
915
+ };
916
+ }