@lifeaitools/rdc-skills 0.24.7 → 0.24.8

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