@lifeaitools/rdc-skills 0.25.0 → 0.25.2

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 (61) hide show
  1. package/.claude-plugin/plugin.json +1560 -1518
  2. package/.github/workflows/self-test.yml +34 -34
  3. package/CHANGELOG.md +322 -310
  4. package/MANIFEST.md +224 -221
  5. package/README.md +379 -377
  6. package/commands/build.md +181 -181
  7. package/commands/collab.md +180 -180
  8. package/commands/deploy.md +148 -148
  9. package/commands/fixit.md +150 -150
  10. package/commands/handoff.md +173 -173
  11. package/commands/overnight.md +220 -220
  12. package/commands/plan.md +158 -158
  13. package/commands/preplan.md +131 -131
  14. package/commands/prototype.md +145 -145
  15. package/commands/report.md +99 -99
  16. package/commands/review.md +120 -120
  17. package/commands/status.md +86 -86
  18. package/commands/workitems.md +127 -127
  19. package/git-sha.json +1 -1
  20. package/guides/agent-bootstrap.md +195 -195
  21. package/guides/agents/backend.md +102 -102
  22. package/guides/agents/content.md +94 -94
  23. package/guides/agents/cs2.md +56 -56
  24. package/guides/agents/data.md +86 -86
  25. package/guides/agents/design.md +77 -77
  26. package/guides/agents/frontend.md +91 -91
  27. package/guides/agents/infrastructure.md +81 -81
  28. package/guides/agents/setup.md +272 -272
  29. package/guides/agents/verify.md +119 -119
  30. package/guides/agents/viz.md +106 -106
  31. package/package.json +57 -57
  32. package/scripts/install-rdc-skills.js +1401 -1289
  33. package/scripts/self-test.mjs +1460 -1460
  34. package/scripts/validate-publish-manifests.js +502 -502
  35. package/skills/build/SKILL.md +574 -559
  36. package/skills/channel-formatter/SKILL.md +538 -538
  37. package/skills/collab/SKILL.md +239 -239
  38. package/skills/convert/SKILL.md +167 -167
  39. package/skills/deploy/SKILL.md +541 -541
  40. package/skills/design/SKILL.md +205 -205
  41. package/skills/env/SKILL.md +141 -0
  42. package/skills/fixit/SKILL.md +203 -203
  43. package/skills/fs-mcp/SKILL.md +37 -2
  44. package/skills/handoff/SKILL.md +236 -236
  45. package/skills/new-model/SKILL.md +49 -0
  46. package/skills/onramp/SKILL.md +1459 -248
  47. package/skills/overnight/SKILL.md +251 -251
  48. package/skills/plan/SKILL.md +345 -345
  49. package/skills/preplan/SKILL.md +90 -90
  50. package/skills/prototype/SKILL.md +150 -150
  51. package/skills/regen-media/SKILL.md +94 -0
  52. package/skills/release/SKILL.md +140 -140
  53. package/skills/report/SKILL.md +100 -100
  54. package/skills/review/SKILL.md +159 -151
  55. package/skills/self-test/SKILL.md +108 -108
  56. package/skills/status/SKILL.md +99 -99
  57. package/skills/tests/MATRIX.md +55 -54
  58. package/skills/tests/onramp.test.json +87 -87
  59. package/skills/tests/rdc-regen-media.test.json +29 -0
  60. package/skills/watch/SKILL.md +84 -84
  61. package/skills/workitems/SKILL.md +151 -151
@@ -1,1460 +1,1460 @@
1
- #!/usr/bin/env node
2
- // self-test.mjs — Tier 1 static linter for rdc-skills
3
- //
4
- // Validates every skill file in skills/ against a checklist:
5
- // 1. File parseable (readable, has frontmatter)
6
- // 2. Frontmatter YAML valid (name + description required)
7
- // 3. description does NOT start with backtick (breaks Claude Code parser)
8
- // 4. description contains a `Usage `rdc:<name>` marker matching this skill
9
- // 5. frontmatter name matches filename (rdc:foo ↔ rdc-foo.md)
10
- // 6. Every referenced guide file exists (guides/*.md references)
11
- // 7. Every referenced rule file exists (.claude/rules/*.md references)
12
- // 8. Body contains the standard output-contract banner
13
- // 9. Plugin manifest (.claude-plugin/plugin.json) exists, parses, has name+version,
14
- // and version matches package.json
15
- // 10. No duplicate skill names; no filename collisions with guides/agents/*.md
16
- // 11. Hook files referenced by skills exist; orphan hook files get a warning
17
- //
18
- // Usage:
19
- // node scripts/self-test.mjs # run all, human output, exit 1 on fail
20
- // node scripts/self-test.mjs --json # machine-readable schema v2
21
- // node scripts/self-test.mjs --skill rdc:foo # single skill
22
- // node scripts/self-test.mjs --strict # warnings become failures
23
- // node scripts/self-test.mjs --fix # auto-repair fixable findings
24
- //
25
- // Exit codes:
26
- // 0 = all pass
27
- // 1 = at least one skill/guide failure
28
- // 2 = runner crashed (unreadable dirs, etc.)
29
- // 3 = plugin manifest missing (distinct from skill failures)
30
-
31
- import { readFileSync, readdirSync, existsSync, writeFileSync, appendFileSync, renameSync, mkdirSync, statSync } from "node:fs";
32
- import { join, basename, dirname, resolve } from "node:path";
33
- import { fileURLToPath } from "node:url";
34
- import { spawnSync } from "node:child_process";
35
- import { loadAllManifests } from "./lib/manifest-schema.mjs";
36
- import { runManifest } from "./lib/runner.mjs";
37
- import {
38
- generateRunId,
39
- resolveSandboxRef,
40
- deleteSupabaseTestBranch,
41
- removeWorktree,
42
- } from "./lib/sandbox.mjs";
43
-
44
- const __dirname = dirname(fileURLToPath(import.meta.url));
45
- const REPO_ROOT = resolve(__dirname, "..");
46
- const SKILLS_DIR = join(REPO_ROOT, "skills");
47
- const GUIDES_DIR = join(REPO_ROOT, "guides");
48
- const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
49
- const HOOKS_DIR = join(REPO_ROOT, "hooks");
50
- const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
51
- const PACKAGE_JSON = join(REPO_ROOT, "package.json");
52
-
53
- // ─── Guide-content validator constants ───────────────────────────────────────
54
- // Path to the regen-root project (sibling of rdc-skills).
55
- // Override with REGEN_ROOT env var when running from a non-standard location.
56
- const REGEN_ROOT = process.env.REGEN_ROOT || "C:/Dev/regen-root";
57
-
58
- // Terms that must NOT appear in guide/rule files as positive instructions.
59
- // A line is flagged only when it does NOT contain a known negation pattern.
60
- const GUIDE_BANNED_TERMS = [
61
- "@masonator/coolify-mcp",
62
- "@masonator",
63
- "coolify-mcp",
64
- "@regen/brand-studio",
65
- "brand-studio",
66
- ];
67
-
68
- // Negation patterns — if a line contains one of these, the banned-term
69
- // occurrence is an explicit "don't use" warning and should NOT be flagged.
70
- const GUIDE_NEGATION_PATTERNS = [
71
- /\bdo not\b/i,
72
- /\bnever\b/i,
73
- /\bno such\b/i,
74
- /\bdoes not exist\b/i,
75
- /\bbanned\b/i,
76
- /\bnot reference\b/i,
77
- /\bnot use\b/i,
78
- /\bavoid\b/i,
79
- /\bremoved\b/i,
80
- /\bdeprecated\b/i,
81
- // Markdown table row showing a WRONG→CORRECT mapping (naming-corrections.md pattern)
82
- /^\|[^|]*WRONG[^|]*\|/i,
83
- // A table row where the term is in the WRONG column (first data column after the | WRONG | header)
84
- // Heuristic: line starts with | and the term appears before the first CORRECT value
85
- /^\|\s*(Brand Studio|brand-studio|@regen\/brand-studio|@masonator[^ |]*|coolify-mcp)[^|]*\|\s*\*\*/,
86
- ];
87
-
88
- // Known valid clauth key names. Extend when new keys are added to the vault.
89
- // Source: C:/Dev/regen-root/.rdc/guides/agent-bootstrap.md credential table
90
- // + C:/Dev/regen-root/.claude/rules/clauth-endpoints.md
91
- const KNOWN_CLAUTH_KEYS = new Set([
92
- "coolify-api",
93
- "cloudflare",
94
- "npm",
95
- "supabase",
96
- "supabase-anon",
97
- "supabase-db",
98
- "supabase-service",
99
- "supabase-service-role",
100
- "r2-access-key-id",
101
- "r2-secret-key",
102
- "anthropic",
103
- "openai",
104
- "github",
105
- "github-token",
106
- "vultr-dev-ssh",
107
- "mcp-web-research-secret",
108
- "mcp-regen-media-secret",
109
- "web-research",
110
- "regen-media",
111
- ]);
112
-
113
- /**
114
- * Audit a single guide or rule file for banned terms and clauth key validity.
115
- *
116
- * Returns an array of finding objects:
117
- * { level: 'error'|'warn', code: string, file: string, line: number, message: string }
118
- */
119
- function auditGuideFile(filepath, relPath) {
120
- const findings = [];
121
- let text;
122
- try {
123
- text = readFileSync(filepath, "utf8");
124
- } catch (e) {
125
- findings.push({ level: "error", code: "guide-unreadable", file: relPath, line: 0, message: `cannot read: ${e.message}` });
126
- return findings;
127
- }
128
-
129
- const lines = text.split(/\r?\n/);
130
-
131
- if (
132
- relPath === "guides/agent-bootstrap.md" &&
133
- !text.includes("guides/engineering-behavior.md")
134
- ) {
135
- findings.push({
136
- level: "error",
137
- code: "guide-engineering-behavior-missing",
138
- file: relPath,
139
- line: 1,
140
- message: "agent bootstrap must reference guides/engineering-behavior.md",
141
- });
142
- }
143
-
144
- lines.forEach((line, i) => {
145
- const lineNo = i + 1;
146
-
147
- // Check banned terms (skip negation lines and comment lines)
148
- for (const term of GUIDE_BANNED_TERMS) {
149
- if (line.includes(term)) {
150
- const isNegated = GUIDE_NEGATION_PATTERNS.some((re) => re.test(line));
151
- if (!isNegated) {
152
- findings.push({
153
- level: "error",
154
- code: "guide-banned-term",
155
- file: relPath,
156
- line: lineNo,
157
- message: `banned term "${term}" found as positive instruction — replace with clauth+REST pattern`,
158
- });
159
- }
160
- }
161
- }
162
-
163
- // Check clauth /v/<key> references — flag unknown key names
164
- // Pattern: /v/some-key-name (standalone path, not inside a larger URL path)
165
- const clauthKeyRe = /\bhttp:\/\/127\.0\.0\.1:52437\/v\/([\w-]+)/g;
166
- let m;
167
- while ((m = clauthKeyRe.exec(line)) !== null) {
168
- const key = m[1];
169
- if (!KNOWN_CLAUTH_KEYS.has(key)) {
170
- findings.push({
171
- level: "warn",
172
- code: "guide-clauth-key-unknown",
173
- file: relPath,
174
- line: lineNo,
175
- message: `clauth key "${key}" not in known key list — verify it exists in the vault`,
176
- });
177
- }
178
- }
179
- });
180
-
181
- return findings;
182
- }
183
-
184
- /**
185
- * Run the guide-content validator across:
186
- * 1. C:/Dev/rdc-skills/guides/**\/*.md (base guides shipped with the plugin)
187
- * 2. REGEN_ROOT/.rdc/guides/**\/*.md (project-level agent guides)
188
- * 3. REGEN_ROOT/.claude/rules/**\/*.md (project auto-loaded rules)
189
- *
190
- * Also scans fixture guides under scripts/fixtures/guides/ when GUIDE_FIXTURE_DIR is set
191
- * (used by the self-test's own test suite).
192
- *
193
- * Returns { findings[], ok, stats }
194
- */
195
- function runGuideContentValidator({ fixtureDir } = {}) {
196
- const allFindings = [];
197
- let filesScanned = 0;
198
-
199
- function scanDir(dir, prefix) {
200
- if (!existsSync(dir)) return;
201
- let entries;
202
- try { entries = readdirSync(dir); } catch { return; }
203
- for (const entry of entries) {
204
- const fullPath = join(dir, entry);
205
- let stat;
206
- try { stat = statSync(fullPath); } catch { continue; }
207
- if (stat.isDirectory()) {
208
- scanDir(fullPath, `${prefix}/${entry}`);
209
- } else if (entry.endsWith(".md")) {
210
- const relPath = `${prefix}/${entry}`;
211
- const findings = auditGuideFile(fullPath, relPath);
212
- allFindings.push(...findings);
213
- filesScanned++;
214
- }
215
- }
216
- }
217
-
218
- // 1. Base guides in rdc-skills repo
219
- scanDir(GUIDES_DIR, "guides");
220
-
221
- // 2. Regen-root agent guides
222
- scanDir(join(REGEN_ROOT, ".rdc", "guides"), "regen-root/.rdc/guides");
223
-
224
- // 3. Regen-root auto-loaded rules
225
- scanDir(join(REGEN_ROOT, ".claude", "rules"), "regen-root/.claude/rules");
226
-
227
- // 4. Fixture directory (for self-test's own test assertions)
228
- if (fixtureDir && existsSync(fixtureDir)) {
229
- scanDir(fixtureDir, "fixtures");
230
- }
231
-
232
- const errors = allFindings.filter((f) => f.level === "error");
233
- const warnings = allFindings.filter((f) => f.level === "warn");
234
-
235
- return {
236
- findings: allFindings,
237
- errors,
238
- warnings,
239
- ok: errors.length === 0,
240
- stats: { files_scanned: filesScanned, errors: errors.length, warnings: warnings.length },
241
- };
242
- }
243
-
244
- const STANDARD_BANNER = [
245
- "> **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`",
246
- "> Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.",
247
- "> One checklist upfront, updated in place, shown again at end with a 1-line verdict.",
248
- "",
249
- "",
250
- ].join("\n");
251
-
252
- const argv = process.argv.slice(2);
253
- const args = new Set(argv);
254
- const STRICT = args.has("--strict");
255
- const JSON_OUT = args.has("--json");
256
- const FIX = args.has("--fix");
257
- const TIER2 = args.has("--tier2");
258
- const QUICK = args.has("--quick");
259
- const ONLY_SKILLS = [];
260
- for (let i = 0; i < argv.length; i++) {
261
- if (argv[i] === "--skill" && argv[i + 1]) {
262
- for (const s of argv[i + 1].split(",")) {
263
- const t = s.trim();
264
- if (t) ONLY_SKILLS.push(t);
265
- }
266
- i++;
267
- }
268
- }
269
- const ONLY_SKILL = ONLY_SKILLS.length === 1 ? ONLY_SKILLS[0] : null; // backwards-compat for single-skill Tier 1 path
270
- const parallelArgIdx = argv.indexOf("--parallel");
271
- const PARALLEL = parallelArgIdx >= 0 ? Math.max(1, parseInt(argv[parallelArgIdx + 1], 10) || 3) : 3;
272
- const logArgIdx = argv.indexOf("--log");
273
- const LIVE_LOG = logArgIdx >= 0 ? argv[logArgIdx + 1] : null;
274
-
275
- /** Append a timestamped line to the live log file (if --log was passed). */
276
- function liveLog(kind, msg) {
277
- if (!LIVE_LOG) return;
278
- const ts = new Date().toISOString();
279
- try { appendFileSync(LIVE_LOG, `[${ts}] [${kind}] ${msg}\n`); } catch {}
280
- }
281
-
282
- // Track files modified by --fix so caller can git diff
283
- const FIXED_FILES = [];
284
-
285
- function escapeRegExp(s) {
286
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
287
- }
288
-
289
- function parseFrontmatter(text) {
290
- text = text.replace(/\r\n/g, "\n");
291
- const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
292
- if (!m) return { error: "no frontmatter block" };
293
- const raw = m[1];
294
- const body = text.slice(m[0].length);
295
- const fmEnd = m[0].length;
296
-
297
- const nameMatch = raw.match(/^name:\s*(.+?)\s*$/m);
298
- if (!nameMatch) return { error: "frontmatter missing `name:`" };
299
- const name = nameMatch[1].trim();
300
-
301
- let description = null;
302
- let descStartsWithBacktick = false;
303
-
304
- const plainDesc = raw.match(/^description:[ \t]+([^\n>| \t].*)$/m);
305
- const foldedDesc = raw.match(/^description:[ \t]*>[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
306
- const literalDesc = raw.match(/^description:[ \t]*\|[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
307
-
308
- if (plainDesc) {
309
- description = plainDesc[1].trim();
310
- descStartsWithBacktick = description.startsWith("`");
311
- } else if (foldedDesc || literalDesc) {
312
- const block = (foldedDesc || literalDesc)[1];
313
- const lines = block
314
- .split(/\n/)
315
- .map((l) => l.replace(/^[ \t]+/, ""))
316
- .filter((l) => l.length > 0);
317
- description = lines.join(foldedDesc ? " " : "\n").trim();
318
- const firstLine = lines[0] || "";
319
- descStartsWithBacktick = firstLine.startsWith("`");
320
- } else {
321
- return { error: "frontmatter missing `description:`" };
322
- }
323
-
324
- return { name, description, descStartsWithBacktick, body, raw, fmEnd };
325
- }
326
-
327
- function expectedSkillName(dirOrFile) {
328
- // Accept current directory names (foo), legacy directory names
329
- // (rdc-foo), or legacy filenames (rdc-foo.md).
330
- const base = basename(dirOrFile, ".md");
331
- if (base.startsWith("rdc-")) return "rdc:" + base.slice(4);
332
- if (base === "tests" || base.startsWith(".")) return null;
333
- return "rdc:" + base;
334
- }
335
-
336
- function expectedFrontmatterName(dirOrFile, frontmatterName) {
337
- const base = basename(dirOrFile, ".md");
338
- if (frontmatterName && frontmatterName.startsWith("rdc:")) {
339
- return expectedSkillName(dirOrFile);
340
- }
341
- return base;
342
- }
343
-
344
- function findReferencedFiles(body) {
345
- const refs = [];
346
- const guideRe = /(?:^|[\s(`'"/])(?:\.rdc\/)?guides\/([\w/-]+\.md)/g;
347
- let m;
348
- while ((m = guideRe.exec(body)) !== null) {
349
- refs.push({ kind: "guide", name: m[1] });
350
- }
351
- const ruleRe = /\.claude\/rules\/([\w-]+\.md)/g;
352
- while ((m = ruleRe.exec(body)) !== null) {
353
- refs.push({ kind: "rule", name: m[1] });
354
- }
355
- const hookRe = /(?:^|[\s(`'"/])hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
356
- while ((m = hookRe.exec(body)) !== null) {
357
- refs.push({ kind: "hook", name: m[1] });
358
- }
359
- return refs;
360
- }
361
-
362
- function addFinding(result, level, code, message) {
363
- result.findings.push({ level, code, message });
364
- if (level === "error") result.errors.push(message);
365
- else result.warnings.push(message);
366
- }
367
-
368
- function tryAutoFix(filepath, fm, text, result) {
369
- if (!FIX) return false;
370
- let changed = false;
371
- let newText = text;
372
- let newPath = filepath;
373
-
374
- // Fix: insert OUTPUT CONTRACT banner after frontmatter if missing
375
- if (!/OUTPUT CONTRACT/.test(fm.body)) {
376
- const fmBlock = newText.slice(0, fm.fmEnd);
377
- const rest = newText.slice(fm.fmEnd).replace(/^\n+/, "");
378
- newText = fmBlock + "\n" + STANDARD_BANNER + rest;
379
- writeFileSync(filepath, newText);
380
- console.log(`FIXED: ${result.name || result.file} — inserted OUTPUT CONTRACT banner`);
381
- FIXED_FILES.push(filepath);
382
- changed = true;
383
- }
384
-
385
- // Fix: filename/name mismatch — rename directory or file to match frontmatter name
386
- const _filename = basename(filepath);
387
- const _dirName = basename(dirname(filepath));
388
- const _isSubdir = _filename === "SKILL.md";
389
- const _skillDirOrFile = _isSubdir ? _dirName : _filename;
390
- const expected = expectedSkillName(_skillDirOrFile);
391
- if (expected && fm.name !== expected && fm.name.startsWith("rdc:")) {
392
- if (_isSubdir) {
393
- // Subdirectory layout: rename the parent directory
394
- const targetDirName = "rdc-" + fm.name.slice(4);
395
- const targetDirPath = join(dirname(dirname(filepath)), targetDirName);
396
- if (!existsSync(targetDirPath)) {
397
- renameSync(dirname(filepath), targetDirPath);
398
- const targetPath = join(targetDirPath, "SKILL.md");
399
- console.log(`FIXED: ${fm.name} — renamed dir ${_dirName} → ${targetDirName}`);
400
- FIXED_FILES.push(targetPath);
401
- newPath = targetPath;
402
- changed = true;
403
- }
404
- } else {
405
- const targetBase = "rdc-" + fm.name.slice(4) + ".md";
406
- const targetPath = join(dirname(filepath), targetBase);
407
- if (!existsSync(targetPath)) {
408
- renameSync(filepath, targetPath);
409
- console.log(`FIXED: ${fm.name} — renamed ${_filename} → ${targetBase}`);
410
- FIXED_FILES.push(targetPath);
411
- newPath = targetPath;
412
- changed = true;
413
- }
414
- }
415
- }
416
-
417
- return changed ? newPath : false;
418
- }
419
-
420
- function auditSkill(filepath) {
421
- const filename = basename(filepath);
422
- // Support both flat (rdc-foo.md) and subdirectory (rdc-foo/SKILL.md) layouts
423
- const dirName = basename(dirname(filepath)); // "rdc-foo" when filepath is .../rdc-foo/SKILL.md
424
- const isSubdir = filename === "SKILL.md";
425
- const skillDirOrFile = isSubdir ? dirName : filename;
426
- const result = {
427
- skill: null,
428
- file: isSubdir ? `skills/${dirName}/SKILL.md` : `skills/${filename}`,
429
- name: null,
430
- pass: true,
431
- errors: [],
432
- warnings: [],
433
- findings: [],
434
- };
435
-
436
- let text;
437
- try {
438
- text = readFileSync(filepath, "utf8");
439
- } catch (e) {
440
- addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
441
- result.pass = false;
442
- return result;
443
- }
444
-
445
- const fm = parseFrontmatter(text);
446
- if (fm.error) {
447
- addFinding(result, "error", "frontmatter-invalid", `frontmatter: ${fm.error}`);
448
- result.pass = false;
449
- return result;
450
- }
451
-
452
- result.name = fm.name;
453
- result.skill = fm.name;
454
-
455
- if (fm.descStartsWithBacktick) {
456
- addFinding(
457
- result,
458
- "error",
459
- "description-backtick-leading",
460
- "description starts with backtick — Claude Code parser will silently drop this skill from the menu",
461
- );
462
- }
463
-
464
- // Usage marker — must reference this skill's own name
465
- if (fm.name.startsWith("rdc:")) {
466
- const escapedName = escapeRegExp(fm.name);
467
- const usageRe = new RegExp("Usage\\s+`" + escapedName + "[ \\\\`]", "i");
468
- const anyUsageRe = /\bUsage\s+`/i;
469
- if (!anyUsageRe.test(fm.description)) {
470
- addFinding(
471
- result,
472
- "warn",
473
- "usage-marker-missing",
474
- "description missing `Usage \\`rdc:name <args>\\`` marker — users can't see arg contract in menu",
475
- );
476
- } else if (!usageRe.test(fm.description)) {
477
- addFinding(
478
- result,
479
- "warn",
480
- "usage-marker-mismatch",
481
- `Usage marker in description does not reference own name "${fm.name}" — copy-paste drift?`,
482
- );
483
- }
484
- }
485
-
486
- const expected = expectedFrontmatterName(skillDirOrFile, fm.name);
487
- if (expected && fm.name !== expected) {
488
- addFinding(
489
- result,
490
- "error",
491
- "name-filename-mismatch",
492
- `name mismatch: frontmatter says "${fm.name}" but filename implies "${expected}"`,
493
- );
494
- }
495
-
496
- const refs = findReferencedFiles(fm.body);
497
- const seen = new Set();
498
- for (const ref of refs) {
499
- const key = `${ref.kind}:${ref.name}`;
500
- if (seen.has(key)) continue;
501
- seen.add(key);
502
- if (ref.kind === "guide") {
503
- if (!existsSync(join(GUIDES_DIR, ref.name))) {
504
- addFinding(
505
- result,
506
- "warn",
507
- "guide-not-found",
508
- `referenced guide not found in repo: guides/${ref.name}`,
509
- );
510
- }
511
- }
512
- if (ref.kind === "rule") {
513
- const regenRoot = "C:/Dev/regen-root/.claude/rules";
514
- if (existsSync(regenRoot) && !existsSync(join(regenRoot, ref.name))) {
515
- addFinding(
516
- result,
517
- "warn",
518
- "rule-not-found",
519
- `referenced rule not found in regen-root: .claude/rules/${ref.name}`,
520
- );
521
- }
522
- }
523
- if (ref.kind === "hook") {
524
- if (!existsSync(join(HOOKS_DIR, ref.name))) {
525
- addFinding(
526
- result,
527
- "error",
528
- "hook-not-found",
529
- `referenced hook file not found: hooks/${ref.name}`,
530
- );
531
- }
532
- }
533
- }
534
-
535
- if (!/OUTPUT CONTRACT/.test(fm.body)) {
536
- addFinding(
537
- result,
538
- "warn",
539
- "banner-missing",
540
- "body missing OUTPUT CONTRACT banner (guides/output-contract.md reference)",
541
- );
542
- }
543
-
544
- // Try auto-fix, then re-audit the same skill once to reflect new state
545
- if (FIX && (result.errors.length > 0 || result.warnings.length > 0)) {
546
- const newPath = tryAutoFix(filepath, fm, text, result);
547
- if (newPath) return auditSkill(newPath);
548
- }
549
-
550
- result.pass = result.errors.length === 0;
551
- return result;
552
- }
553
-
554
- function auditAgentGuide(filepath) {
555
- const filename = basename(filepath);
556
- const result = {
557
- file: `guides/agents/${filename}`,
558
- name: filename,
559
- pass: true,
560
- errors: [],
561
- warnings: [],
562
- findings: [],
563
- };
564
- let text;
565
- try {
566
- text = readFileSync(filepath, "utf8");
567
- } catch (e) {
568
- addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
569
- result.pass = false;
570
- return result;
571
- }
572
- if (text.replace(/\r\n/g, "\n").startsWith("---\n")) {
573
- addFinding(
574
- result,
575
- "error",
576
- "agent-guide-has-frontmatter",
577
- "agent guide still has frontmatter — should be plain markdown",
578
- );
579
- }
580
- if (!/OUTPUT CONTRACT/.test(text)) {
581
- addFinding(result, "warn", "banner-missing", "body missing OUTPUT CONTRACT banner");
582
- }
583
- if (text.trim().length === 0) {
584
- addFinding(result, "error", "empty-file", "file is empty");
585
- }
586
- result.pass = result.errors.length === 0;
587
- return result;
588
- }
589
-
590
- // Plugin manifest check. Returns { ok, exitCode, findings }
591
- function auditPluginManifest() {
592
- const findings = [];
593
- if (!existsSync(PLUGIN_MANIFEST)) {
594
- findings.push({
595
- level: "error",
596
- code: "manifest-missing",
597
- message: `.claude-plugin/plugin.json not found`,
598
- });
599
- return { ok: false, exitCode: 3, findings, manifest: null };
600
- }
601
- let manifest;
602
- try {
603
- manifest = JSON.parse(readFileSync(PLUGIN_MANIFEST, "utf8"));
604
- } catch (e) {
605
- findings.push({
606
- level: "error",
607
- code: "manifest-invalid-json",
608
- message: `plugin.json invalid JSON: ${e.message}`,
609
- });
610
- return { ok: false, exitCode: 1, findings, manifest: null };
611
- }
612
- if (!manifest.name) {
613
- findings.push({ level: "error", code: "manifest-missing-name", message: "plugin.json missing `name`" });
614
- }
615
- if (!manifest.version) {
616
- findings.push({
617
- level: "error",
618
- code: "manifest-missing-version",
619
- message: "plugin.json missing `version`",
620
- });
621
- }
622
- try {
623
- const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
624
- if (manifest.version && pkg.version && manifest.version !== pkg.version) {
625
- findings.push({
626
- level: "error",
627
- code: "manifest-version-mismatch",
628
- message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
629
- });
630
- }
631
- } catch (e) {
632
- findings.push({
633
- level: "warn",
634
- code: "package-json-unreadable",
635
- message: `could not read package.json for version cross-check: ${e.message}`,
636
- });
637
- }
638
- const ok = findings.every((f) => f.level !== "error");
639
- return { ok, exitCode: ok ? 0 : 1, findings, manifest };
640
- }
641
-
642
- // Duplicate skill name + filename collision check
643
- function auditDuplicates(results) {
644
- const findings = [];
645
- const nameMap = new Map();
646
- for (const r of results) {
647
- if (!r.name) continue;
648
- if (nameMap.has(r.name)) {
649
- findings.push({
650
- level: "error",
651
- code: "duplicate-skill-name",
652
- message: `duplicate skill name "${r.name}" in ${nameMap.get(r.name)} and ${r.file}`,
653
- });
654
- } else {
655
- nameMap.set(r.name, r.file);
656
- }
657
- }
658
- if (existsSync(AGENT_GUIDES_DIR)) {
659
- const agentBases = new Set(
660
- readdirSync(AGENT_GUIDES_DIR)
661
- .filter((f) => f.endsWith(".md"))
662
- .map((f) => basename(f, ".md")),
663
- );
664
- for (const r of results) {
665
- // r.file is either "skills/rdc-foo/SKILL.md" or legacy "skills/rdc-foo.md"
666
- // Extract the skill directory/base name in both cases
667
- const parts = r.file.replace(/\\/g, "/").split("/");
668
- let skillBase;
669
- if (parts.length >= 3 && parts[2] === "SKILL.md") {
670
- skillBase = parts[1]; // "rdc-foo" from "skills/rdc-foo/SKILL.md"
671
- } else {
672
- skillBase = basename(r.file, ".md"); // legacy
673
- }
674
- const stem = skillBase.startsWith("rdc-") ? skillBase.slice(4) : skillBase;
675
- if (agentBases.has(stem)) {
676
- findings.push({
677
- level: "info",
678
- code: "skill-guide-name-overlap",
679
- message: `skills/${skillBase}/SKILL.md overlaps guides/agents/${stem}.md; allowed when a user-facing skill delegates to an agent guide`,
680
- });
681
- }
682
- }
683
- }
684
- return findings;
685
- }
686
-
687
- // Orphan hook scan — warn when hooks/ files aren't referenced anywhere
688
- function auditOrphanHooks(results) {
689
- const findings = [];
690
- if (!existsSync(HOOKS_DIR)) return findings;
691
- let hookFiles;
692
- try {
693
- hookFiles = readdirSync(HOOKS_DIR).filter((f) => /\.(?:js|mjs|cjs)$/.test(f));
694
- } catch {
695
- return findings;
696
- }
697
- const referenced = new Set();
698
- for (const r of results) {
699
- for (const f of r.findings) {
700
- // no-op: findings don't carry refs. Re-scan body below.
701
- }
702
- }
703
- // Re-scan skill bodies + known config files for hook refs
704
- const sources = [
705
- ...readdirSync(SKILLS_DIR)
706
- .filter((f) => {
707
- try {
708
- return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
709
- } catch { return false; }
710
- })
711
- .map((f) => join(SKILLS_DIR, f, "SKILL.md")),
712
- join(REPO_ROOT, ".claude", "settings.json"),
713
- join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
714
- PLUGIN_MANIFEST,
715
- ];
716
- for (const src of sources) {
717
- if (!existsSync(src)) continue;
718
- try {
719
- const text = readFileSync(src, "utf8");
720
- // Match either "hooks/foo.js" paths or bare basenames (stem match)
721
- const pathRe = /hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
722
- let m;
723
- while ((m = pathRe.exec(text)) !== null) referenced.add(m[1]);
724
- for (const hf of hookFiles) {
725
- const stem = hf.replace(/\.(?:js|mjs|cjs)$/, "");
726
- if (text.includes(hf) || new RegExp("\\b" + escapeRegExp(stem) + "\\b").test(text)) {
727
- referenced.add(hf);
728
- }
729
- }
730
- } catch {}
731
- }
732
- for (const hf of hookFiles) {
733
- if (!referenced.has(hf)) {
734
- findings.push({
735
- level: "info",
736
- code: "orphan-hook",
737
- message: `hooks/${hf} exists but is not referenced by any skill; plugin hooks may still be convention-discovered or wired by settings`,
738
- });
739
- }
740
- }
741
- return findings;
742
- }
743
-
744
- function auditRequiredHookWiring() {
745
- const findings = [];
746
- const required = [
747
- {
748
- file: join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
749
- label: "scripts/install-rdc-skills.js",
750
- tokens: [
751
- "UserPromptExpansion",
752
- "UserPromptSubmit",
753
- "Stop",
754
- "rdc-invocation-marker.js",
755
- "rdc-output-contract-gate.js",
756
- ],
757
- },
758
- {
759
- file: join(REPO_ROOT, "scripts", "install.ps1"),
760
- label: "scripts/install.ps1",
761
- tokens: [
762
- "UserPromptExpansion",
763
- "UserPromptSubmit",
764
- "Stop",
765
- "rdc-invocation-marker.js",
766
- "rdc-output-contract-gate.js",
767
- ],
768
- },
769
- ];
770
-
771
- for (const hookFile of [
772
- "rdc-invocation-marker.js",
773
- "rdc-output-contract-gate.js",
774
- ]) {
775
- if (!existsSync(join(HOOKS_DIR, hookFile))) {
776
- findings.push({
777
- level: "error",
778
- code: "required-hook-missing",
779
- message: `hooks/${hookFile} is required for RDC output-contract enforcement`,
780
- });
781
- }
782
- }
783
-
784
- for (const target of required) {
785
- if (!existsSync(target.file)) {
786
- findings.push({
787
- level: "error",
788
- code: "hook-installer-missing",
789
- message: `${target.label} not found for RDC hook wiring audit`,
790
- });
791
- continue;
792
- }
793
- let text = "";
794
- try { text = readFileSync(target.file, "utf8"); } catch (e) {
795
- findings.push({
796
- level: "error",
797
- code: "hook-installer-unreadable",
798
- message: `${target.label} unreadable: ${e.message}`,
799
- });
800
- continue;
801
- }
802
- for (const token of target.tokens) {
803
- if (!text.includes(token)) {
804
- findings.push({
805
- level: "error",
806
- code: "required-hook-wiring-missing",
807
- message: `${target.label} must reference ${token}`,
808
- });
809
- }
810
- }
811
- }
812
-
813
- return findings;
814
- }
815
-
816
- function runHookBehaviorTests() {
817
- const result = spawnSync(process.execPath, [join(REPO_ROOT, "scripts", "test-rdc-hooks.mjs")], {
818
- cwd: REPO_ROOT,
819
- encoding: "utf8",
820
- });
821
- return {
822
- ok: result.status === 0,
823
- status: result.status,
824
- stdout: (result.stdout || "").trim(),
825
- stderr: (result.stderr || "").trim(),
826
- };
827
- }
828
-
829
- // ─── Tier 2 behavioral runner ──────────────────────────────────────────────
830
-
831
- async function runPool(items, concurrency, worker) {
832
- const results = new Array(items.length);
833
- let next = 0;
834
- async function lane() {
835
- while (true) {
836
- const i = next++;
837
- if (i >= items.length) return;
838
- try {
839
- results[i] = await worker(items[i], i);
840
- } catch (e) {
841
- results[i] = { pass: false, error: e?.message || String(e) };
842
- }
843
- }
844
- }
845
- const lanes = Array.from({ length: Math.min(concurrency, items.length) }, () => lane());
846
- await Promise.all(lanes);
847
- return results;
848
- }
849
-
850
- async function runTier2() {
851
- console.log("\nrdc-skills self-test — Tier 2 (behavioral)\n");
852
-
853
- // Load manifests
854
- let all = loadAllManifests();
855
- if (ONLY_SKILLS.length > 0) {
856
- all = all.filter((m) => m.manifest && ONLY_SKILLS.includes(m.manifest.skill));
857
- }
858
- if (QUICK) {
859
- all = all.filter((m) => !(m.manifest?.fixture?.slow === true));
860
- }
861
-
862
- if (all.length === 0) {
863
- console.log("No manifests found in skills/tests/.");
864
- console.log("(Tier 2 will run once WP6/WP7 land baseline manifests.)");
865
- process.exit(STRICT ? 1 : 0);
866
- }
867
-
868
- // Reject invalid manifests up front
869
- const invalid = all.filter((m) => !m.ok);
870
- const valid = all.filter((m) => m.ok);
871
- for (const m of invalid) {
872
- console.error(`SKIP ${m.file}: ${m.errors.map((e) => `${e.path}: ${e.msg}`).join("; ")}`);
873
- }
874
- if (valid.length === 0) {
875
- console.error("No valid manifests to run.");
876
- process.exit(1);
877
- }
878
-
879
- const runId = generateRunId();
880
- console.log(`runId: ${runId}`);
881
- console.log(`manifests: ${valid.length} valid, ${invalid.length} invalid`);
882
- console.log(`parallel: ${PARALLEL}${QUICK ? " quick" : ""}`);
883
- liveLog("start", `runId=${runId} skills=${valid.map((m) => m.manifest.skill).join(",")}`);
884
-
885
- // WP-A3: use resolveSandboxRef — defaults to main-db mode (no branch, no cost)
886
- const sandboxRef = await resolveSandboxRef({ runId });
887
- console.log(`sandbox mode: ${sandboxRef.mode} apiUrl=${sandboxRef.apiUrl || "none"} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
888
- liveLog("sandbox", `mode=${sandboxRef.mode} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
889
-
890
- // Run
891
- const toRun = valid.map((m) => m.manifest);
892
- const total = toRun.length;
893
- let launched = 0;
894
- let finished = 0;
895
- const started = Date.now();
896
- let results;
897
- try {
898
- results = await runPool(toRun, PARALLEL, async (manifest) => {
899
- const n = ++launched;
900
- const timeoutSec = Math.round((manifest.timeout_ms || 240_000) / 1000);
901
- console.log(`▶ [${n}/${total}] ${manifest.skill} (timeout ${timeoutSec}s)`);
902
- liveLog("running", `skill=${manifest.skill}`);
903
- const r = await runManifest(manifest, {
904
- runId,
905
- supabaseBranchRef: sandboxRef,
906
- projectCwd: process.cwd(),
907
- });
908
- const done = ++finished;
909
- const status = r.error ? "ERROR" : r.pass ? "PASS" : "FAIL";
910
- const dur = r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(1)}s` : "?";
911
- const icon = status === "PASS" ? "✓" : "✗";
912
- const detail = r.error || (r.failures || []).map((f) => f.message).join("; ") || "";
913
- console.log(
914
- `${icon} [${done}/${total}] ${r.skill} ${dur} ${status}${detail ? " — " + detail : ""}`,
915
- );
916
- liveLog(status.toLowerCase(), `skill=${r.skill} duration=${r.duration_ms}ms${detail ? " | " + detail : ""}`);
917
- return r;
918
- });
919
- } finally {
920
- // Teardown — always. Pass process.cwd() so removeWorktree targets the
921
- // same projectRoot (regen-root) that createWorktree used.
922
- const projectCwd = process.cwd();
923
- for (const r of results || []) {
924
- if (r && r.skill) {
925
- try { removeWorktree(runId, r.skill, projectCwd); } catch {}
926
- }
927
- }
928
- try { await deleteSupabaseTestBranch(sandboxRef.branchId); } catch {}
929
- }
930
-
931
- const totalDuration = Date.now() - started;
932
-
933
- // Report
934
- const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
935
- console.log("\n" + pad("skill", 24) + pad("status", 10) + pad("duration", 12) + "failures");
936
- console.log("─".repeat(90));
937
- let failed = 0;
938
- let errored = 0;
939
- for (const r of results) {
940
- if (r.error) {
941
- errored++;
942
- console.log(pad(r.skill || "?", 24) + pad("ERROR", 10) + pad("-", 12) + r.error);
943
- continue;
944
- }
945
- const status = r.pass ? "pass" : "FAIL";
946
- if (!r.pass) failed++;
947
- const dur = `${r.duration_ms}ms`;
948
- const fs = (r.failures || []).map((f) => `${f.predicate}: ${f.message}`).join("; ") || "";
949
- console.log(pad(r.skill, 24) + pad(status, 10) + pad(dur, 12) + fs);
950
- }
951
- console.log("─".repeat(90));
952
- console.log(
953
- `total: ${results.length} | pass: ${results.length - failed - errored} | fail: ${failed} | error: ${errored} | wall: ${totalDuration}ms`,
954
- );
955
- liveLog("done", `total=${results.length} pass=${results.length - failed - errored} fail=${failed} error=${errored} wall=${totalDuration}ms`);
956
-
957
- // JSON dump
958
- try {
959
- const REPORTS_DIR = resolve(REPO_ROOT, ".rdc", "reports");
960
- if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
961
- const iso = new Date().toISOString().replace(/[:.]/g, "-");
962
- const reportPath = join(REPORTS_DIR, `self-test-tier2-${iso}.json`);
963
- writeFileSync(
964
- reportPath,
965
- JSON.stringify(
966
- {
967
- run_id: runId,
968
- started_at: new Date(started).toISOString(),
969
- duration_ms: totalDuration,
970
- parallel: PARALLEL,
971
- quick: QUICK,
972
- supabase_branch: sandboxRef.branchId || null,
973
- summary: {
974
- total: results.length,
975
- pass: results.length - failed - errored,
976
- fail: failed,
977
- error: errored,
978
- },
979
- results,
980
- invalid_manifests: invalid.map((m) => ({ file: m.file, errors: m.errors })),
981
- },
982
- null,
983
- 2,
984
- ),
985
- );
986
- console.log(`\nreport: ${reportPath}`);
987
- } catch (e) {
988
- console.error(`WARN: failed to write JSON report: ${e.message}`);
989
- }
990
-
991
- writeLastRun({
992
- tier: 2,
993
- verdict: (failed > 0 || errored > 0) ? "FAIL" : "PASS",
994
- exit_code: errored > 0 ? 2 : failed > 0 ? 1 : 0,
995
- summary: {
996
- total: results.length,
997
- passed: results.length - failed - errored,
998
- failed,
999
- errored,
1000
- wall_ms: totalDuration,
1001
- },
1002
- failures: results
1003
- .filter((r) => !r.pass || r.error)
1004
- .map((r) => ({
1005
- skill: r.skill,
1006
- error: r.error || null,
1007
- timed_out: r.observed?.timed_out || false,
1008
- assertions_failed: (r.failures || []).map((f) => `${f.predicate}: ${f.message}`),
1009
- })),
1010
- warnings: [],
1011
- });
1012
-
1013
- if (errored > 0) process.exit(2);
1014
- process.exit(failed > 0 ? 1 : 0);
1015
- }
1016
-
1017
- function main() {
1018
- // Plugin manifest pass first — can short-circuit with exit 3
1019
- const manifestAudit = auditPluginManifest();
1020
- const manifestMissing = manifestAudit.findings.some((f) => f.code === "manifest-missing");
1021
-
1022
- let files;
1023
- try {
1024
- files = readdirSync(SKILLS_DIR).filter((f) => {
1025
- try {
1026
- return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
1027
- } catch { return false; }
1028
- });
1029
- } catch (e) {
1030
- console.error(`FATAL: cannot read skills dir ${SKILLS_DIR}: ${e.message}`);
1031
- process.exit(2);
1032
- }
1033
-
1034
- if (ONLY_SKILLS.length > 0) {
1035
- files = files.filter((f) => {
1036
- return ONLY_SKILLS.some((s) => {
1037
- const bare = s.replace(/^rdc:/, "");
1038
- return f === bare || f === s.replace(":", "-");
1039
- });
1040
- });
1041
- if (files.length === 0) {
1042
- console.error(`no skill file matches: ${ONLY_SKILLS.join(", ")}`);
1043
- process.exit(2);
1044
- }
1045
- }
1046
-
1047
- if (JSON_OUT) {
1048
- // JSON path: buffer everything then dump
1049
- const results = files.map((f) => auditSkill(join(SKILLS_DIR, f, "SKILL.md")));
1050
-
1051
- let duplicateFindings = [];
1052
- let orphanHookFindings = [];
1053
- if (!ONLY_SKILL) {
1054
- duplicateFindings = auditDuplicates(results);
1055
- orphanHookFindings = auditOrphanHooks(results);
1056
- }
1057
-
1058
- let agentResults = [];
1059
- if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
1060
- const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
1061
- agentResults = agentFiles.map((f) => auditAgentGuide(join(AGENT_GUIDES_DIR, f)));
1062
- }
1063
-
1064
- const guideValidatorResultJson = !ONLY_SKILL ? runGuideContentValidator() : null;
1065
- const hookBehaviorResultJson = !ONLY_SKILL ? runHookBehaviorTests() : null;
1066
-
1067
- const failed = results.filter((r) => r.errors.length > 0);
1068
- const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1069
- const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1070
- const agentFailed = agentResults.filter((r) => r.errors.length > 0);
1071
- const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1072
- const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1073
- const globalErrors = [
1074
- ...manifestAudit.findings.filter((f) => f.level === "error"),
1075
- ...duplicateFindings.filter((f) => f.level === "error"),
1076
- ...orphanHookFindings.filter((f) => f.level === "error"),
1077
- ...(guideValidatorResultJson ? guideValidatorResultJson.errors : []),
1078
- ...(hookBehaviorResultJson && !hookBehaviorResultJson.ok ? [{
1079
- level: "error",
1080
- code: "hook-behavior-test-failed",
1081
- message: hookBehaviorResultJson.stderr || hookBehaviorResultJson.stdout || `exit ${hookBehaviorResultJson.status}`,
1082
- }] : []),
1083
- ];
1084
- const globalWarnings = [
1085
- ...manifestAudit.findings.filter((f) => f.level === "warn"),
1086
- ...duplicateFindings.filter((f) => f.level === "warn"),
1087
- ...orphanHookFindings.filter((f) => f.level === "warn"),
1088
- ...(guideValidatorResultJson ? guideValidatorResultJson.warnings : []),
1089
- ];
1090
- const fail =
1091
- failed.length > 0 ||
1092
- agentFailed.length > 0 ||
1093
- globalErrors.length > 0 ||
1094
- (STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
1095
- let exitCode = fail ? 1 : 0;
1096
- if (manifestMissing) exitCode = 3;
1097
-
1098
- console.log(
1099
- JSON.stringify(
1100
- {
1101
- summary: {
1102
- total: results.length,
1103
- failed: failed.length,
1104
- warned: warned.length,
1105
- clean: clean.length,
1106
- strict: STRICT,
1107
- fix: FIX,
1108
- fixed_files: FIXED_FILES.map((p) => p.replace(REPO_ROOT + "\\", "").replace(REPO_ROOT + "/", "")),
1109
- verdict: fail ? "FAIL" : "PASS",
1110
- exit_code: exitCode,
1111
- },
1112
- plugin_manifest: {
1113
- ok: manifestAudit.ok,
1114
- version: manifestAudit.manifest?.version || null,
1115
- findings: manifestAudit.findings,
1116
- },
1117
- global_findings: [...duplicateFindings, ...orphanHookFindings],
1118
- guide_content_validator: guideValidatorResultJson
1119
- ? {
1120
- ok: guideValidatorResultJson.ok,
1121
- stats: guideValidatorResultJson.stats,
1122
- findings: guideValidatorResultJson.findings,
1123
- }
1124
- : null,
1125
- hook_behavior_tests: hookBehaviorResultJson,
1126
- results: results.map((r) => ({
1127
- skill: r.skill,
1128
- file: r.file,
1129
- pass: r.pass,
1130
- findings: r.findings,
1131
- })),
1132
- agent_guides: {
1133
- total: agentResults.length,
1134
- failed: agentFailed.length,
1135
- warned: agentWarned.length,
1136
- clean: agentClean.length,
1137
- results: agentResults.map((r) => ({
1138
- file: r.file,
1139
- pass: r.pass,
1140
- findings: r.findings,
1141
- })),
1142
- },
1143
- },
1144
- null,
1145
- 2,
1146
- ),
1147
- );
1148
- process.exit(exitCode);
1149
- }
1150
-
1151
- // Human-readable path: stream each result live as it's audited
1152
- const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
1153
-
1154
- // Count agent guides upfront for the startup banner
1155
- let agentFileCount = 0;
1156
- if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
1157
- try { agentFileCount = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md")).length; } catch {}
1158
- }
1159
-
1160
- // Startup banner — printed immediately so the user sees activity right away
1161
- console.log(`\nrdc-skills self-test — Tier 1 (static lint)`);
1162
- const scopeDesc = ONLY_SKILLS.length > 0
1163
- ? `skill: ${ONLY_SKILLS.join(", ")}`
1164
- : `${files.length} skill${files.length !== 1 ? "s" : ""}${agentFileCount ? ` + ${agentFileCount} agent guide${agentFileCount !== 1 ? "s" : ""}` : ""}`;
1165
- console.log(`Scanning ${scopeDesc}${STRICT ? " [strict]" : ""}${FIX ? " [fix]" : ""}\n`);
1166
-
1167
- // Plugin manifest line
1168
- const manifestStatus = manifestAudit.ok ? "pass" : "FAIL";
1169
- const manifestNote = manifestAudit.ok
1170
- ? `v${manifestAudit.manifest?.version || "?"}`
1171
- : manifestAudit.findings.map((f) => f.message).join("; ");
1172
- console.log(pad("plugin manifest", 24) + pad(manifestStatus, 10) + manifestNote);
1173
- console.log();
1174
-
1175
- // Skills — print each row immediately after audit (no buffering)
1176
- console.log(pad("skill", 24) + pad("status", 10) + "notes");
1177
- console.log("─".repeat(80));
1178
- const results = [];
1179
- for (const f of files) {
1180
- const r = auditSkill(join(SKILLS_DIR, f, "SKILL.md"));
1181
- results.push(r);
1182
- const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
1183
- const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
1184
- console.log(pad(r.name || r.file, 24) + pad(status, 10) + notes);
1185
- const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
1186
- for (const extra of extras) {
1187
- console.log(pad("", 24) + pad("", 10) + " " + extra);
1188
- }
1189
- }
1190
-
1191
- const failed = results.filter((r) => r.errors.length > 0);
1192
- const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1193
- const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1194
-
1195
- console.log("─".repeat(80));
1196
- console.log(`total: ${results.length} | fail: ${failed.length} | warn: ${warned.length} | pass: ${clean.length}`);
1197
-
1198
- // Agent guides — stream live
1199
- const agentResults = [];
1200
- if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
1201
- const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
1202
- if (agentFiles.length > 0) {
1203
- console.log("\nagent guides (guides/agents/*.md)\n");
1204
- console.log(pad("guide", 24) + pad("status", 10) + "notes");
1205
- console.log("─".repeat(80));
1206
- for (const f of agentFiles) {
1207
- const r = auditAgentGuide(join(AGENT_GUIDES_DIR, f));
1208
- agentResults.push(r);
1209
- const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
1210
- const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
1211
- console.log(pad(r.file, 24) + pad(status, 10) + notes);
1212
- const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
1213
- for (const extra of extras) {
1214
- console.log(pad("", 24) + pad("", 10) + " " + extra);
1215
- }
1216
- }
1217
- const agentFailed = agentResults.filter((r) => r.errors.length > 0);
1218
- const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1219
- const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1220
- console.log("─".repeat(80));
1221
- console.log(`total: ${agentResults.length} | fail: ${agentFailed.length} | warn: ${agentWarned.length} | pass: ${agentClean.length}`);
1222
- }
1223
- }
1224
-
1225
- // Guide-content validation — check .rdc/guides + .claude/rules for banned terms and invalid clauth keys
1226
- let guideValidatorResult = null;
1227
- if (!ONLY_SKILL) {
1228
- guideValidatorResult = runGuideContentValidator();
1229
- if (guideValidatorResult.stats.files_scanned > 0) {
1230
- console.log(`\nguide-content validator (${guideValidatorResult.stats.files_scanned} files)\n`);
1231
- console.log("─".repeat(80));
1232
- if (guideValidatorResult.findings.length === 0) {
1233
- console.log(" ✓ no banned terms or invalid clauth keys found");
1234
- } else {
1235
- for (const f of guideValidatorResult.findings) {
1236
- const tag = f.level === "error" ? "FAIL" : "WARN";
1237
- console.log(` [${tag}] ${f.file}:${f.line} ${f.code} ${f.message}`);
1238
- }
1239
- }
1240
- console.log("─".repeat(80));
1241
- const gErrors = guideValidatorResult.errors.length;
1242
- const gWarnings = guideValidatorResult.warnings.length;
1243
- console.log(`total: ${guideValidatorResult.stats.files_scanned} | errors: ${gErrors} | warnings: ${gWarnings}`);
1244
- }
1245
- }
1246
-
1247
- // Cross-skill checks — deferred until all audits complete
1248
- let duplicateFindings = [];
1249
- let orphanHookFindings = [];
1250
- let requiredHookFindings = [];
1251
- let hookBehaviorResult = null;
1252
- if (!ONLY_SKILL) {
1253
- duplicateFindings = auditDuplicates(results);
1254
- orphanHookFindings = auditOrphanHooks(results);
1255
- requiredHookFindings = auditRequiredHookWiring();
1256
- hookBehaviorResult = runHookBehaviorTests();
1257
- }
1258
-
1259
- if (duplicateFindings.length + orphanHookFindings.length + requiredHookFindings.length > 0) {
1260
- console.log("\nglobal findings\n");
1261
- for (const f of [...duplicateFindings, ...orphanHookFindings, ...requiredHookFindings]) {
1262
- console.log(` [${f.level}] ${f.code}: ${f.message}`);
1263
- }
1264
- }
1265
-
1266
- if (hookBehaviorResult) {
1267
- console.log("\nhook behavior tests\n");
1268
- console.log("─".repeat(80));
1269
- console.log(hookBehaviorResult.ok ? " pass rdc marker/gate behavioral smoke tests" : " FAIL rdc marker/gate behavioral smoke tests");
1270
- if (hookBehaviorResult.stdout) console.log(` ${hookBehaviorResult.stdout}`);
1271
- if (hookBehaviorResult.stderr) console.log(` ${hookBehaviorResult.stderr}`);
1272
- }
1273
-
1274
- if (FIXED_FILES.length > 0) {
1275
- console.log("\nfixed files (review with git diff):");
1276
- for (const p of FIXED_FILES) console.log(` ${p}`);
1277
- }
1278
-
1279
- const agentFailed = agentResults.filter((r) => r.errors.length > 0);
1280
- const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1281
- const globalErrors = [
1282
- ...manifestAudit.findings.filter((f) => f.level === "error"),
1283
- ...duplicateFindings.filter((f) => f.level === "error"),
1284
- ...orphanHookFindings.filter((f) => f.level === "error"),
1285
- ...requiredHookFindings.filter((f) => f.level === "error"),
1286
- ...(guideValidatorResult ? guideValidatorResult.errors : []),
1287
- ...(hookBehaviorResult && !hookBehaviorResult.ok ? [{
1288
- level: "error",
1289
- code: "hook-behavior-test-failed",
1290
- message: hookBehaviorResult.stderr || hookBehaviorResult.stdout || `exit ${hookBehaviorResult.status}`,
1291
- }] : []),
1292
- ];
1293
- const globalWarnings = [
1294
- ...manifestAudit.findings.filter((f) => f.level === "warn"),
1295
- ...duplicateFindings.filter((f) => f.level === "warn"),
1296
- ...orphanHookFindings.filter((f) => f.level === "warn"),
1297
- ...requiredHookFindings.filter((f) => f.level === "warn"),
1298
- ...(guideValidatorResult ? guideValidatorResult.warnings : []),
1299
- ];
1300
-
1301
- const fail =
1302
- failed.length > 0 ||
1303
- agentFailed.length > 0 ||
1304
- globalErrors.length > 0 ||
1305
- (STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
1306
-
1307
- let exitCode = fail ? 1 : 0;
1308
- if (manifestMissing) exitCode = 3;
1309
-
1310
- console.log(`\nverdict: ${fail ? "❌ FAIL" : "✓ PASS"}${STRICT ? " (strict)" : ""} exit=${exitCode}\n`);
1311
-
1312
- writeLastRun({
1313
- tier: 1,
1314
- verdict: fail ? "FAIL" : "PASS",
1315
- exit_code: exitCode,
1316
- strict: STRICT,
1317
- summary: {
1318
- total: results.length,
1319
- failed: failed.length,
1320
- warned: warned.length,
1321
- passed: clean.length,
1322
- },
1323
- failures: [
1324
- ...manifestAudit.findings.filter((f) => f.level === "error").map((f) => ({
1325
- scope: "plugin_manifest", code: f.code, message: f.message,
1326
- })),
1327
- ...failed.map((r) => ({ skill: r.skill || r.file, errors: r.errors, findings: r.findings })),
1328
- ...agentFailed.map((r) => ({ scope: "agent_guide", file: r.file, errors: r.errors, findings: r.findings })),
1329
- ...duplicateFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
1330
- ...orphanHookFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
1331
- ],
1332
- warnings: [
1333
- ...warned.map((r) => ({ skill: r.skill || r.file, warnings: r.warnings })),
1334
- ...agentWarned.map((r) => ({ scope: "agent_guide", file: r.file, warnings: r.warnings })),
1335
- ],
1336
- });
1337
-
1338
- process.exit(exitCode);
1339
- }
1340
-
1341
- // ── last-run.json ───────────────────────────────────────────────────────────
1342
- // Always written after every run so Claude Code can read it without the user
1343
- // having to copy/paste terminal output.
1344
- // Path: C:/Dev/rdc-skills/.rdc/reports/last-run.json
1345
- //
1346
- // Shape:
1347
- // { tier, ran_at, verdict, exit_code, summary, failures[], warnings[] }
1348
- // "failures" contains only items that actually failed — ready to act on.
1349
-
1350
- function writeLastRun(data) {
1351
- const payload = { ...data, ran_at: new Date().toISOString() };
1352
- try {
1353
- const reportsDir = resolve(REPO_ROOT, ".rdc", "reports");
1354
- if (!existsSync(reportsDir)) mkdirSync(reportsDir, { recursive: true });
1355
-
1356
- const jsonPath = join(reportsDir, "last-run.json");
1357
- writeFileSync(jsonPath, JSON.stringify(payload, null, 2));
1358
-
1359
- const htmlPath = join(reportsDir, "last-run.html");
1360
- writeFileSync(htmlPath, renderHtml(payload));
1361
-
1362
- console.log(`\nresults: file:///${htmlPath.replace(/\\/g, "/")}`);
1363
- } catch (e) {
1364
- console.error(`WARN: could not write last-run reports: ${e.message}`);
1365
- }
1366
- }
1367
-
1368
- function renderHtml(d) {
1369
- const pass = d.verdict === "PASS";
1370
- const ts = new Date(d.ran_at).toLocaleString();
1371
- const badge = pass
1372
- ? `<span class="badge pass">✓ PASS</span>`
1373
- : `<span class="badge fail">✗ FAIL</span>`;
1374
-
1375
- const failRows = (d.failures || []).map((f) => {
1376
- const name = f.skill || f.file || f.scope || "?";
1377
- const detail = [
1378
- ...(f.errors || []),
1379
- ...(f.assertions_failed || []),
1380
- f.error || "",
1381
- f.message || "",
1382
- ].filter(Boolean).join("<br>");
1383
- const extra = f.timed_out ? "<em>timed out</em>" : detail;
1384
- return `<tr class="fail-row"><td>${name}</td><td>${extra || "—"}</td></tr>`;
1385
- }).join("");
1386
-
1387
- const warnRows = (d.warnings || []).map((w) => {
1388
- const name = w.skill || w.file || "?";
1389
- const detail = (w.warnings || []).join("<br>");
1390
- return `<tr class="warn-row"><td>${name}</td><td>${detail}</td></tr>`;
1391
- }).join("");
1392
-
1393
- const s = d.summary || {};
1394
-
1395
- return `<!DOCTYPE html>
1396
- <html lang="en">
1397
- <head>
1398
- <meta charset="utf-8">
1399
- <meta http-equiv="refresh" content="5">
1400
- <title>rdc:self-test — ${d.verdict}</title>
1401
- <style>
1402
- body { font-family: monospace; background: #0d1117; color: #e6edf3; margin: 2rem; }
1403
- h1 { font-size: 1.2rem; margin-bottom: 0.25rem; }
1404
- .meta { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
1405
- .badge { padding: 0.2rem 0.6rem; border-radius: 4px; font-weight: bold; font-size: 1rem; }
1406
- .badge.pass { background: #1a4731; color: #3fb950; }
1407
- .badge.fail { background: #4a1515; color: #f85149; }
1408
- table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
1409
- th { text-align: left; padding: 0.4rem 0.8rem; background: #161b22; color: #8b949e; font-size: 0.8rem; }
1410
- td { padding: 0.4rem 0.8rem; border-top: 1px solid #21262d; vertical-align: top; }
1411
- .fail-row td:first-child { color: #f85149; font-weight: bold; }
1412
- .warn-row td:first-child { color: #d29922; }
1413
- .summary { display: flex; gap: 2rem; margin-bottom: 1rem; }
1414
- .stat { text-align: center; }
1415
- .stat .n { font-size: 1.8rem; font-weight: bold; }
1416
- .stat .n.red { color: #f85149; }
1417
- .stat .n.green { color: #3fb950; }
1418
- .stat .n.yellow { color: #d29922; }
1419
- .stat .label { color: #8b949e; font-size: 0.8rem; }
1420
- .section-title { color: #8b949e; font-size: 0.8rem; text-transform: uppercase;
1421
- letter-spacing: 0.05em; margin: 1.5rem 0 0.5rem; }
1422
- .refresh { color: #8b949e; font-size: 0.75rem; float: right; }
1423
- </style>
1424
- </head>
1425
- <body>
1426
- <h1>rdc:self-test &nbsp; ${badge} &nbsp; Tier ${d.tier}</h1>
1427
- <div class="meta">${ts} &nbsp;·&nbsp; exit ${d.exit_code}${d.strict ? " &nbsp;·&nbsp; strict" : ""}<span class="refresh">auto-refresh 5s</span></div>
1428
-
1429
- <div class="summary">
1430
- <div class="stat"><div class="n">${s.total ?? "?"}</div><div class="label">total</div></div>
1431
- <div class="stat"><div class="n ${(s.failed || 0) > 0 ? "red" : "green"}">${s.failed ?? s.fail ?? 0}</div><div class="label">failed</div></div>
1432
- ${s.warned != null ? `<div class="stat"><div class="n ${(s.warned) > 0 ? "yellow" : ""}">${s.warned}</div><div class="label">warned</div></div>` : ""}
1433
- <div class="stat"><div class="n green">${s.passed ?? s.pass ?? 0}</div><div class="label">passed</div></div>
1434
- ${s.wall_ms != null ? `<div class="stat"><div class="n">${(s.wall_ms/1000).toFixed(1)}s</div><div class="label">wall time</div></div>` : ""}
1435
- </div>
1436
-
1437
- ${failRows ? `<div class="section-title">Failures</div>
1438
- <table><tr><th>Skill / Scope</th><th>Detail</th></tr>${failRows}</table>` : ""}
1439
-
1440
- ${warnRows ? `<div class="section-title">Warnings</div>
1441
- <table><tr><th>Skill / Scope</th><th>Detail</th></tr>${warnRows}</table>` : ""}
1442
-
1443
- ${!failRows && !warnRows ? `<p style="color:#3fb950;margin-top:1rem">All checks passed.</p>` : ""}
1444
- </body>
1445
- </html>`;
1446
- }
1447
-
1448
- if (TIER2) {
1449
- runTier2().catch((e) => {
1450
- console.error(`FATAL: ${e.stack || e.message}`);
1451
- process.exit(2);
1452
- });
1453
- } else {
1454
- try {
1455
- main();
1456
- } catch (e) {
1457
- console.error(`FATAL: ${e.stack || e.message}`);
1458
- process.exit(2);
1459
- }
1460
- }
1
+ #!/usr/bin/env node
2
+ // self-test.mjs — Tier 1 static linter for rdc-skills
3
+ //
4
+ // Validates every skill file in skills/ against a checklist:
5
+ // 1. File parseable (readable, has frontmatter)
6
+ // 2. Frontmatter YAML valid (name + description required)
7
+ // 3. description does NOT start with backtick (breaks Claude Code parser)
8
+ // 4. description contains a `Usage `rdc:<name>` marker matching this skill
9
+ // 5. frontmatter name matches filename (rdc:foo ↔ rdc-foo.md)
10
+ // 6. Every referenced guide file exists (guides/*.md references)
11
+ // 7. Every referenced rule file exists (.claude/rules/*.md references)
12
+ // 8. Body contains the standard output-contract banner
13
+ // 9. Plugin manifest (.claude-plugin/plugin.json) exists, parses, has name+version,
14
+ // and version matches package.json
15
+ // 10. No duplicate skill names; no filename collisions with guides/agents/*.md
16
+ // 11. Hook files referenced by skills exist; orphan hook files get a warning
17
+ //
18
+ // Usage:
19
+ // node scripts/self-test.mjs # run all, human output, exit 1 on fail
20
+ // node scripts/self-test.mjs --json # machine-readable schema v2
21
+ // node scripts/self-test.mjs --skill rdc:foo # single skill
22
+ // node scripts/self-test.mjs --strict # warnings become failures
23
+ // node scripts/self-test.mjs --fix # auto-repair fixable findings
24
+ //
25
+ // Exit codes:
26
+ // 0 = all pass
27
+ // 1 = at least one skill/guide failure
28
+ // 2 = runner crashed (unreadable dirs, etc.)
29
+ // 3 = plugin manifest missing (distinct from skill failures)
30
+
31
+ import { readFileSync, readdirSync, existsSync, writeFileSync, appendFileSync, renameSync, mkdirSync, statSync } from "node:fs";
32
+ import { join, basename, dirname, resolve } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { spawnSync } from "node:child_process";
35
+ import { loadAllManifests } from "./lib/manifest-schema.mjs";
36
+ import { runManifest } from "./lib/runner.mjs";
37
+ import {
38
+ generateRunId,
39
+ resolveSandboxRef,
40
+ deleteSupabaseTestBranch,
41
+ removeWorktree,
42
+ } from "./lib/sandbox.mjs";
43
+
44
+ const __dirname = dirname(fileURLToPath(import.meta.url));
45
+ const REPO_ROOT = resolve(__dirname, "..");
46
+ const SKILLS_DIR = join(REPO_ROOT, "skills");
47
+ const GUIDES_DIR = join(REPO_ROOT, "guides");
48
+ const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
49
+ const HOOKS_DIR = join(REPO_ROOT, "hooks");
50
+ const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
51
+ const PACKAGE_JSON = join(REPO_ROOT, "package.json");
52
+
53
+ // ─── Guide-content validator constants ───────────────────────────────────────
54
+ // Path to the regen-root project (sibling of rdc-skills).
55
+ // Override with REGEN_ROOT env var when running from a non-standard location.
56
+ const REGEN_ROOT = process.env.REGEN_ROOT || "C:/Dev/regen-root";
57
+
58
+ // Terms that must NOT appear in guide/rule files as positive instructions.
59
+ // A line is flagged only when it does NOT contain a known negation pattern.
60
+ const GUIDE_BANNED_TERMS = [
61
+ "@masonator/coolify-mcp",
62
+ "@masonator",
63
+ "coolify-mcp",
64
+ "@regen/brand-studio",
65
+ "brand-studio",
66
+ ];
67
+
68
+ // Negation patterns — if a line contains one of these, the banned-term
69
+ // occurrence is an explicit "don't use" warning and should NOT be flagged.
70
+ const GUIDE_NEGATION_PATTERNS = [
71
+ /\bdo not\b/i,
72
+ /\bnever\b/i,
73
+ /\bno such\b/i,
74
+ /\bdoes not exist\b/i,
75
+ /\bbanned\b/i,
76
+ /\bnot reference\b/i,
77
+ /\bnot use\b/i,
78
+ /\bavoid\b/i,
79
+ /\bremoved\b/i,
80
+ /\bdeprecated\b/i,
81
+ // Markdown table row showing a WRONG→CORRECT mapping (naming-corrections.md pattern)
82
+ /^\|[^|]*WRONG[^|]*\|/i,
83
+ // A table row where the term is in the WRONG column (first data column after the | WRONG | header)
84
+ // Heuristic: line starts with | and the term appears before the first CORRECT value
85
+ /^\|\s*(Brand Studio|brand-studio|@regen\/brand-studio|@masonator[^ |]*|coolify-mcp)[^|]*\|\s*\*\*/,
86
+ ];
87
+
88
+ // Known valid clauth key names. Extend when new keys are added to the vault.
89
+ // Source: C:/Dev/regen-root/.rdc/guides/agent-bootstrap.md credential table
90
+ // + C:/Dev/regen-root/.claude/rules/clauth-endpoints.md
91
+ const KNOWN_CLAUTH_KEYS = new Set([
92
+ "coolify-api",
93
+ "cloudflare",
94
+ "npm",
95
+ "supabase",
96
+ "supabase-anon",
97
+ "supabase-db",
98
+ "supabase-service",
99
+ "supabase-service-role",
100
+ "r2-access-key-id",
101
+ "r2-secret-key",
102
+ "anthropic",
103
+ "openai",
104
+ "github",
105
+ "github-token",
106
+ "vultr-dev-ssh",
107
+ "mcp-web-research-secret",
108
+ "mcp-regen-media-secret",
109
+ "web-research",
110
+ "regen-media",
111
+ ]);
112
+
113
+ /**
114
+ * Audit a single guide or rule file for banned terms and clauth key validity.
115
+ *
116
+ * Returns an array of finding objects:
117
+ * { level: 'error'|'warn', code: string, file: string, line: number, message: string }
118
+ */
119
+ function auditGuideFile(filepath, relPath) {
120
+ const findings = [];
121
+ let text;
122
+ try {
123
+ text = readFileSync(filepath, "utf8");
124
+ } catch (e) {
125
+ findings.push({ level: "error", code: "guide-unreadable", file: relPath, line: 0, message: `cannot read: ${e.message}` });
126
+ return findings;
127
+ }
128
+
129
+ const lines = text.split(/\r?\n/);
130
+
131
+ if (
132
+ relPath === "guides/agent-bootstrap.md" &&
133
+ !text.includes("guides/engineering-behavior.md")
134
+ ) {
135
+ findings.push({
136
+ level: "error",
137
+ code: "guide-engineering-behavior-missing",
138
+ file: relPath,
139
+ line: 1,
140
+ message: "agent bootstrap must reference guides/engineering-behavior.md",
141
+ });
142
+ }
143
+
144
+ lines.forEach((line, i) => {
145
+ const lineNo = i + 1;
146
+
147
+ // Check banned terms (skip negation lines and comment lines)
148
+ for (const term of GUIDE_BANNED_TERMS) {
149
+ if (line.includes(term)) {
150
+ const isNegated = GUIDE_NEGATION_PATTERNS.some((re) => re.test(line));
151
+ if (!isNegated) {
152
+ findings.push({
153
+ level: "error",
154
+ code: "guide-banned-term",
155
+ file: relPath,
156
+ line: lineNo,
157
+ message: `banned term "${term}" found as positive instruction — replace with clauth+REST pattern`,
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ // Check clauth /v/<key> references — flag unknown key names
164
+ // Pattern: /v/some-key-name (standalone path, not inside a larger URL path)
165
+ const clauthKeyRe = /\bhttp:\/\/127\.0\.0\.1:52437\/v\/([\w-]+)/g;
166
+ let m;
167
+ while ((m = clauthKeyRe.exec(line)) !== null) {
168
+ const key = m[1];
169
+ if (!KNOWN_CLAUTH_KEYS.has(key)) {
170
+ findings.push({
171
+ level: "warn",
172
+ code: "guide-clauth-key-unknown",
173
+ file: relPath,
174
+ line: lineNo,
175
+ message: `clauth key "${key}" not in known key list — verify it exists in the vault`,
176
+ });
177
+ }
178
+ }
179
+ });
180
+
181
+ return findings;
182
+ }
183
+
184
+ /**
185
+ * Run the guide-content validator across:
186
+ * 1. C:/Dev/rdc-skills/guides/**\/*.md (base guides shipped with the plugin)
187
+ * 2. REGEN_ROOT/.rdc/guides/**\/*.md (project-level agent guides)
188
+ * 3. REGEN_ROOT/.claude/rules/**\/*.md (project auto-loaded rules)
189
+ *
190
+ * Also scans fixture guides under scripts/fixtures/guides/ when GUIDE_FIXTURE_DIR is set
191
+ * (used by the self-test's own test suite).
192
+ *
193
+ * Returns { findings[], ok, stats }
194
+ */
195
+ function runGuideContentValidator({ fixtureDir } = {}) {
196
+ const allFindings = [];
197
+ let filesScanned = 0;
198
+
199
+ function scanDir(dir, prefix) {
200
+ if (!existsSync(dir)) return;
201
+ let entries;
202
+ try { entries = readdirSync(dir); } catch { return; }
203
+ for (const entry of entries) {
204
+ const fullPath = join(dir, entry);
205
+ let stat;
206
+ try { stat = statSync(fullPath); } catch { continue; }
207
+ if (stat.isDirectory()) {
208
+ scanDir(fullPath, `${prefix}/${entry}`);
209
+ } else if (entry.endsWith(".md")) {
210
+ const relPath = `${prefix}/${entry}`;
211
+ const findings = auditGuideFile(fullPath, relPath);
212
+ allFindings.push(...findings);
213
+ filesScanned++;
214
+ }
215
+ }
216
+ }
217
+
218
+ // 1. Base guides in rdc-skills repo
219
+ scanDir(GUIDES_DIR, "guides");
220
+
221
+ // 2. Regen-root agent guides
222
+ scanDir(join(REGEN_ROOT, ".rdc", "guides"), "regen-root/.rdc/guides");
223
+
224
+ // 3. Regen-root auto-loaded rules
225
+ scanDir(join(REGEN_ROOT, ".claude", "rules"), "regen-root/.claude/rules");
226
+
227
+ // 4. Fixture directory (for self-test's own test assertions)
228
+ if (fixtureDir && existsSync(fixtureDir)) {
229
+ scanDir(fixtureDir, "fixtures");
230
+ }
231
+
232
+ const errors = allFindings.filter((f) => f.level === "error");
233
+ const warnings = allFindings.filter((f) => f.level === "warn");
234
+
235
+ return {
236
+ findings: allFindings,
237
+ errors,
238
+ warnings,
239
+ ok: errors.length === 0,
240
+ stats: { files_scanned: filesScanned, errors: errors.length, warnings: warnings.length },
241
+ };
242
+ }
243
+
244
+ const STANDARD_BANNER = [
245
+ "> **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`",
246
+ "> Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.",
247
+ "> One checklist upfront, updated in place, shown again at end with a 1-line verdict.",
248
+ "",
249
+ "",
250
+ ].join("\n");
251
+
252
+ const argv = process.argv.slice(2);
253
+ const args = new Set(argv);
254
+ const STRICT = args.has("--strict");
255
+ const JSON_OUT = args.has("--json");
256
+ const FIX = args.has("--fix");
257
+ const TIER2 = args.has("--tier2");
258
+ const QUICK = args.has("--quick");
259
+ const ONLY_SKILLS = [];
260
+ for (let i = 0; i < argv.length; i++) {
261
+ if (argv[i] === "--skill" && argv[i + 1]) {
262
+ for (const s of argv[i + 1].split(",")) {
263
+ const t = s.trim();
264
+ if (t) ONLY_SKILLS.push(t);
265
+ }
266
+ i++;
267
+ }
268
+ }
269
+ const ONLY_SKILL = ONLY_SKILLS.length === 1 ? ONLY_SKILLS[0] : null; // backwards-compat for single-skill Tier 1 path
270
+ const parallelArgIdx = argv.indexOf("--parallel");
271
+ const PARALLEL = parallelArgIdx >= 0 ? Math.max(1, parseInt(argv[parallelArgIdx + 1], 10) || 3) : 3;
272
+ const logArgIdx = argv.indexOf("--log");
273
+ const LIVE_LOG = logArgIdx >= 0 ? argv[logArgIdx + 1] : null;
274
+
275
+ /** Append a timestamped line to the live log file (if --log was passed). */
276
+ function liveLog(kind, msg) {
277
+ if (!LIVE_LOG) return;
278
+ const ts = new Date().toISOString();
279
+ try { appendFileSync(LIVE_LOG, `[${ts}] [${kind}] ${msg}\n`); } catch {}
280
+ }
281
+
282
+ // Track files modified by --fix so caller can git diff
283
+ const FIXED_FILES = [];
284
+
285
+ function escapeRegExp(s) {
286
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
287
+ }
288
+
289
+ function parseFrontmatter(text) {
290
+ text = text.replace(/\r\n/g, "\n");
291
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
292
+ if (!m) return { error: "no frontmatter block" };
293
+ const raw = m[1];
294
+ const body = text.slice(m[0].length);
295
+ const fmEnd = m[0].length;
296
+
297
+ const nameMatch = raw.match(/^name:\s*(.+?)\s*$/m);
298
+ if (!nameMatch) return { error: "frontmatter missing `name:`" };
299
+ const name = nameMatch[1].trim();
300
+
301
+ let description = null;
302
+ let descStartsWithBacktick = false;
303
+
304
+ const plainDesc = raw.match(/^description:[ \t]+([^\n>| \t].*)$/m);
305
+ const foldedDesc = raw.match(/^description:[ \t]*>[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
306
+ const literalDesc = raw.match(/^description:[ \t]*\|[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
307
+
308
+ if (plainDesc) {
309
+ description = plainDesc[1].trim();
310
+ descStartsWithBacktick = description.startsWith("`");
311
+ } else if (foldedDesc || literalDesc) {
312
+ const block = (foldedDesc || literalDesc)[1];
313
+ const lines = block
314
+ .split(/\n/)
315
+ .map((l) => l.replace(/^[ \t]+/, ""))
316
+ .filter((l) => l.length > 0);
317
+ description = lines.join(foldedDesc ? " " : "\n").trim();
318
+ const firstLine = lines[0] || "";
319
+ descStartsWithBacktick = firstLine.startsWith("`");
320
+ } else {
321
+ return { error: "frontmatter missing `description:`" };
322
+ }
323
+
324
+ return { name, description, descStartsWithBacktick, body, raw, fmEnd };
325
+ }
326
+
327
+ function expectedSkillName(dirOrFile) {
328
+ // Accept current directory names (foo), legacy directory names
329
+ // (rdc-foo), or legacy filenames (rdc-foo.md).
330
+ const base = basename(dirOrFile, ".md");
331
+ if (base.startsWith("rdc-")) return "rdc:" + base.slice(4);
332
+ if (base === "tests" || base.startsWith(".")) return null;
333
+ return "rdc:" + base;
334
+ }
335
+
336
+ function expectedFrontmatterName(dirOrFile, frontmatterName) {
337
+ const base = basename(dirOrFile, ".md");
338
+ if (frontmatterName && frontmatterName.startsWith("rdc:")) {
339
+ return expectedSkillName(dirOrFile);
340
+ }
341
+ return base;
342
+ }
343
+
344
+ function findReferencedFiles(body) {
345
+ const refs = [];
346
+ const guideRe = /(?:^|[\s(`'"/])(?:\.rdc\/)?guides\/([\w/-]+\.md)/g;
347
+ let m;
348
+ while ((m = guideRe.exec(body)) !== null) {
349
+ refs.push({ kind: "guide", name: m[1] });
350
+ }
351
+ const ruleRe = /\.claude\/rules\/([\w-]+\.md)/g;
352
+ while ((m = ruleRe.exec(body)) !== null) {
353
+ refs.push({ kind: "rule", name: m[1] });
354
+ }
355
+ const hookRe = /(?:^|[\s(`'"/])hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
356
+ while ((m = hookRe.exec(body)) !== null) {
357
+ refs.push({ kind: "hook", name: m[1] });
358
+ }
359
+ return refs;
360
+ }
361
+
362
+ function addFinding(result, level, code, message) {
363
+ result.findings.push({ level, code, message });
364
+ if (level === "error") result.errors.push(message);
365
+ else result.warnings.push(message);
366
+ }
367
+
368
+ function tryAutoFix(filepath, fm, text, result) {
369
+ if (!FIX) return false;
370
+ let changed = false;
371
+ let newText = text;
372
+ let newPath = filepath;
373
+
374
+ // Fix: insert OUTPUT CONTRACT banner after frontmatter if missing
375
+ if (!/OUTPUT CONTRACT/.test(fm.body)) {
376
+ const fmBlock = newText.slice(0, fm.fmEnd);
377
+ const rest = newText.slice(fm.fmEnd).replace(/^\n+/, "");
378
+ newText = fmBlock + "\n" + STANDARD_BANNER + rest;
379
+ writeFileSync(filepath, newText);
380
+ console.log(`FIXED: ${result.name || result.file} — inserted OUTPUT CONTRACT banner`);
381
+ FIXED_FILES.push(filepath);
382
+ changed = true;
383
+ }
384
+
385
+ // Fix: filename/name mismatch — rename directory or file to match frontmatter name
386
+ const _filename = basename(filepath);
387
+ const _dirName = basename(dirname(filepath));
388
+ const _isSubdir = _filename === "SKILL.md";
389
+ const _skillDirOrFile = _isSubdir ? _dirName : _filename;
390
+ const expected = expectedSkillName(_skillDirOrFile);
391
+ if (expected && fm.name !== expected && fm.name.startsWith("rdc:")) {
392
+ if (_isSubdir) {
393
+ // Subdirectory layout: rename the parent directory
394
+ const targetDirName = "rdc-" + fm.name.slice(4);
395
+ const targetDirPath = join(dirname(dirname(filepath)), targetDirName);
396
+ if (!existsSync(targetDirPath)) {
397
+ renameSync(dirname(filepath), targetDirPath);
398
+ const targetPath = join(targetDirPath, "SKILL.md");
399
+ console.log(`FIXED: ${fm.name} — renamed dir ${_dirName} → ${targetDirName}`);
400
+ FIXED_FILES.push(targetPath);
401
+ newPath = targetPath;
402
+ changed = true;
403
+ }
404
+ } else {
405
+ const targetBase = "rdc-" + fm.name.slice(4) + ".md";
406
+ const targetPath = join(dirname(filepath), targetBase);
407
+ if (!existsSync(targetPath)) {
408
+ renameSync(filepath, targetPath);
409
+ console.log(`FIXED: ${fm.name} — renamed ${_filename} → ${targetBase}`);
410
+ FIXED_FILES.push(targetPath);
411
+ newPath = targetPath;
412
+ changed = true;
413
+ }
414
+ }
415
+ }
416
+
417
+ return changed ? newPath : false;
418
+ }
419
+
420
+ function auditSkill(filepath) {
421
+ const filename = basename(filepath);
422
+ // Support both flat (rdc-foo.md) and subdirectory (rdc-foo/SKILL.md) layouts
423
+ const dirName = basename(dirname(filepath)); // "rdc-foo" when filepath is .../rdc-foo/SKILL.md
424
+ const isSubdir = filename === "SKILL.md";
425
+ const skillDirOrFile = isSubdir ? dirName : filename;
426
+ const result = {
427
+ skill: null,
428
+ file: isSubdir ? `skills/${dirName}/SKILL.md` : `skills/${filename}`,
429
+ name: null,
430
+ pass: true,
431
+ errors: [],
432
+ warnings: [],
433
+ findings: [],
434
+ };
435
+
436
+ let text;
437
+ try {
438
+ text = readFileSync(filepath, "utf8");
439
+ } catch (e) {
440
+ addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
441
+ result.pass = false;
442
+ return result;
443
+ }
444
+
445
+ const fm = parseFrontmatter(text);
446
+ if (fm.error) {
447
+ addFinding(result, "error", "frontmatter-invalid", `frontmatter: ${fm.error}`);
448
+ result.pass = false;
449
+ return result;
450
+ }
451
+
452
+ result.name = fm.name;
453
+ result.skill = fm.name;
454
+
455
+ if (fm.descStartsWithBacktick) {
456
+ addFinding(
457
+ result,
458
+ "error",
459
+ "description-backtick-leading",
460
+ "description starts with backtick — Claude Code parser will silently drop this skill from the menu",
461
+ );
462
+ }
463
+
464
+ // Usage marker — must reference this skill's own name
465
+ if (fm.name.startsWith("rdc:")) {
466
+ const escapedName = escapeRegExp(fm.name);
467
+ const usageRe = new RegExp("Usage\\s+`" + escapedName + "[ \\\\`]", "i");
468
+ const anyUsageRe = /\bUsage\s+`/i;
469
+ if (!anyUsageRe.test(fm.description)) {
470
+ addFinding(
471
+ result,
472
+ "warn",
473
+ "usage-marker-missing",
474
+ "description missing `Usage \\`rdc:name <args>\\`` marker — users can't see arg contract in menu",
475
+ );
476
+ } else if (!usageRe.test(fm.description)) {
477
+ addFinding(
478
+ result,
479
+ "warn",
480
+ "usage-marker-mismatch",
481
+ `Usage marker in description does not reference own name "${fm.name}" — copy-paste drift?`,
482
+ );
483
+ }
484
+ }
485
+
486
+ const expected = expectedFrontmatterName(skillDirOrFile, fm.name);
487
+ if (expected && fm.name !== expected) {
488
+ addFinding(
489
+ result,
490
+ "error",
491
+ "name-filename-mismatch",
492
+ `name mismatch: frontmatter says "${fm.name}" but filename implies "${expected}"`,
493
+ );
494
+ }
495
+
496
+ const refs = findReferencedFiles(fm.body);
497
+ const seen = new Set();
498
+ for (const ref of refs) {
499
+ const key = `${ref.kind}:${ref.name}`;
500
+ if (seen.has(key)) continue;
501
+ seen.add(key);
502
+ if (ref.kind === "guide") {
503
+ if (!existsSync(join(GUIDES_DIR, ref.name))) {
504
+ addFinding(
505
+ result,
506
+ "warn",
507
+ "guide-not-found",
508
+ `referenced guide not found in repo: guides/${ref.name}`,
509
+ );
510
+ }
511
+ }
512
+ if (ref.kind === "rule") {
513
+ const regenRoot = "C:/Dev/regen-root/.claude/rules";
514
+ if (existsSync(regenRoot) && !existsSync(join(regenRoot, ref.name))) {
515
+ addFinding(
516
+ result,
517
+ "warn",
518
+ "rule-not-found",
519
+ `referenced rule not found in regen-root: .claude/rules/${ref.name}`,
520
+ );
521
+ }
522
+ }
523
+ if (ref.kind === "hook") {
524
+ if (!existsSync(join(HOOKS_DIR, ref.name))) {
525
+ addFinding(
526
+ result,
527
+ "error",
528
+ "hook-not-found",
529
+ `referenced hook file not found: hooks/${ref.name}`,
530
+ );
531
+ }
532
+ }
533
+ }
534
+
535
+ if (!/OUTPUT CONTRACT/.test(fm.body)) {
536
+ addFinding(
537
+ result,
538
+ "warn",
539
+ "banner-missing",
540
+ "body missing OUTPUT CONTRACT banner (guides/output-contract.md reference)",
541
+ );
542
+ }
543
+
544
+ // Try auto-fix, then re-audit the same skill once to reflect new state
545
+ if (FIX && (result.errors.length > 0 || result.warnings.length > 0)) {
546
+ const newPath = tryAutoFix(filepath, fm, text, result);
547
+ if (newPath) return auditSkill(newPath);
548
+ }
549
+
550
+ result.pass = result.errors.length === 0;
551
+ return result;
552
+ }
553
+
554
+ function auditAgentGuide(filepath) {
555
+ const filename = basename(filepath);
556
+ const result = {
557
+ file: `guides/agents/${filename}`,
558
+ name: filename,
559
+ pass: true,
560
+ errors: [],
561
+ warnings: [],
562
+ findings: [],
563
+ };
564
+ let text;
565
+ try {
566
+ text = readFileSync(filepath, "utf8");
567
+ } catch (e) {
568
+ addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
569
+ result.pass = false;
570
+ return result;
571
+ }
572
+ if (text.replace(/\r\n/g, "\n").startsWith("---\n")) {
573
+ addFinding(
574
+ result,
575
+ "error",
576
+ "agent-guide-has-frontmatter",
577
+ "agent guide still has frontmatter — should be plain markdown",
578
+ );
579
+ }
580
+ if (!/OUTPUT CONTRACT/.test(text)) {
581
+ addFinding(result, "warn", "banner-missing", "body missing OUTPUT CONTRACT banner");
582
+ }
583
+ if (text.trim().length === 0) {
584
+ addFinding(result, "error", "empty-file", "file is empty");
585
+ }
586
+ result.pass = result.errors.length === 0;
587
+ return result;
588
+ }
589
+
590
+ // Plugin manifest check. Returns { ok, exitCode, findings }
591
+ function auditPluginManifest() {
592
+ const findings = [];
593
+ if (!existsSync(PLUGIN_MANIFEST)) {
594
+ findings.push({
595
+ level: "error",
596
+ code: "manifest-missing",
597
+ message: `.claude-plugin/plugin.json not found`,
598
+ });
599
+ return { ok: false, exitCode: 3, findings, manifest: null };
600
+ }
601
+ let manifest;
602
+ try {
603
+ manifest = JSON.parse(readFileSync(PLUGIN_MANIFEST, "utf8"));
604
+ } catch (e) {
605
+ findings.push({
606
+ level: "error",
607
+ code: "manifest-invalid-json",
608
+ message: `plugin.json invalid JSON: ${e.message}`,
609
+ });
610
+ return { ok: false, exitCode: 1, findings, manifest: null };
611
+ }
612
+ if (!manifest.name) {
613
+ findings.push({ level: "error", code: "manifest-missing-name", message: "plugin.json missing `name`" });
614
+ }
615
+ if (!manifest.version) {
616
+ findings.push({
617
+ level: "error",
618
+ code: "manifest-missing-version",
619
+ message: "plugin.json missing `version`",
620
+ });
621
+ }
622
+ try {
623
+ const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
624
+ if (manifest.version && pkg.version && manifest.version !== pkg.version) {
625
+ findings.push({
626
+ level: "error",
627
+ code: "manifest-version-mismatch",
628
+ message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
629
+ });
630
+ }
631
+ } catch (e) {
632
+ findings.push({
633
+ level: "warn",
634
+ code: "package-json-unreadable",
635
+ message: `could not read package.json for version cross-check: ${e.message}`,
636
+ });
637
+ }
638
+ const ok = findings.every((f) => f.level !== "error");
639
+ return { ok, exitCode: ok ? 0 : 1, findings, manifest };
640
+ }
641
+
642
+ // Duplicate skill name + filename collision check
643
+ function auditDuplicates(results) {
644
+ const findings = [];
645
+ const nameMap = new Map();
646
+ for (const r of results) {
647
+ if (!r.name) continue;
648
+ if (nameMap.has(r.name)) {
649
+ findings.push({
650
+ level: "error",
651
+ code: "duplicate-skill-name",
652
+ message: `duplicate skill name "${r.name}" in ${nameMap.get(r.name)} and ${r.file}`,
653
+ });
654
+ } else {
655
+ nameMap.set(r.name, r.file);
656
+ }
657
+ }
658
+ if (existsSync(AGENT_GUIDES_DIR)) {
659
+ const agentBases = new Set(
660
+ readdirSync(AGENT_GUIDES_DIR)
661
+ .filter((f) => f.endsWith(".md"))
662
+ .map((f) => basename(f, ".md")),
663
+ );
664
+ for (const r of results) {
665
+ // r.file is either "skills/rdc-foo/SKILL.md" or legacy "skills/rdc-foo.md"
666
+ // Extract the skill directory/base name in both cases
667
+ const parts = r.file.replace(/\\/g, "/").split("/");
668
+ let skillBase;
669
+ if (parts.length >= 3 && parts[2] === "SKILL.md") {
670
+ skillBase = parts[1]; // "rdc-foo" from "skills/rdc-foo/SKILL.md"
671
+ } else {
672
+ skillBase = basename(r.file, ".md"); // legacy
673
+ }
674
+ const stem = skillBase.startsWith("rdc-") ? skillBase.slice(4) : skillBase;
675
+ if (agentBases.has(stem)) {
676
+ findings.push({
677
+ level: "info",
678
+ code: "skill-guide-name-overlap",
679
+ message: `skills/${skillBase}/SKILL.md overlaps guides/agents/${stem}.md; allowed when a user-facing skill delegates to an agent guide`,
680
+ });
681
+ }
682
+ }
683
+ }
684
+ return findings;
685
+ }
686
+
687
+ // Orphan hook scan — warn when hooks/ files aren't referenced anywhere
688
+ function auditOrphanHooks(results) {
689
+ const findings = [];
690
+ if (!existsSync(HOOKS_DIR)) return findings;
691
+ let hookFiles;
692
+ try {
693
+ hookFiles = readdirSync(HOOKS_DIR).filter((f) => /\.(?:js|mjs|cjs)$/.test(f));
694
+ } catch {
695
+ return findings;
696
+ }
697
+ const referenced = new Set();
698
+ for (const r of results) {
699
+ for (const f of r.findings) {
700
+ // no-op: findings don't carry refs. Re-scan body below.
701
+ }
702
+ }
703
+ // Re-scan skill bodies + known config files for hook refs
704
+ const sources = [
705
+ ...readdirSync(SKILLS_DIR)
706
+ .filter((f) => {
707
+ try {
708
+ return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
709
+ } catch { return false; }
710
+ })
711
+ .map((f) => join(SKILLS_DIR, f, "SKILL.md")),
712
+ join(REPO_ROOT, ".claude", "settings.json"),
713
+ join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
714
+ PLUGIN_MANIFEST,
715
+ ];
716
+ for (const src of sources) {
717
+ if (!existsSync(src)) continue;
718
+ try {
719
+ const text = readFileSync(src, "utf8");
720
+ // Match either "hooks/foo.js" paths or bare basenames (stem match)
721
+ const pathRe = /hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
722
+ let m;
723
+ while ((m = pathRe.exec(text)) !== null) referenced.add(m[1]);
724
+ for (const hf of hookFiles) {
725
+ const stem = hf.replace(/\.(?:js|mjs|cjs)$/, "");
726
+ if (text.includes(hf) || new RegExp("\\b" + escapeRegExp(stem) + "\\b").test(text)) {
727
+ referenced.add(hf);
728
+ }
729
+ }
730
+ } catch {}
731
+ }
732
+ for (const hf of hookFiles) {
733
+ if (!referenced.has(hf)) {
734
+ findings.push({
735
+ level: "info",
736
+ code: "orphan-hook",
737
+ message: `hooks/${hf} exists but is not referenced by any skill; plugin hooks may still be convention-discovered or wired by settings`,
738
+ });
739
+ }
740
+ }
741
+ return findings;
742
+ }
743
+
744
+ function auditRequiredHookWiring() {
745
+ const findings = [];
746
+ const required = [
747
+ {
748
+ file: join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
749
+ label: "scripts/install-rdc-skills.js",
750
+ tokens: [
751
+ "UserPromptExpansion",
752
+ "UserPromptSubmit",
753
+ "Stop",
754
+ "rdc-invocation-marker.js",
755
+ "rdc-output-contract-gate.js",
756
+ ],
757
+ },
758
+ {
759
+ file: join(REPO_ROOT, "scripts", "install.ps1"),
760
+ label: "scripts/install.ps1",
761
+ tokens: [
762
+ "UserPromptExpansion",
763
+ "UserPromptSubmit",
764
+ "Stop",
765
+ "rdc-invocation-marker.js",
766
+ "rdc-output-contract-gate.js",
767
+ ],
768
+ },
769
+ ];
770
+
771
+ for (const hookFile of [
772
+ "rdc-invocation-marker.js",
773
+ "rdc-output-contract-gate.js",
774
+ ]) {
775
+ if (!existsSync(join(HOOKS_DIR, hookFile))) {
776
+ findings.push({
777
+ level: "error",
778
+ code: "required-hook-missing",
779
+ message: `hooks/${hookFile} is required for RDC output-contract enforcement`,
780
+ });
781
+ }
782
+ }
783
+
784
+ for (const target of required) {
785
+ if (!existsSync(target.file)) {
786
+ findings.push({
787
+ level: "error",
788
+ code: "hook-installer-missing",
789
+ message: `${target.label} not found for RDC hook wiring audit`,
790
+ });
791
+ continue;
792
+ }
793
+ let text = "";
794
+ try { text = readFileSync(target.file, "utf8"); } catch (e) {
795
+ findings.push({
796
+ level: "error",
797
+ code: "hook-installer-unreadable",
798
+ message: `${target.label} unreadable: ${e.message}`,
799
+ });
800
+ continue;
801
+ }
802
+ for (const token of target.tokens) {
803
+ if (!text.includes(token)) {
804
+ findings.push({
805
+ level: "error",
806
+ code: "required-hook-wiring-missing",
807
+ message: `${target.label} must reference ${token}`,
808
+ });
809
+ }
810
+ }
811
+ }
812
+
813
+ return findings;
814
+ }
815
+
816
+ function runHookBehaviorTests() {
817
+ const result = spawnSync(process.execPath, [join(REPO_ROOT, "scripts", "test-rdc-hooks.mjs")], {
818
+ cwd: REPO_ROOT,
819
+ encoding: "utf8",
820
+ });
821
+ return {
822
+ ok: result.status === 0,
823
+ status: result.status,
824
+ stdout: (result.stdout || "").trim(),
825
+ stderr: (result.stderr || "").trim(),
826
+ };
827
+ }
828
+
829
+ // ─── Tier 2 behavioral runner ──────────────────────────────────────────────
830
+
831
+ async function runPool(items, concurrency, worker) {
832
+ const results = new Array(items.length);
833
+ let next = 0;
834
+ async function lane() {
835
+ while (true) {
836
+ const i = next++;
837
+ if (i >= items.length) return;
838
+ try {
839
+ results[i] = await worker(items[i], i);
840
+ } catch (e) {
841
+ results[i] = { pass: false, error: e?.message || String(e) };
842
+ }
843
+ }
844
+ }
845
+ const lanes = Array.from({ length: Math.min(concurrency, items.length) }, () => lane());
846
+ await Promise.all(lanes);
847
+ return results;
848
+ }
849
+
850
+ async function runTier2() {
851
+ console.log("\nrdc-skills self-test — Tier 2 (behavioral)\n");
852
+
853
+ // Load manifests
854
+ let all = loadAllManifests();
855
+ if (ONLY_SKILLS.length > 0) {
856
+ all = all.filter((m) => m.manifest && ONLY_SKILLS.includes(m.manifest.skill));
857
+ }
858
+ if (QUICK) {
859
+ all = all.filter((m) => !(m.manifest?.fixture?.slow === true));
860
+ }
861
+
862
+ if (all.length === 0) {
863
+ console.log("No manifests found in skills/tests/.");
864
+ console.log("(Tier 2 will run once WP6/WP7 land baseline manifests.)");
865
+ process.exit(STRICT ? 1 : 0);
866
+ }
867
+
868
+ // Reject invalid manifests up front
869
+ const invalid = all.filter((m) => !m.ok);
870
+ const valid = all.filter((m) => m.ok);
871
+ for (const m of invalid) {
872
+ console.error(`SKIP ${m.file}: ${m.errors.map((e) => `${e.path}: ${e.msg}`).join("; ")}`);
873
+ }
874
+ if (valid.length === 0) {
875
+ console.error("No valid manifests to run.");
876
+ process.exit(1);
877
+ }
878
+
879
+ const runId = generateRunId();
880
+ console.log(`runId: ${runId}`);
881
+ console.log(`manifests: ${valid.length} valid, ${invalid.length} invalid`);
882
+ console.log(`parallel: ${PARALLEL}${QUICK ? " quick" : ""}`);
883
+ liveLog("start", `runId=${runId} skills=${valid.map((m) => m.manifest.skill).join(",")}`);
884
+
885
+ // WP-A3: use resolveSandboxRef — defaults to main-db mode (no branch, no cost)
886
+ const sandboxRef = await resolveSandboxRef({ runId });
887
+ console.log(`sandbox mode: ${sandboxRef.mode} apiUrl=${sandboxRef.apiUrl || "none"} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
888
+ liveLog("sandbox", `mode=${sandboxRef.mode} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
889
+
890
+ // Run
891
+ const toRun = valid.map((m) => m.manifest);
892
+ const total = toRun.length;
893
+ let launched = 0;
894
+ let finished = 0;
895
+ const started = Date.now();
896
+ let results;
897
+ try {
898
+ results = await runPool(toRun, PARALLEL, async (manifest) => {
899
+ const n = ++launched;
900
+ const timeoutSec = Math.round((manifest.timeout_ms || 240_000) / 1000);
901
+ console.log(`▶ [${n}/${total}] ${manifest.skill} (timeout ${timeoutSec}s)`);
902
+ liveLog("running", `skill=${manifest.skill}`);
903
+ const r = await runManifest(manifest, {
904
+ runId,
905
+ supabaseBranchRef: sandboxRef,
906
+ projectCwd: process.cwd(),
907
+ });
908
+ const done = ++finished;
909
+ const status = r.error ? "ERROR" : r.pass ? "PASS" : "FAIL";
910
+ const dur = r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(1)}s` : "?";
911
+ const icon = status === "PASS" ? "✓" : "✗";
912
+ const detail = r.error || (r.failures || []).map((f) => f.message).join("; ") || "";
913
+ console.log(
914
+ `${icon} [${done}/${total}] ${r.skill} ${dur} ${status}${detail ? " — " + detail : ""}`,
915
+ );
916
+ liveLog(status.toLowerCase(), `skill=${r.skill} duration=${r.duration_ms}ms${detail ? " | " + detail : ""}`);
917
+ return r;
918
+ });
919
+ } finally {
920
+ // Teardown — always. Pass process.cwd() so removeWorktree targets the
921
+ // same projectRoot (regen-root) that createWorktree used.
922
+ const projectCwd = process.cwd();
923
+ for (const r of results || []) {
924
+ if (r && r.skill) {
925
+ try { removeWorktree(runId, r.skill, projectCwd); } catch {}
926
+ }
927
+ }
928
+ try { await deleteSupabaseTestBranch(sandboxRef.branchId); } catch {}
929
+ }
930
+
931
+ const totalDuration = Date.now() - started;
932
+
933
+ // Report
934
+ const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
935
+ console.log("\n" + pad("skill", 24) + pad("status", 10) + pad("duration", 12) + "failures");
936
+ console.log("─".repeat(90));
937
+ let failed = 0;
938
+ let errored = 0;
939
+ for (const r of results) {
940
+ if (r.error) {
941
+ errored++;
942
+ console.log(pad(r.skill || "?", 24) + pad("ERROR", 10) + pad("-", 12) + r.error);
943
+ continue;
944
+ }
945
+ const status = r.pass ? "pass" : "FAIL";
946
+ if (!r.pass) failed++;
947
+ const dur = `${r.duration_ms}ms`;
948
+ const fs = (r.failures || []).map((f) => `${f.predicate}: ${f.message}`).join("; ") || "";
949
+ console.log(pad(r.skill, 24) + pad(status, 10) + pad(dur, 12) + fs);
950
+ }
951
+ console.log("─".repeat(90));
952
+ console.log(
953
+ `total: ${results.length} | pass: ${results.length - failed - errored} | fail: ${failed} | error: ${errored} | wall: ${totalDuration}ms`,
954
+ );
955
+ liveLog("done", `total=${results.length} pass=${results.length - failed - errored} fail=${failed} error=${errored} wall=${totalDuration}ms`);
956
+
957
+ // JSON dump
958
+ try {
959
+ const REPORTS_DIR = resolve(REPO_ROOT, ".rdc", "reports");
960
+ if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
961
+ const iso = new Date().toISOString().replace(/[:.]/g, "-");
962
+ const reportPath = join(REPORTS_DIR, `self-test-tier2-${iso}.json`);
963
+ writeFileSync(
964
+ reportPath,
965
+ JSON.stringify(
966
+ {
967
+ run_id: runId,
968
+ started_at: new Date(started).toISOString(),
969
+ duration_ms: totalDuration,
970
+ parallel: PARALLEL,
971
+ quick: QUICK,
972
+ supabase_branch: sandboxRef.branchId || null,
973
+ summary: {
974
+ total: results.length,
975
+ pass: results.length - failed - errored,
976
+ fail: failed,
977
+ error: errored,
978
+ },
979
+ results,
980
+ invalid_manifests: invalid.map((m) => ({ file: m.file, errors: m.errors })),
981
+ },
982
+ null,
983
+ 2,
984
+ ),
985
+ );
986
+ console.log(`\nreport: ${reportPath}`);
987
+ } catch (e) {
988
+ console.error(`WARN: failed to write JSON report: ${e.message}`);
989
+ }
990
+
991
+ writeLastRun({
992
+ tier: 2,
993
+ verdict: (failed > 0 || errored > 0) ? "FAIL" : "PASS",
994
+ exit_code: errored > 0 ? 2 : failed > 0 ? 1 : 0,
995
+ summary: {
996
+ total: results.length,
997
+ passed: results.length - failed - errored,
998
+ failed,
999
+ errored,
1000
+ wall_ms: totalDuration,
1001
+ },
1002
+ failures: results
1003
+ .filter((r) => !r.pass || r.error)
1004
+ .map((r) => ({
1005
+ skill: r.skill,
1006
+ error: r.error || null,
1007
+ timed_out: r.observed?.timed_out || false,
1008
+ assertions_failed: (r.failures || []).map((f) => `${f.predicate}: ${f.message}`),
1009
+ })),
1010
+ warnings: [],
1011
+ });
1012
+
1013
+ if (errored > 0) process.exit(2);
1014
+ process.exit(failed > 0 ? 1 : 0);
1015
+ }
1016
+
1017
+ function main() {
1018
+ // Plugin manifest pass first — can short-circuit with exit 3
1019
+ const manifestAudit = auditPluginManifest();
1020
+ const manifestMissing = manifestAudit.findings.some((f) => f.code === "manifest-missing");
1021
+
1022
+ let files;
1023
+ try {
1024
+ files = readdirSync(SKILLS_DIR).filter((f) => {
1025
+ try {
1026
+ return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
1027
+ } catch { return false; }
1028
+ });
1029
+ } catch (e) {
1030
+ console.error(`FATAL: cannot read skills dir ${SKILLS_DIR}: ${e.message}`);
1031
+ process.exit(2);
1032
+ }
1033
+
1034
+ if (ONLY_SKILLS.length > 0) {
1035
+ files = files.filter((f) => {
1036
+ return ONLY_SKILLS.some((s) => {
1037
+ const bare = s.replace(/^rdc:/, "");
1038
+ return f === bare || f === s.replace(":", "-");
1039
+ });
1040
+ });
1041
+ if (files.length === 0) {
1042
+ console.error(`no skill file matches: ${ONLY_SKILLS.join(", ")}`);
1043
+ process.exit(2);
1044
+ }
1045
+ }
1046
+
1047
+ if (JSON_OUT) {
1048
+ // JSON path: buffer everything then dump
1049
+ const results = files.map((f) => auditSkill(join(SKILLS_DIR, f, "SKILL.md")));
1050
+
1051
+ let duplicateFindings = [];
1052
+ let orphanHookFindings = [];
1053
+ if (!ONLY_SKILL) {
1054
+ duplicateFindings = auditDuplicates(results);
1055
+ orphanHookFindings = auditOrphanHooks(results);
1056
+ }
1057
+
1058
+ let agentResults = [];
1059
+ if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
1060
+ const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
1061
+ agentResults = agentFiles.map((f) => auditAgentGuide(join(AGENT_GUIDES_DIR, f)));
1062
+ }
1063
+
1064
+ const guideValidatorResultJson = !ONLY_SKILL ? runGuideContentValidator() : null;
1065
+ const hookBehaviorResultJson = !ONLY_SKILL ? runHookBehaviorTests() : null;
1066
+
1067
+ const failed = results.filter((r) => r.errors.length > 0);
1068
+ const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1069
+ const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1070
+ const agentFailed = agentResults.filter((r) => r.errors.length > 0);
1071
+ const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1072
+ const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1073
+ const globalErrors = [
1074
+ ...manifestAudit.findings.filter((f) => f.level === "error"),
1075
+ ...duplicateFindings.filter((f) => f.level === "error"),
1076
+ ...orphanHookFindings.filter((f) => f.level === "error"),
1077
+ ...(guideValidatorResultJson ? guideValidatorResultJson.errors : []),
1078
+ ...(hookBehaviorResultJson && !hookBehaviorResultJson.ok ? [{
1079
+ level: "error",
1080
+ code: "hook-behavior-test-failed",
1081
+ message: hookBehaviorResultJson.stderr || hookBehaviorResultJson.stdout || `exit ${hookBehaviorResultJson.status}`,
1082
+ }] : []),
1083
+ ];
1084
+ const globalWarnings = [
1085
+ ...manifestAudit.findings.filter((f) => f.level === "warn"),
1086
+ ...duplicateFindings.filter((f) => f.level === "warn"),
1087
+ ...orphanHookFindings.filter((f) => f.level === "warn"),
1088
+ ...(guideValidatorResultJson ? guideValidatorResultJson.warnings : []),
1089
+ ];
1090
+ const fail =
1091
+ failed.length > 0 ||
1092
+ agentFailed.length > 0 ||
1093
+ globalErrors.length > 0 ||
1094
+ (STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
1095
+ let exitCode = fail ? 1 : 0;
1096
+ if (manifestMissing) exitCode = 3;
1097
+
1098
+ console.log(
1099
+ JSON.stringify(
1100
+ {
1101
+ summary: {
1102
+ total: results.length,
1103
+ failed: failed.length,
1104
+ warned: warned.length,
1105
+ clean: clean.length,
1106
+ strict: STRICT,
1107
+ fix: FIX,
1108
+ fixed_files: FIXED_FILES.map((p) => p.replace(REPO_ROOT + "\\", "").replace(REPO_ROOT + "/", "")),
1109
+ verdict: fail ? "FAIL" : "PASS",
1110
+ exit_code: exitCode,
1111
+ },
1112
+ plugin_manifest: {
1113
+ ok: manifestAudit.ok,
1114
+ version: manifestAudit.manifest?.version || null,
1115
+ findings: manifestAudit.findings,
1116
+ },
1117
+ global_findings: [...duplicateFindings, ...orphanHookFindings],
1118
+ guide_content_validator: guideValidatorResultJson
1119
+ ? {
1120
+ ok: guideValidatorResultJson.ok,
1121
+ stats: guideValidatorResultJson.stats,
1122
+ findings: guideValidatorResultJson.findings,
1123
+ }
1124
+ : null,
1125
+ hook_behavior_tests: hookBehaviorResultJson,
1126
+ results: results.map((r) => ({
1127
+ skill: r.skill,
1128
+ file: r.file,
1129
+ pass: r.pass,
1130
+ findings: r.findings,
1131
+ })),
1132
+ agent_guides: {
1133
+ total: agentResults.length,
1134
+ failed: agentFailed.length,
1135
+ warned: agentWarned.length,
1136
+ clean: agentClean.length,
1137
+ results: agentResults.map((r) => ({
1138
+ file: r.file,
1139
+ pass: r.pass,
1140
+ findings: r.findings,
1141
+ })),
1142
+ },
1143
+ },
1144
+ null,
1145
+ 2,
1146
+ ),
1147
+ );
1148
+ process.exit(exitCode);
1149
+ }
1150
+
1151
+ // Human-readable path: stream each result live as it's audited
1152
+ const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
1153
+
1154
+ // Count agent guides upfront for the startup banner
1155
+ let agentFileCount = 0;
1156
+ if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
1157
+ try { agentFileCount = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md")).length; } catch {}
1158
+ }
1159
+
1160
+ // Startup banner — printed immediately so the user sees activity right away
1161
+ console.log(`\nrdc-skills self-test — Tier 1 (static lint)`);
1162
+ const scopeDesc = ONLY_SKILLS.length > 0
1163
+ ? `skill: ${ONLY_SKILLS.join(", ")}`
1164
+ : `${files.length} skill${files.length !== 1 ? "s" : ""}${agentFileCount ? ` + ${agentFileCount} agent guide${agentFileCount !== 1 ? "s" : ""}` : ""}`;
1165
+ console.log(`Scanning ${scopeDesc}${STRICT ? " [strict]" : ""}${FIX ? " [fix]" : ""}\n`);
1166
+
1167
+ // Plugin manifest line
1168
+ const manifestStatus = manifestAudit.ok ? "pass" : "FAIL";
1169
+ const manifestNote = manifestAudit.ok
1170
+ ? `v${manifestAudit.manifest?.version || "?"}`
1171
+ : manifestAudit.findings.map((f) => f.message).join("; ");
1172
+ console.log(pad("plugin manifest", 24) + pad(manifestStatus, 10) + manifestNote);
1173
+ console.log();
1174
+
1175
+ // Skills — print each row immediately after audit (no buffering)
1176
+ console.log(pad("skill", 24) + pad("status", 10) + "notes");
1177
+ console.log("─".repeat(80));
1178
+ const results = [];
1179
+ for (const f of files) {
1180
+ const r = auditSkill(join(SKILLS_DIR, f, "SKILL.md"));
1181
+ results.push(r);
1182
+ const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
1183
+ const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
1184
+ console.log(pad(r.name || r.file, 24) + pad(status, 10) + notes);
1185
+ const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
1186
+ for (const extra of extras) {
1187
+ console.log(pad("", 24) + pad("", 10) + " " + extra);
1188
+ }
1189
+ }
1190
+
1191
+ const failed = results.filter((r) => r.errors.length > 0);
1192
+ const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1193
+ const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1194
+
1195
+ console.log("─".repeat(80));
1196
+ console.log(`total: ${results.length} | fail: ${failed.length} | warn: ${warned.length} | pass: ${clean.length}`);
1197
+
1198
+ // Agent guides — stream live
1199
+ const agentResults = [];
1200
+ if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
1201
+ const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
1202
+ if (agentFiles.length > 0) {
1203
+ console.log("\nagent guides (guides/agents/*.md)\n");
1204
+ console.log(pad("guide", 24) + pad("status", 10) + "notes");
1205
+ console.log("─".repeat(80));
1206
+ for (const f of agentFiles) {
1207
+ const r = auditAgentGuide(join(AGENT_GUIDES_DIR, f));
1208
+ agentResults.push(r);
1209
+ const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
1210
+ const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
1211
+ console.log(pad(r.file, 24) + pad(status, 10) + notes);
1212
+ const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
1213
+ for (const extra of extras) {
1214
+ console.log(pad("", 24) + pad("", 10) + " " + extra);
1215
+ }
1216
+ }
1217
+ const agentFailed = agentResults.filter((r) => r.errors.length > 0);
1218
+ const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1219
+ const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
1220
+ console.log("─".repeat(80));
1221
+ console.log(`total: ${agentResults.length} | fail: ${agentFailed.length} | warn: ${agentWarned.length} | pass: ${agentClean.length}`);
1222
+ }
1223
+ }
1224
+
1225
+ // Guide-content validation — check .rdc/guides + .claude/rules for banned terms and invalid clauth keys
1226
+ let guideValidatorResult = null;
1227
+ if (!ONLY_SKILL) {
1228
+ guideValidatorResult = runGuideContentValidator();
1229
+ if (guideValidatorResult.stats.files_scanned > 0) {
1230
+ console.log(`\nguide-content validator (${guideValidatorResult.stats.files_scanned} files)\n`);
1231
+ console.log("─".repeat(80));
1232
+ if (guideValidatorResult.findings.length === 0) {
1233
+ console.log(" ✓ no banned terms or invalid clauth keys found");
1234
+ } else {
1235
+ for (const f of guideValidatorResult.findings) {
1236
+ const tag = f.level === "error" ? "FAIL" : "WARN";
1237
+ console.log(` [${tag}] ${f.file}:${f.line} ${f.code} ${f.message}`);
1238
+ }
1239
+ }
1240
+ console.log("─".repeat(80));
1241
+ const gErrors = guideValidatorResult.errors.length;
1242
+ const gWarnings = guideValidatorResult.warnings.length;
1243
+ console.log(`total: ${guideValidatorResult.stats.files_scanned} | errors: ${gErrors} | warnings: ${gWarnings}`);
1244
+ }
1245
+ }
1246
+
1247
+ // Cross-skill checks — deferred until all audits complete
1248
+ let duplicateFindings = [];
1249
+ let orphanHookFindings = [];
1250
+ let requiredHookFindings = [];
1251
+ let hookBehaviorResult = null;
1252
+ if (!ONLY_SKILL) {
1253
+ duplicateFindings = auditDuplicates(results);
1254
+ orphanHookFindings = auditOrphanHooks(results);
1255
+ requiredHookFindings = auditRequiredHookWiring();
1256
+ hookBehaviorResult = runHookBehaviorTests();
1257
+ }
1258
+
1259
+ if (duplicateFindings.length + orphanHookFindings.length + requiredHookFindings.length > 0) {
1260
+ console.log("\nglobal findings\n");
1261
+ for (const f of [...duplicateFindings, ...orphanHookFindings, ...requiredHookFindings]) {
1262
+ console.log(` [${f.level}] ${f.code}: ${f.message}`);
1263
+ }
1264
+ }
1265
+
1266
+ if (hookBehaviorResult) {
1267
+ console.log("\nhook behavior tests\n");
1268
+ console.log("─".repeat(80));
1269
+ console.log(hookBehaviorResult.ok ? " pass rdc marker/gate behavioral smoke tests" : " FAIL rdc marker/gate behavioral smoke tests");
1270
+ if (hookBehaviorResult.stdout) console.log(` ${hookBehaviorResult.stdout}`);
1271
+ if (hookBehaviorResult.stderr) console.log(` ${hookBehaviorResult.stderr}`);
1272
+ }
1273
+
1274
+ if (FIXED_FILES.length > 0) {
1275
+ console.log("\nfixed files (review with git diff):");
1276
+ for (const p of FIXED_FILES) console.log(` ${p}`);
1277
+ }
1278
+
1279
+ const agentFailed = agentResults.filter((r) => r.errors.length > 0);
1280
+ const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
1281
+ const globalErrors = [
1282
+ ...manifestAudit.findings.filter((f) => f.level === "error"),
1283
+ ...duplicateFindings.filter((f) => f.level === "error"),
1284
+ ...orphanHookFindings.filter((f) => f.level === "error"),
1285
+ ...requiredHookFindings.filter((f) => f.level === "error"),
1286
+ ...(guideValidatorResult ? guideValidatorResult.errors : []),
1287
+ ...(hookBehaviorResult && !hookBehaviorResult.ok ? [{
1288
+ level: "error",
1289
+ code: "hook-behavior-test-failed",
1290
+ message: hookBehaviorResult.stderr || hookBehaviorResult.stdout || `exit ${hookBehaviorResult.status}`,
1291
+ }] : []),
1292
+ ];
1293
+ const globalWarnings = [
1294
+ ...manifestAudit.findings.filter((f) => f.level === "warn"),
1295
+ ...duplicateFindings.filter((f) => f.level === "warn"),
1296
+ ...orphanHookFindings.filter((f) => f.level === "warn"),
1297
+ ...requiredHookFindings.filter((f) => f.level === "warn"),
1298
+ ...(guideValidatorResult ? guideValidatorResult.warnings : []),
1299
+ ];
1300
+
1301
+ const fail =
1302
+ failed.length > 0 ||
1303
+ agentFailed.length > 0 ||
1304
+ globalErrors.length > 0 ||
1305
+ (STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
1306
+
1307
+ let exitCode = fail ? 1 : 0;
1308
+ if (manifestMissing) exitCode = 3;
1309
+
1310
+ console.log(`\nverdict: ${fail ? "❌ FAIL" : "✓ PASS"}${STRICT ? " (strict)" : ""} exit=${exitCode}\n`);
1311
+
1312
+ writeLastRun({
1313
+ tier: 1,
1314
+ verdict: fail ? "FAIL" : "PASS",
1315
+ exit_code: exitCode,
1316
+ strict: STRICT,
1317
+ summary: {
1318
+ total: results.length,
1319
+ failed: failed.length,
1320
+ warned: warned.length,
1321
+ passed: clean.length,
1322
+ },
1323
+ failures: [
1324
+ ...manifestAudit.findings.filter((f) => f.level === "error").map((f) => ({
1325
+ scope: "plugin_manifest", code: f.code, message: f.message,
1326
+ })),
1327
+ ...failed.map((r) => ({ skill: r.skill || r.file, errors: r.errors, findings: r.findings })),
1328
+ ...agentFailed.map((r) => ({ scope: "agent_guide", file: r.file, errors: r.errors, findings: r.findings })),
1329
+ ...duplicateFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
1330
+ ...orphanHookFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
1331
+ ],
1332
+ warnings: [
1333
+ ...warned.map((r) => ({ skill: r.skill || r.file, warnings: r.warnings })),
1334
+ ...agentWarned.map((r) => ({ scope: "agent_guide", file: r.file, warnings: r.warnings })),
1335
+ ],
1336
+ });
1337
+
1338
+ process.exit(exitCode);
1339
+ }
1340
+
1341
+ // ── last-run.json ───────────────────────────────────────────────────────────
1342
+ // Always written after every run so Claude Code can read it without the user
1343
+ // having to copy/paste terminal output.
1344
+ // Path: C:/Dev/rdc-skills/.rdc/reports/last-run.json
1345
+ //
1346
+ // Shape:
1347
+ // { tier, ran_at, verdict, exit_code, summary, failures[], warnings[] }
1348
+ // "failures" contains only items that actually failed — ready to act on.
1349
+
1350
+ function writeLastRun(data) {
1351
+ const payload = { ...data, ran_at: new Date().toISOString() };
1352
+ try {
1353
+ const reportsDir = resolve(REPO_ROOT, ".rdc", "reports");
1354
+ if (!existsSync(reportsDir)) mkdirSync(reportsDir, { recursive: true });
1355
+
1356
+ const jsonPath = join(reportsDir, "last-run.json");
1357
+ writeFileSync(jsonPath, JSON.stringify(payload, null, 2));
1358
+
1359
+ const htmlPath = join(reportsDir, "last-run.html");
1360
+ writeFileSync(htmlPath, renderHtml(payload));
1361
+
1362
+ console.log(`\nresults: file:///${htmlPath.replace(/\\/g, "/")}`);
1363
+ } catch (e) {
1364
+ console.error(`WARN: could not write last-run reports: ${e.message}`);
1365
+ }
1366
+ }
1367
+
1368
+ function renderHtml(d) {
1369
+ const pass = d.verdict === "PASS";
1370
+ const ts = new Date(d.ran_at).toLocaleString();
1371
+ const badge = pass
1372
+ ? `<span class="badge pass">✓ PASS</span>`
1373
+ : `<span class="badge fail">✗ FAIL</span>`;
1374
+
1375
+ const failRows = (d.failures || []).map((f) => {
1376
+ const name = f.skill || f.file || f.scope || "?";
1377
+ const detail = [
1378
+ ...(f.errors || []),
1379
+ ...(f.assertions_failed || []),
1380
+ f.error || "",
1381
+ f.message || "",
1382
+ ].filter(Boolean).join("<br>");
1383
+ const extra = f.timed_out ? "<em>timed out</em>" : detail;
1384
+ return `<tr class="fail-row"><td>${name}</td><td>${extra || "—"}</td></tr>`;
1385
+ }).join("");
1386
+
1387
+ const warnRows = (d.warnings || []).map((w) => {
1388
+ const name = w.skill || w.file || "?";
1389
+ const detail = (w.warnings || []).join("<br>");
1390
+ return `<tr class="warn-row"><td>${name}</td><td>${detail}</td></tr>`;
1391
+ }).join("");
1392
+
1393
+ const s = d.summary || {};
1394
+
1395
+ return `<!DOCTYPE html>
1396
+ <html lang="en">
1397
+ <head>
1398
+ <meta charset="utf-8">
1399
+ <meta http-equiv="refresh" content="5">
1400
+ <title>rdc:self-test — ${d.verdict}</title>
1401
+ <style>
1402
+ body { font-family: monospace; background: #0d1117; color: #e6edf3; margin: 2rem; }
1403
+ h1 { font-size: 1.2rem; margin-bottom: 0.25rem; }
1404
+ .meta { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
1405
+ .badge { padding: 0.2rem 0.6rem; border-radius: 4px; font-weight: bold; font-size: 1rem; }
1406
+ .badge.pass { background: #1a4731; color: #3fb950; }
1407
+ .badge.fail { background: #4a1515; color: #f85149; }
1408
+ table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
1409
+ th { text-align: left; padding: 0.4rem 0.8rem; background: #161b22; color: #8b949e; font-size: 0.8rem; }
1410
+ td { padding: 0.4rem 0.8rem; border-top: 1px solid #21262d; vertical-align: top; }
1411
+ .fail-row td:first-child { color: #f85149; font-weight: bold; }
1412
+ .warn-row td:first-child { color: #d29922; }
1413
+ .summary { display: flex; gap: 2rem; margin-bottom: 1rem; }
1414
+ .stat { text-align: center; }
1415
+ .stat .n { font-size: 1.8rem; font-weight: bold; }
1416
+ .stat .n.red { color: #f85149; }
1417
+ .stat .n.green { color: #3fb950; }
1418
+ .stat .n.yellow { color: #d29922; }
1419
+ .stat .label { color: #8b949e; font-size: 0.8rem; }
1420
+ .section-title { color: #8b949e; font-size: 0.8rem; text-transform: uppercase;
1421
+ letter-spacing: 0.05em; margin: 1.5rem 0 0.5rem; }
1422
+ .refresh { color: #8b949e; font-size: 0.75rem; float: right; }
1423
+ </style>
1424
+ </head>
1425
+ <body>
1426
+ <h1>rdc:self-test &nbsp; ${badge} &nbsp; Tier ${d.tier}</h1>
1427
+ <div class="meta">${ts} &nbsp;·&nbsp; exit ${d.exit_code}${d.strict ? " &nbsp;·&nbsp; strict" : ""}<span class="refresh">auto-refresh 5s</span></div>
1428
+
1429
+ <div class="summary">
1430
+ <div class="stat"><div class="n">${s.total ?? "?"}</div><div class="label">total</div></div>
1431
+ <div class="stat"><div class="n ${(s.failed || 0) > 0 ? "red" : "green"}">${s.failed ?? s.fail ?? 0}</div><div class="label">failed</div></div>
1432
+ ${s.warned != null ? `<div class="stat"><div class="n ${(s.warned) > 0 ? "yellow" : ""}">${s.warned}</div><div class="label">warned</div></div>` : ""}
1433
+ <div class="stat"><div class="n green">${s.passed ?? s.pass ?? 0}</div><div class="label">passed</div></div>
1434
+ ${s.wall_ms != null ? `<div class="stat"><div class="n">${(s.wall_ms/1000).toFixed(1)}s</div><div class="label">wall time</div></div>` : ""}
1435
+ </div>
1436
+
1437
+ ${failRows ? `<div class="section-title">Failures</div>
1438
+ <table><tr><th>Skill / Scope</th><th>Detail</th></tr>${failRows}</table>` : ""}
1439
+
1440
+ ${warnRows ? `<div class="section-title">Warnings</div>
1441
+ <table><tr><th>Skill / Scope</th><th>Detail</th></tr>${warnRows}</table>` : ""}
1442
+
1443
+ ${!failRows && !warnRows ? `<p style="color:#3fb950;margin-top:1rem">All checks passed.</p>` : ""}
1444
+ </body>
1445
+ </html>`;
1446
+ }
1447
+
1448
+ if (TIER2) {
1449
+ runTier2().catch((e) => {
1450
+ console.error(`FATAL: ${e.stack || e.message}`);
1451
+ process.exit(2);
1452
+ });
1453
+ } else {
1454
+ try {
1455
+ main();
1456
+ } catch (e) {
1457
+ console.error(`FATAL: ${e.stack || e.message}`);
1458
+ process.exit(2);
1459
+ }
1460
+ }