@lifeaitools/rdc-skills 0.8.7

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 (97) hide show
  1. package/.claude/settings.json +15 -0
  2. package/.claude-plugin/marketplace.json +21 -0
  3. package/.claude-plugin/plugin.json +11 -0
  4. package/.github/workflows/publish.yml +25 -0
  5. package/.github/workflows/self-test.yml +53 -0
  6. package/CHANGELOG.md +246 -0
  7. package/LICENSE +21 -0
  8. package/MANIFEST.md +190 -0
  9. package/README.md +188 -0
  10. package/README.sandbox.md +3 -0
  11. package/assets/watcher/viewer.html +164 -0
  12. package/commands/build.md +183 -0
  13. package/commands/collab.md +180 -0
  14. package/commands/deploy.md +138 -0
  15. package/commands/fixit.md +112 -0
  16. package/commands/handoff.md +173 -0
  17. package/commands/help.md +88 -0
  18. package/commands/overnight.md +220 -0
  19. package/commands/plan.md +158 -0
  20. package/commands/preplan.md +131 -0
  21. package/commands/prototype.md +145 -0
  22. package/commands/release.md +159 -0
  23. package/commands/report.md +99 -0
  24. package/commands/review.md +120 -0
  25. package/commands/self-test.md +107 -0
  26. package/commands/status.md +86 -0
  27. package/commands/watch.md +92 -0
  28. package/commands/workitems.md +132 -0
  29. package/guides/.gitkeep +0 -0
  30. package/guides/agent-bootstrap.md +191 -0
  31. package/guides/agents/backend.md +104 -0
  32. package/guides/agents/content.md +94 -0
  33. package/guides/agents/cs2.md +56 -0
  34. package/guides/agents/data.md +87 -0
  35. package/guides/agents/design.md +77 -0
  36. package/guides/agents/frontend.md +92 -0
  37. package/guides/agents/infrastructure.md +81 -0
  38. package/guides/agents/setup.md +279 -0
  39. package/guides/agents/verify.md +132 -0
  40. package/guides/agents/viz.md +106 -0
  41. package/guides/backend.md +146 -0
  42. package/guides/content.md +147 -0
  43. package/guides/cs2.md +190 -0
  44. package/guides/data.md +123 -0
  45. package/guides/design.md +116 -0
  46. package/guides/frontend.md +151 -0
  47. package/guides/infrastructure.md +179 -0
  48. package/guides/output-contract.md +98 -0
  49. package/hooks/no-stop-open-epics.js +125 -0
  50. package/package.json +31 -0
  51. package/rules/work-items-rpc.md +399 -0
  52. package/scripts/install-rdc-skills.js +559 -0
  53. package/scripts/install.ps1 +165 -0
  54. package/scripts/install.sh +132 -0
  55. package/scripts/lib/assertions.mjs +264 -0
  56. package/scripts/lib/manifest-schema.mjs +607 -0
  57. package/scripts/lib/runner.mjs +429 -0
  58. package/scripts/lib/sandbox.mjs +435 -0
  59. package/scripts/self-test.mjs +1108 -0
  60. package/scripts/uninstall.ps1 +77 -0
  61. package/scripts/uninstall.sh +69 -0
  62. package/scripts/update.ps1 +43 -0
  63. package/scripts/update.sh +43 -0
  64. package/scripts/watch-init.mjs +100 -0
  65. package/skills/.gitkeep +0 -0
  66. package/skills/build/SKILL.md +238 -0
  67. package/skills/collab/SKILL.md +218 -0
  68. package/skills/deploy/SKILL.md +144 -0
  69. package/skills/fixit/SKILL.md +112 -0
  70. package/skills/handoff/SKILL.md +175 -0
  71. package/skills/help/SKILL.md +101 -0
  72. package/skills/overnight/SKILL.md +220 -0
  73. package/skills/plan/SKILL.md +96 -0
  74. package/skills/preplan/SKILL.md +87 -0
  75. package/skills/prototype/SKILL.md +151 -0
  76. package/skills/release/SKILL.md +221 -0
  77. package/skills/report/SKILL.md +101 -0
  78. package/skills/review/SKILL.md +120 -0
  79. package/skills/self-test/SKILL.md +127 -0
  80. package/skills/status/SKILL.md +86 -0
  81. package/skills/tests/README.md +29 -0
  82. package/skills/tests/rdc-build.test.json +22 -0
  83. package/skills/tests/rdc-deploy.test.json +15 -0
  84. package/skills/tests/rdc-fixit.test.json +21 -0
  85. package/skills/tests/rdc-handoff.test.json +14 -0
  86. package/skills/tests/rdc-overnight.test.json +21 -0
  87. package/skills/tests/rdc-plan.test.json +14 -0
  88. package/skills/tests/rdc-preplan.test.json +15 -0
  89. package/skills/tests/rdc-prototype.test.json +14 -0
  90. package/skills/tests/rdc-release.test.json +15 -0
  91. package/skills/tests/rdc-report.test.json +14 -0
  92. package/skills/tests/rdc-review.test.json +14 -0
  93. package/skills/tests/rdc-status.test.json +16 -0
  94. package/skills/tests/rdc-workitems.test.json +15 -0
  95. package/skills/watch/SKILL.md +92 -0
  96. package/skills/workitems/SKILL.md +147 -0
  97. package/tests/validate-skills.js +183 -0
@@ -0,0 +1,1108 @@
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 { loadAllManifests } from "./lib/manifest-schema.mjs";
35
+ import { runManifest } from "./lib/runner.mjs";
36
+ import {
37
+ generateRunId,
38
+ resolveSandboxRef,
39
+ deleteSupabaseTestBranch,
40
+ removeWorktree,
41
+ } from "./lib/sandbox.mjs";
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+ const REPO_ROOT = resolve(__dirname, "..");
45
+ const SKILLS_DIR = join(REPO_ROOT, "skills");
46
+ const GUIDES_DIR = join(REPO_ROOT, "guides");
47
+ const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
48
+ const HOOKS_DIR = join(REPO_ROOT, "hooks");
49
+ const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
50
+ const PACKAGE_JSON = join(REPO_ROOT, "package.json");
51
+
52
+ const STANDARD_BANNER = [
53
+ "> **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`",
54
+ "> Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.",
55
+ "> One checklist upfront, updated in place, shown again at end with a 1-line verdict.",
56
+ "",
57
+ "",
58
+ ].join("\n");
59
+
60
+ const argv = process.argv.slice(2);
61
+ const args = new Set(argv);
62
+ const STRICT = args.has("--strict");
63
+ const JSON_OUT = args.has("--json");
64
+ const FIX = args.has("--fix");
65
+ const TIER2 = args.has("--tier2");
66
+ const QUICK = args.has("--quick");
67
+ const ONLY_SKILLS = [];
68
+ for (let i = 0; i < argv.length; i++) {
69
+ if (argv[i] === "--skill" && argv[i + 1]) {
70
+ for (const s of argv[i + 1].split(",")) {
71
+ const t = s.trim();
72
+ if (t) ONLY_SKILLS.push(t);
73
+ }
74
+ i++;
75
+ }
76
+ }
77
+ const ONLY_SKILL = ONLY_SKILLS.length === 1 ? ONLY_SKILLS[0] : null; // backwards-compat for single-skill Tier 1 path
78
+ const parallelArgIdx = argv.indexOf("--parallel");
79
+ const PARALLEL = parallelArgIdx >= 0 ? Math.max(1, parseInt(argv[parallelArgIdx + 1], 10) || 3) : 3;
80
+ const logArgIdx = argv.indexOf("--log");
81
+ const LIVE_LOG = logArgIdx >= 0 ? argv[logArgIdx + 1] : null;
82
+
83
+ /** Append a timestamped line to the live log file (if --log was passed). */
84
+ function liveLog(kind, msg) {
85
+ if (!LIVE_LOG) return;
86
+ const ts = new Date().toISOString();
87
+ try { appendFileSync(LIVE_LOG, `[${ts}] [${kind}] ${msg}\n`); } catch {}
88
+ }
89
+
90
+ // Track files modified by --fix so caller can git diff
91
+ const FIXED_FILES = [];
92
+
93
+ function escapeRegExp(s) {
94
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
+ }
96
+
97
+ function parseFrontmatter(text) {
98
+ text = text.replace(/\r\n/g, "\n");
99
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
100
+ if (!m) return { error: "no frontmatter block" };
101
+ const raw = m[1];
102
+ const body = text.slice(m[0].length);
103
+ const fmEnd = m[0].length;
104
+
105
+ const nameMatch = raw.match(/^name:\s*(.+?)\s*$/m);
106
+ if (!nameMatch) return { error: "frontmatter missing `name:`" };
107
+ const name = nameMatch[1].trim();
108
+
109
+ let description = null;
110
+ let descStartsWithBacktick = false;
111
+
112
+ const plainDesc = raw.match(/^description:[ \t]+([^\n>| \t].*)$/m);
113
+ const foldedDesc = raw.match(/^description:[ \t]*>[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
114
+ const literalDesc = raw.match(/^description:[ \t]*\|[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
115
+
116
+ if (plainDesc) {
117
+ description = plainDesc[1].trim();
118
+ descStartsWithBacktick = description.startsWith("`");
119
+ } else if (foldedDesc || literalDesc) {
120
+ const block = (foldedDesc || literalDesc)[1];
121
+ const lines = block
122
+ .split(/\n/)
123
+ .map((l) => l.replace(/^[ \t]+/, ""))
124
+ .filter((l) => l.length > 0);
125
+ description = lines.join(foldedDesc ? " " : "\n").trim();
126
+ const firstLine = lines[0] || "";
127
+ descStartsWithBacktick = firstLine.startsWith("`");
128
+ } else {
129
+ return { error: "frontmatter missing `description:`" };
130
+ }
131
+
132
+ return { name, description, descStartsWithBacktick, body, raw, fmEnd };
133
+ }
134
+
135
+ function expectedSkillName(dirOrFile) {
136
+ // Accept either a directory name (rdc-foo) or legacy filename (rdc-foo.md)
137
+ const base = basename(dirOrFile, ".md");
138
+ if (!base.startsWith("rdc-")) return null;
139
+ return "rdc:" + base.slice(4);
140
+ }
141
+
142
+ function findReferencedFiles(body) {
143
+ const refs = [];
144
+ const guideRe = /(?:^|[\s(`'"/])(?:\.rdc\/)?guides\/([\w/-]+\.md)/g;
145
+ let m;
146
+ while ((m = guideRe.exec(body)) !== null) {
147
+ refs.push({ kind: "guide", name: m[1] });
148
+ }
149
+ const ruleRe = /\.claude\/rules\/([\w-]+\.md)/g;
150
+ while ((m = ruleRe.exec(body)) !== null) {
151
+ refs.push({ kind: "rule", name: m[1] });
152
+ }
153
+ const hookRe = /(?:^|[\s(`'"/])hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
154
+ while ((m = hookRe.exec(body)) !== null) {
155
+ refs.push({ kind: "hook", name: m[1] });
156
+ }
157
+ return refs;
158
+ }
159
+
160
+ function addFinding(result, level, code, message) {
161
+ result.findings.push({ level, code, message });
162
+ if (level === "error") result.errors.push(message);
163
+ else result.warnings.push(message);
164
+ }
165
+
166
+ function tryAutoFix(filepath, fm, text, result) {
167
+ if (!FIX) return false;
168
+ let changed = false;
169
+ let newText = text;
170
+ let newPath = filepath;
171
+
172
+ // Fix: insert OUTPUT CONTRACT banner after frontmatter if missing
173
+ if (!/OUTPUT CONTRACT/.test(fm.body)) {
174
+ const fmBlock = newText.slice(0, fm.fmEnd);
175
+ const rest = newText.slice(fm.fmEnd).replace(/^\n+/, "");
176
+ newText = fmBlock + "\n" + STANDARD_BANNER + rest;
177
+ writeFileSync(filepath, newText);
178
+ console.log(`FIXED: ${result.name || result.file} — inserted OUTPUT CONTRACT banner`);
179
+ FIXED_FILES.push(filepath);
180
+ changed = true;
181
+ }
182
+
183
+ // Fix: filename/name mismatch — rename directory or file to match frontmatter name
184
+ const _filename = basename(filepath);
185
+ const _dirName = basename(dirname(filepath));
186
+ const _isSubdir = _filename === "SKILL.md" && _dirName.startsWith("rdc-");
187
+ const _skillDirOrFile = _isSubdir ? _dirName : _filename;
188
+ const expected = expectedSkillName(_skillDirOrFile);
189
+ if (expected && fm.name !== expected && fm.name.startsWith("rdc:")) {
190
+ if (_isSubdir) {
191
+ // Subdirectory layout: rename the parent directory
192
+ const targetDirName = "rdc-" + fm.name.slice(4);
193
+ const targetDirPath = join(dirname(dirname(filepath)), targetDirName);
194
+ if (!existsSync(targetDirPath)) {
195
+ renameSync(dirname(filepath), targetDirPath);
196
+ const targetPath = join(targetDirPath, "SKILL.md");
197
+ console.log(`FIXED: ${fm.name} — renamed dir ${_dirName} → ${targetDirName}`);
198
+ FIXED_FILES.push(targetPath);
199
+ newPath = targetPath;
200
+ changed = true;
201
+ }
202
+ } else {
203
+ const targetBase = "rdc-" + fm.name.slice(4) + ".md";
204
+ const targetPath = join(dirname(filepath), targetBase);
205
+ if (!existsSync(targetPath)) {
206
+ renameSync(filepath, targetPath);
207
+ console.log(`FIXED: ${fm.name} — renamed ${_filename} → ${targetBase}`);
208
+ FIXED_FILES.push(targetPath);
209
+ newPath = targetPath;
210
+ changed = true;
211
+ }
212
+ }
213
+ }
214
+
215
+ return changed ? newPath : false;
216
+ }
217
+
218
+ function auditSkill(filepath) {
219
+ const filename = basename(filepath);
220
+ // Support both flat (rdc-foo.md) and subdirectory (rdc-foo/SKILL.md) layouts
221
+ const dirName = basename(dirname(filepath)); // "rdc-foo" when filepath is .../rdc-foo/SKILL.md
222
+ const isSubdir = filename === "SKILL.md" && dirName.startsWith("rdc-");
223
+ const skillDirOrFile = isSubdir ? dirName : filename;
224
+ const result = {
225
+ skill: null,
226
+ file: isSubdir ? `skills/${dirName}/SKILL.md` : `skills/${filename}`,
227
+ name: null,
228
+ pass: true,
229
+ errors: [],
230
+ warnings: [],
231
+ findings: [],
232
+ };
233
+
234
+ let text;
235
+ try {
236
+ text = readFileSync(filepath, "utf8");
237
+ } catch (e) {
238
+ addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
239
+ result.pass = false;
240
+ return result;
241
+ }
242
+
243
+ const fm = parseFrontmatter(text);
244
+ if (fm.error) {
245
+ addFinding(result, "error", "frontmatter-invalid", `frontmatter: ${fm.error}`);
246
+ result.pass = false;
247
+ return result;
248
+ }
249
+
250
+ result.name = fm.name;
251
+ result.skill = fm.name;
252
+
253
+ if (fm.descStartsWithBacktick) {
254
+ addFinding(
255
+ result,
256
+ "error",
257
+ "description-backtick-leading",
258
+ "description starts with backtick — Claude Code parser will silently drop this skill from the menu",
259
+ );
260
+ }
261
+
262
+ // Usage marker — must reference this skill's own name
263
+ if (fm.name.startsWith("rdc:")) {
264
+ const escapedName = escapeRegExp(fm.name);
265
+ const usageRe = new RegExp("Usage\\s+`" + escapedName + "[ \\\\`]", "i");
266
+ const anyUsageRe = /\bUsage\s+`/i;
267
+ if (!anyUsageRe.test(fm.description)) {
268
+ addFinding(
269
+ result,
270
+ "warn",
271
+ "usage-marker-missing",
272
+ "description missing `Usage \\`rdc:name <args>\\`` marker — users can't see arg contract in menu",
273
+ );
274
+ } else if (!usageRe.test(fm.description)) {
275
+ addFinding(
276
+ result,
277
+ "warn",
278
+ "usage-marker-mismatch",
279
+ `Usage marker in description does not reference own name "${fm.name}" — copy-paste drift?`,
280
+ );
281
+ }
282
+ }
283
+
284
+ const expected = expectedSkillName(skillDirOrFile);
285
+ if (expected && fm.name !== expected) {
286
+ addFinding(
287
+ result,
288
+ "error",
289
+ "name-filename-mismatch",
290
+ `name mismatch: frontmatter says "${fm.name}" but filename implies "${expected}"`,
291
+ );
292
+ }
293
+
294
+ const refs = findReferencedFiles(fm.body);
295
+ const seen = new Set();
296
+ for (const ref of refs) {
297
+ const key = `${ref.kind}:${ref.name}`;
298
+ if (seen.has(key)) continue;
299
+ seen.add(key);
300
+ if (ref.kind === "guide") {
301
+ if (!existsSync(join(GUIDES_DIR, ref.name))) {
302
+ addFinding(
303
+ result,
304
+ "warn",
305
+ "guide-not-found",
306
+ `referenced guide not found in repo: guides/${ref.name}`,
307
+ );
308
+ }
309
+ }
310
+ if (ref.kind === "rule") {
311
+ const regenRoot = "C:/Dev/regen-root/.claude/rules";
312
+ if (existsSync(regenRoot) && !existsSync(join(regenRoot, ref.name))) {
313
+ addFinding(
314
+ result,
315
+ "warn",
316
+ "rule-not-found",
317
+ `referenced rule not found in regen-root: .claude/rules/${ref.name}`,
318
+ );
319
+ }
320
+ }
321
+ if (ref.kind === "hook") {
322
+ if (!existsSync(join(HOOKS_DIR, ref.name))) {
323
+ addFinding(
324
+ result,
325
+ "error",
326
+ "hook-not-found",
327
+ `referenced hook file not found: hooks/${ref.name}`,
328
+ );
329
+ }
330
+ }
331
+ }
332
+
333
+ if (!/OUTPUT CONTRACT/.test(fm.body)) {
334
+ addFinding(
335
+ result,
336
+ "warn",
337
+ "banner-missing",
338
+ "body missing OUTPUT CONTRACT banner (guides/output-contract.md reference)",
339
+ );
340
+ }
341
+
342
+ // Try auto-fix, then re-audit the same skill once to reflect new state
343
+ if (FIX && (result.errors.length > 0 || result.warnings.length > 0)) {
344
+ const newPath = tryAutoFix(filepath, fm, text, result);
345
+ if (newPath) return auditSkill(newPath);
346
+ }
347
+
348
+ result.pass = result.errors.length === 0;
349
+ return result;
350
+ }
351
+
352
+ function auditAgentGuide(filepath) {
353
+ const filename = basename(filepath);
354
+ const result = {
355
+ file: `guides/agents/${filename}`,
356
+ name: filename,
357
+ pass: true,
358
+ errors: [],
359
+ warnings: [],
360
+ findings: [],
361
+ };
362
+ let text;
363
+ try {
364
+ text = readFileSync(filepath, "utf8");
365
+ } catch (e) {
366
+ addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
367
+ result.pass = false;
368
+ return result;
369
+ }
370
+ if (text.replace(/\r\n/g, "\n").startsWith("---\n")) {
371
+ addFinding(
372
+ result,
373
+ "error",
374
+ "agent-guide-has-frontmatter",
375
+ "agent guide still has frontmatter — should be plain markdown",
376
+ );
377
+ }
378
+ if (!/OUTPUT CONTRACT/.test(text)) {
379
+ addFinding(result, "warn", "banner-missing", "body missing OUTPUT CONTRACT banner");
380
+ }
381
+ if (text.trim().length === 0) {
382
+ addFinding(result, "error", "empty-file", "file is empty");
383
+ }
384
+ result.pass = result.errors.length === 0;
385
+ return result;
386
+ }
387
+
388
+ // Plugin manifest check. Returns { ok, exitCode, findings }
389
+ function auditPluginManifest() {
390
+ const findings = [];
391
+ if (!existsSync(PLUGIN_MANIFEST)) {
392
+ findings.push({
393
+ level: "error",
394
+ code: "manifest-missing",
395
+ message: `.claude-plugin/plugin.json not found`,
396
+ });
397
+ return { ok: false, exitCode: 3, findings, manifest: null };
398
+ }
399
+ let manifest;
400
+ try {
401
+ manifest = JSON.parse(readFileSync(PLUGIN_MANIFEST, "utf8"));
402
+ } catch (e) {
403
+ findings.push({
404
+ level: "error",
405
+ code: "manifest-invalid-json",
406
+ message: `plugin.json invalid JSON: ${e.message}`,
407
+ });
408
+ return { ok: false, exitCode: 1, findings, manifest: null };
409
+ }
410
+ if (!manifest.name) {
411
+ findings.push({ level: "error", code: "manifest-missing-name", message: "plugin.json missing `name`" });
412
+ }
413
+ if (!manifest.version) {
414
+ findings.push({
415
+ level: "error",
416
+ code: "manifest-missing-version",
417
+ message: "plugin.json missing `version`",
418
+ });
419
+ }
420
+ try {
421
+ const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
422
+ if (manifest.version && pkg.version && manifest.version !== pkg.version) {
423
+ findings.push({
424
+ level: "error",
425
+ code: "manifest-version-mismatch",
426
+ message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
427
+ });
428
+ }
429
+ } catch (e) {
430
+ findings.push({
431
+ level: "warn",
432
+ code: "package-json-unreadable",
433
+ message: `could not read package.json for version cross-check: ${e.message}`,
434
+ });
435
+ }
436
+ const ok = findings.every((f) => f.level !== "error");
437
+ return { ok, exitCode: ok ? 0 : 1, findings, manifest };
438
+ }
439
+
440
+ // Duplicate skill name + filename collision check
441
+ function auditDuplicates(results) {
442
+ const findings = [];
443
+ const nameMap = new Map();
444
+ for (const r of results) {
445
+ if (!r.name) continue;
446
+ if (nameMap.has(r.name)) {
447
+ findings.push({
448
+ level: "error",
449
+ code: "duplicate-skill-name",
450
+ message: `duplicate skill name "${r.name}" in ${nameMap.get(r.name)} and ${r.file}`,
451
+ });
452
+ } else {
453
+ nameMap.set(r.name, r.file);
454
+ }
455
+ }
456
+ if (existsSync(AGENT_GUIDES_DIR)) {
457
+ const agentBases = new Set(
458
+ readdirSync(AGENT_GUIDES_DIR)
459
+ .filter((f) => f.endsWith(".md"))
460
+ .map((f) => basename(f, ".md")),
461
+ );
462
+ for (const r of results) {
463
+ // r.file is either "skills/rdc-foo/SKILL.md" or legacy "skills/rdc-foo.md"
464
+ // Extract the skill directory/base name in both cases
465
+ const parts = r.file.replace(/\\/g, "/").split("/");
466
+ let skillBase;
467
+ if (parts.length >= 3 && parts[2] === "SKILL.md") {
468
+ skillBase = parts[1]; // "rdc-foo" from "skills/rdc-foo/SKILL.md"
469
+ } else {
470
+ skillBase = basename(r.file, ".md"); // legacy
471
+ }
472
+ if (skillBase.startsWith("rdc-")) {
473
+ const stem = skillBase.slice(4);
474
+ if (agentBases.has(stem)) {
475
+ findings.push({
476
+ level: "error",
477
+ code: "skill-guide-filename-collision",
478
+ message: `skills/${skillBase}/SKILL.md collides with guides/agents/${stem}.md (half-reverted move?)`,
479
+ });
480
+ }
481
+ }
482
+ }
483
+ }
484
+ return findings;
485
+ }
486
+
487
+ // Orphan hook scan — warn when hooks/ files aren't referenced anywhere
488
+ function auditOrphanHooks(results) {
489
+ const findings = [];
490
+ if (!existsSync(HOOKS_DIR)) return findings;
491
+ let hookFiles;
492
+ try {
493
+ hookFiles = readdirSync(HOOKS_DIR).filter((f) => /\.(?:js|mjs|cjs)$/.test(f));
494
+ } catch {
495
+ return findings;
496
+ }
497
+ const referenced = new Set();
498
+ for (const r of results) {
499
+ for (const f of r.findings) {
500
+ // no-op: findings don't carry refs. Re-scan body below.
501
+ }
502
+ }
503
+ // Re-scan skill bodies + known config files for hook refs
504
+ const sources = [
505
+ ...readdirSync(SKILLS_DIR)
506
+ .filter((f) => {
507
+ if (!f.startsWith("rdc-")) return false;
508
+ try { return statSync(join(SKILLS_DIR, f)).isDirectory(); } catch { return false; }
509
+ })
510
+ .map((f) => join(SKILLS_DIR, f, "SKILL.md")),
511
+ join(REPO_ROOT, ".claude", "settings.json"),
512
+ PLUGIN_MANIFEST,
513
+ ];
514
+ for (const src of sources) {
515
+ if (!existsSync(src)) continue;
516
+ try {
517
+ const text = readFileSync(src, "utf8");
518
+ // Match either "hooks/foo.js" paths or bare basenames (stem match)
519
+ const pathRe = /hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
520
+ let m;
521
+ while ((m = pathRe.exec(text)) !== null) referenced.add(m[1]);
522
+ for (const hf of hookFiles) {
523
+ const stem = hf.replace(/\.(?:js|mjs|cjs)$/, "");
524
+ if (text.includes(hf) || new RegExp("\\b" + escapeRegExp(stem) + "\\b").test(text)) {
525
+ referenced.add(hf);
526
+ }
527
+ }
528
+ } catch {}
529
+ }
530
+ for (const hf of hookFiles) {
531
+ if (!referenced.has(hf)) {
532
+ findings.push({
533
+ level: "warn",
534
+ code: "orphan-hook",
535
+ message: `hooks/${hf} exists but is not referenced by any skill`,
536
+ });
537
+ }
538
+ }
539
+ return findings;
540
+ }
541
+
542
+ // ─── Tier 2 behavioral runner ──────────────────────────────────────────────
543
+
544
+ async function runPool(items, concurrency, worker) {
545
+ const results = new Array(items.length);
546
+ let next = 0;
547
+ async function lane() {
548
+ while (true) {
549
+ const i = next++;
550
+ if (i >= items.length) return;
551
+ try {
552
+ results[i] = await worker(items[i], i);
553
+ } catch (e) {
554
+ results[i] = { pass: false, error: e?.message || String(e) };
555
+ }
556
+ }
557
+ }
558
+ const lanes = Array.from({ length: Math.min(concurrency, items.length) }, () => lane());
559
+ await Promise.all(lanes);
560
+ return results;
561
+ }
562
+
563
+ async function runTier2() {
564
+ console.log("\nrdc-skills self-test — Tier 2 (behavioral)\n");
565
+
566
+ // Load manifests
567
+ let all = loadAllManifests();
568
+ if (ONLY_SKILLS.length > 0) {
569
+ all = all.filter((m) => m.manifest && ONLY_SKILLS.includes(m.manifest.skill));
570
+ }
571
+ if (QUICK) {
572
+ all = all.filter((m) => !(m.manifest?.fixture?.slow === true));
573
+ }
574
+
575
+ if (all.length === 0) {
576
+ console.log("No manifests found in skills/tests/.");
577
+ console.log("(Tier 2 will run once WP6/WP7 land baseline manifests.)");
578
+ process.exit(STRICT ? 1 : 0);
579
+ }
580
+
581
+ // Reject invalid manifests up front
582
+ const invalid = all.filter((m) => !m.ok);
583
+ const valid = all.filter((m) => m.ok);
584
+ for (const m of invalid) {
585
+ console.error(`SKIP ${m.file}: ${m.errors.map((e) => `${e.path}: ${e.msg}`).join("; ")}`);
586
+ }
587
+ if (valid.length === 0) {
588
+ console.error("No valid manifests to run.");
589
+ process.exit(1);
590
+ }
591
+
592
+ const runId = generateRunId();
593
+ console.log(`runId: ${runId}`);
594
+ console.log(`manifests: ${valid.length} valid, ${invalid.length} invalid`);
595
+ console.log(`parallel: ${PARALLEL}${QUICK ? " quick" : ""}`);
596
+ liveLog("start", `runId=${runId} skills=${valid.map((m) => m.manifest.skill).join(",")}`);
597
+
598
+ // WP-A3: use resolveSandboxRef — defaults to main-db mode (no branch, no cost)
599
+ const sandboxRef = await resolveSandboxRef({ runId });
600
+ console.log(`sandbox mode: ${sandboxRef.mode} apiUrl=${sandboxRef.apiUrl || "none"} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
601
+ liveLog("sandbox", `mode=${sandboxRef.mode} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
602
+
603
+ // Run
604
+ const toRun = valid.map((m) => m.manifest);
605
+ const total = toRun.length;
606
+ let launched = 0;
607
+ let finished = 0;
608
+ const started = Date.now();
609
+ let results;
610
+ try {
611
+ results = await runPool(toRun, PARALLEL, async (manifest) => {
612
+ const n = ++launched;
613
+ const timeoutSec = Math.round((manifest.timeout_ms || 240_000) / 1000);
614
+ console.log(`▶ [${n}/${total}] ${manifest.skill} (timeout ${timeoutSec}s)`);
615
+ liveLog("running", `skill=${manifest.skill}`);
616
+ const r = await runManifest(manifest, {
617
+ runId,
618
+ supabaseBranchRef: sandboxRef,
619
+ projectCwd: process.cwd(),
620
+ });
621
+ const done = ++finished;
622
+ const status = r.error ? "ERROR" : r.pass ? "PASS" : "FAIL";
623
+ const dur = r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(1)}s` : "?";
624
+ const icon = status === "PASS" ? "✓" : "✗";
625
+ const detail = r.error || (r.failures || []).map((f) => f.message).join("; ") || "";
626
+ console.log(
627
+ `${icon} [${done}/${total}] ${r.skill} ${dur} ${status}${detail ? " — " + detail : ""}`,
628
+ );
629
+ liveLog(status.toLowerCase(), `skill=${r.skill} duration=${r.duration_ms}ms${detail ? " | " + detail : ""}`);
630
+ return r;
631
+ });
632
+ } finally {
633
+ // Teardown — always. Pass process.cwd() so removeWorktree targets the
634
+ // same projectRoot (regen-root) that createWorktree used.
635
+ const projectCwd = process.cwd();
636
+ for (const r of results || []) {
637
+ if (r && r.skill) {
638
+ try { removeWorktree(runId, r.skill, projectCwd); } catch {}
639
+ }
640
+ }
641
+ try { await deleteSupabaseTestBranch(sandboxRef.branchId); } catch {}
642
+ }
643
+
644
+ const totalDuration = Date.now() - started;
645
+
646
+ // Report
647
+ const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
648
+ console.log("\n" + pad("skill", 24) + pad("status", 10) + pad("duration", 12) + "failures");
649
+ console.log("─".repeat(90));
650
+ let failed = 0;
651
+ let errored = 0;
652
+ for (const r of results) {
653
+ if (r.error) {
654
+ errored++;
655
+ console.log(pad(r.skill || "?", 24) + pad("ERROR", 10) + pad("-", 12) + r.error);
656
+ continue;
657
+ }
658
+ const status = r.pass ? "pass" : "FAIL";
659
+ if (!r.pass) failed++;
660
+ const dur = `${r.duration_ms}ms`;
661
+ const fs = (r.failures || []).map((f) => `${f.predicate}: ${f.message}`).join("; ") || "";
662
+ console.log(pad(r.skill, 24) + pad(status, 10) + pad(dur, 12) + fs);
663
+ }
664
+ console.log("─".repeat(90));
665
+ console.log(
666
+ `total: ${results.length} | pass: ${results.length - failed - errored} | fail: ${failed} | error: ${errored} | wall: ${totalDuration}ms`,
667
+ );
668
+ liveLog("done", `total=${results.length} pass=${results.length - failed - errored} fail=${failed} error=${errored} wall=${totalDuration}ms`);
669
+
670
+ // JSON dump
671
+ try {
672
+ const REPORTS_DIR = resolve(REPO_ROOT, ".rdc", "reports");
673
+ if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
674
+ const iso = new Date().toISOString().replace(/[:.]/g, "-");
675
+ const reportPath = join(REPORTS_DIR, `self-test-tier2-${iso}.json`);
676
+ writeFileSync(
677
+ reportPath,
678
+ JSON.stringify(
679
+ {
680
+ run_id: runId,
681
+ started_at: new Date(started).toISOString(),
682
+ duration_ms: totalDuration,
683
+ parallel: PARALLEL,
684
+ quick: QUICK,
685
+ supabase_branch: sandboxRef.branchId || null,
686
+ summary: {
687
+ total: results.length,
688
+ pass: results.length - failed - errored,
689
+ fail: failed,
690
+ error: errored,
691
+ },
692
+ results,
693
+ invalid_manifests: invalid.map((m) => ({ file: m.file, errors: m.errors })),
694
+ },
695
+ null,
696
+ 2,
697
+ ),
698
+ );
699
+ console.log(`\nreport: ${reportPath}`);
700
+ } catch (e) {
701
+ console.error(`WARN: failed to write JSON report: ${e.message}`);
702
+ }
703
+
704
+ writeLastRun({
705
+ tier: 2,
706
+ verdict: (failed > 0 || errored > 0) ? "FAIL" : "PASS",
707
+ exit_code: errored > 0 ? 2 : failed > 0 ? 1 : 0,
708
+ summary: {
709
+ total: results.length,
710
+ passed: results.length - failed - errored,
711
+ failed,
712
+ errored,
713
+ wall_ms: totalDuration,
714
+ },
715
+ failures: results
716
+ .filter((r) => !r.pass || r.error)
717
+ .map((r) => ({
718
+ skill: r.skill,
719
+ error: r.error || null,
720
+ timed_out: r.observed?.timed_out || false,
721
+ assertions_failed: (r.failures || []).map((f) => `${f.predicate}: ${f.message}`),
722
+ })),
723
+ warnings: [],
724
+ });
725
+
726
+ if (errored > 0) process.exit(2);
727
+ process.exit(failed > 0 ? 1 : 0);
728
+ }
729
+
730
+ function main() {
731
+ // Plugin manifest pass first — can short-circuit with exit 3
732
+ const manifestAudit = auditPluginManifest();
733
+ const manifestMissing = manifestAudit.findings.some((f) => f.code === "manifest-missing");
734
+
735
+ let files;
736
+ try {
737
+ files = readdirSync(SKILLS_DIR).filter((f) => {
738
+ if (!f.startsWith("rdc-")) return false;
739
+ try { return statSync(join(SKILLS_DIR, f)).isDirectory(); } catch { return false; }
740
+ });
741
+ } catch (e) {
742
+ console.error(`FATAL: cannot read skills dir ${SKILLS_DIR}: ${e.message}`);
743
+ process.exit(2);
744
+ }
745
+
746
+ if (ONLY_SKILLS.length > 0) {
747
+ files = files.filter((f) => {
748
+ return ONLY_SKILLS.some((s) => f === s.replace(":", "-"));
749
+ });
750
+ if (files.length === 0) {
751
+ console.error(`no skill file matches: ${ONLY_SKILLS.join(", ")}`);
752
+ process.exit(2);
753
+ }
754
+ }
755
+
756
+ if (JSON_OUT) {
757
+ // JSON path: buffer everything then dump
758
+ const results = files.map((f) => auditSkill(join(SKILLS_DIR, f, "SKILL.md")));
759
+
760
+ let duplicateFindings = [];
761
+ let orphanHookFindings = [];
762
+ if (!ONLY_SKILL) {
763
+ duplicateFindings = auditDuplicates(results);
764
+ orphanHookFindings = auditOrphanHooks(results);
765
+ }
766
+
767
+ let agentResults = [];
768
+ if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
769
+ const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
770
+ agentResults = agentFiles.map((f) => auditAgentGuide(join(AGENT_GUIDES_DIR, f)));
771
+ }
772
+
773
+ const failed = results.filter((r) => r.errors.length > 0);
774
+ const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
775
+ const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
776
+ const agentFailed = agentResults.filter((r) => r.errors.length > 0);
777
+ const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
778
+ const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
779
+ const globalErrors = [
780
+ ...manifestAudit.findings.filter((f) => f.level === "error"),
781
+ ...duplicateFindings.filter((f) => f.level === "error"),
782
+ ...orphanHookFindings.filter((f) => f.level === "error"),
783
+ ];
784
+ const globalWarnings = [
785
+ ...manifestAudit.findings.filter((f) => f.level === "warn"),
786
+ ...duplicateFindings.filter((f) => f.level === "warn"),
787
+ ...orphanHookFindings.filter((f) => f.level === "warn"),
788
+ ];
789
+ const fail =
790
+ failed.length > 0 ||
791
+ agentFailed.length > 0 ||
792
+ globalErrors.length > 0 ||
793
+ (STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
794
+ let exitCode = fail ? 1 : 0;
795
+ if (manifestMissing) exitCode = 3;
796
+
797
+ console.log(
798
+ JSON.stringify(
799
+ {
800
+ summary: {
801
+ total: results.length,
802
+ failed: failed.length,
803
+ warned: warned.length,
804
+ clean: clean.length,
805
+ strict: STRICT,
806
+ fix: FIX,
807
+ fixed_files: FIXED_FILES.map((p) => p.replace(REPO_ROOT + "\\", "").replace(REPO_ROOT + "/", "")),
808
+ verdict: fail ? "FAIL" : "PASS",
809
+ exit_code: exitCode,
810
+ },
811
+ plugin_manifest: {
812
+ ok: manifestAudit.ok,
813
+ version: manifestAudit.manifest?.version || null,
814
+ findings: manifestAudit.findings,
815
+ },
816
+ global_findings: [...duplicateFindings, ...orphanHookFindings],
817
+ results: results.map((r) => ({
818
+ skill: r.skill,
819
+ file: r.file,
820
+ pass: r.pass,
821
+ findings: r.findings,
822
+ })),
823
+ agent_guides: {
824
+ total: agentResults.length,
825
+ failed: agentFailed.length,
826
+ warned: agentWarned.length,
827
+ clean: agentClean.length,
828
+ results: agentResults.map((r) => ({
829
+ file: r.file,
830
+ pass: r.pass,
831
+ findings: r.findings,
832
+ })),
833
+ },
834
+ },
835
+ null,
836
+ 2,
837
+ ),
838
+ );
839
+ process.exit(exitCode);
840
+ }
841
+
842
+ // Human-readable path: stream each result live as it's audited
843
+ const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
844
+
845
+ // Count agent guides upfront for the startup banner
846
+ let agentFileCount = 0;
847
+ if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
848
+ try { agentFileCount = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md")).length; } catch {}
849
+ }
850
+
851
+ // Startup banner — printed immediately so the user sees activity right away
852
+ console.log(`\nrdc-skills self-test — Tier 1 (static lint)`);
853
+ const scopeDesc = ONLY_SKILLS.length > 0
854
+ ? `skill: ${ONLY_SKILLS.join(", ")}`
855
+ : `${files.length} skill${files.length !== 1 ? "s" : ""}${agentFileCount ? ` + ${agentFileCount} agent guide${agentFileCount !== 1 ? "s" : ""}` : ""}`;
856
+ console.log(`Scanning ${scopeDesc}${STRICT ? " [strict]" : ""}${FIX ? " [fix]" : ""}\n`);
857
+
858
+ // Plugin manifest line
859
+ const manifestStatus = manifestAudit.ok ? "pass" : "FAIL";
860
+ const manifestNote = manifestAudit.ok
861
+ ? `v${manifestAudit.manifest?.version || "?"}`
862
+ : manifestAudit.findings.map((f) => f.message).join("; ");
863
+ console.log(pad("plugin manifest", 24) + pad(manifestStatus, 10) + manifestNote);
864
+ console.log();
865
+
866
+ // Skills — print each row immediately after audit (no buffering)
867
+ console.log(pad("skill", 24) + pad("status", 10) + "notes");
868
+ console.log("─".repeat(80));
869
+ const results = [];
870
+ for (const f of files) {
871
+ const r = auditSkill(join(SKILLS_DIR, f, "SKILL.md"));
872
+ results.push(r);
873
+ const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
874
+ const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
875
+ console.log(pad(r.name || r.file, 24) + pad(status, 10) + notes);
876
+ const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
877
+ for (const extra of extras) {
878
+ console.log(pad("", 24) + pad("", 10) + " " + extra);
879
+ }
880
+ }
881
+
882
+ const failed = results.filter((r) => r.errors.length > 0);
883
+ const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
884
+ const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
885
+
886
+ console.log("─".repeat(80));
887
+ console.log(`total: ${results.length} | fail: ${failed.length} | warn: ${warned.length} | pass: ${clean.length}`);
888
+
889
+ // Agent guides — stream live
890
+ const agentResults = [];
891
+ if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
892
+ const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
893
+ if (agentFiles.length > 0) {
894
+ console.log("\nagent guides (guides/agents/*.md)\n");
895
+ console.log(pad("guide", 24) + pad("status", 10) + "notes");
896
+ console.log("─".repeat(80));
897
+ for (const f of agentFiles) {
898
+ const r = auditAgentGuide(join(AGENT_GUIDES_DIR, f));
899
+ agentResults.push(r);
900
+ const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
901
+ const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
902
+ console.log(pad(r.file, 24) + pad(status, 10) + notes);
903
+ const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
904
+ for (const extra of extras) {
905
+ console.log(pad("", 24) + pad("", 10) + " " + extra);
906
+ }
907
+ }
908
+ const agentFailed = agentResults.filter((r) => r.errors.length > 0);
909
+ const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
910
+ const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
911
+ console.log("─".repeat(80));
912
+ console.log(`total: ${agentResults.length} | fail: ${agentFailed.length} | warn: ${agentWarned.length} | pass: ${agentClean.length}`);
913
+ }
914
+ }
915
+
916
+ // Cross-skill checks — deferred until all audits complete
917
+ let duplicateFindings = [];
918
+ let orphanHookFindings = [];
919
+ if (!ONLY_SKILL) {
920
+ duplicateFindings = auditDuplicates(results);
921
+ orphanHookFindings = auditOrphanHooks(results);
922
+ }
923
+
924
+ if (duplicateFindings.length + orphanHookFindings.length > 0) {
925
+ console.log("\nglobal findings\n");
926
+ for (const f of [...duplicateFindings, ...orphanHookFindings]) {
927
+ console.log(` [${f.level}] ${f.code}: ${f.message}`);
928
+ }
929
+ }
930
+
931
+ if (FIXED_FILES.length > 0) {
932
+ console.log("\nfixed files (review with git diff):");
933
+ for (const p of FIXED_FILES) console.log(` ${p}`);
934
+ }
935
+
936
+ const agentFailed = agentResults.filter((r) => r.errors.length > 0);
937
+ const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
938
+ const globalErrors = [
939
+ ...manifestAudit.findings.filter((f) => f.level === "error"),
940
+ ...duplicateFindings.filter((f) => f.level === "error"),
941
+ ...orphanHookFindings.filter((f) => f.level === "error"),
942
+ ];
943
+ const globalWarnings = [
944
+ ...manifestAudit.findings.filter((f) => f.level === "warn"),
945
+ ...duplicateFindings.filter((f) => f.level === "warn"),
946
+ ...orphanHookFindings.filter((f) => f.level === "warn"),
947
+ ];
948
+
949
+ const fail =
950
+ failed.length > 0 ||
951
+ agentFailed.length > 0 ||
952
+ globalErrors.length > 0 ||
953
+ (STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
954
+
955
+ let exitCode = fail ? 1 : 0;
956
+ if (manifestMissing) exitCode = 3;
957
+
958
+ console.log(`\nverdict: ${fail ? "❌ FAIL" : "✓ PASS"}${STRICT ? " (strict)" : ""} exit=${exitCode}\n`);
959
+
960
+ writeLastRun({
961
+ tier: 1,
962
+ verdict: fail ? "FAIL" : "PASS",
963
+ exit_code: exitCode,
964
+ strict: STRICT,
965
+ summary: {
966
+ total: results.length,
967
+ failed: failed.length,
968
+ warned: warned.length,
969
+ passed: clean.length,
970
+ },
971
+ failures: [
972
+ ...manifestAudit.findings.filter((f) => f.level === "error").map((f) => ({
973
+ scope: "plugin_manifest", code: f.code, message: f.message,
974
+ })),
975
+ ...failed.map((r) => ({ skill: r.skill || r.file, errors: r.errors, findings: r.findings })),
976
+ ...agentFailed.map((r) => ({ scope: "agent_guide", file: r.file, errors: r.errors, findings: r.findings })),
977
+ ...duplicateFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
978
+ ...orphanHookFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
979
+ ],
980
+ warnings: [
981
+ ...warned.map((r) => ({ skill: r.skill || r.file, warnings: r.warnings })),
982
+ ...agentWarned.map((r) => ({ scope: "agent_guide", file: r.file, warnings: r.warnings })),
983
+ ],
984
+ });
985
+
986
+ process.exit(exitCode);
987
+ }
988
+
989
+ // ── last-run.json ───────────────────────────────────────────────────────────
990
+ // Always written after every run so Claude Code can read it without the user
991
+ // having to copy/paste terminal output.
992
+ // Path: C:/Dev/rdc-skills/.rdc/reports/last-run.json
993
+ //
994
+ // Shape:
995
+ // { tier, ran_at, verdict, exit_code, summary, failures[], warnings[] }
996
+ // "failures" contains only items that actually failed — ready to act on.
997
+
998
+ function writeLastRun(data) {
999
+ const payload = { ...data, ran_at: new Date().toISOString() };
1000
+ try {
1001
+ const reportsDir = resolve(REPO_ROOT, ".rdc", "reports");
1002
+ if (!existsSync(reportsDir)) mkdirSync(reportsDir, { recursive: true });
1003
+
1004
+ const jsonPath = join(reportsDir, "last-run.json");
1005
+ writeFileSync(jsonPath, JSON.stringify(payload, null, 2));
1006
+
1007
+ const htmlPath = join(reportsDir, "last-run.html");
1008
+ writeFileSync(htmlPath, renderHtml(payload));
1009
+
1010
+ console.log(`\nresults: file:///${htmlPath.replace(/\\/g, "/")}`);
1011
+ } catch (e) {
1012
+ console.error(`WARN: could not write last-run reports: ${e.message}`);
1013
+ }
1014
+ }
1015
+
1016
+ function renderHtml(d) {
1017
+ const pass = d.verdict === "PASS";
1018
+ const ts = new Date(d.ran_at).toLocaleString();
1019
+ const badge = pass
1020
+ ? `<span class="badge pass">✓ PASS</span>`
1021
+ : `<span class="badge fail">✗ FAIL</span>`;
1022
+
1023
+ const failRows = (d.failures || []).map((f) => {
1024
+ const name = f.skill || f.file || f.scope || "?";
1025
+ const detail = [
1026
+ ...(f.errors || []),
1027
+ ...(f.assertions_failed || []),
1028
+ f.error || "",
1029
+ f.message || "",
1030
+ ].filter(Boolean).join("<br>");
1031
+ const extra = f.timed_out ? "<em>timed out</em>" : detail;
1032
+ return `<tr class="fail-row"><td>${name}</td><td>${extra || "—"}</td></tr>`;
1033
+ }).join("");
1034
+
1035
+ const warnRows = (d.warnings || []).map((w) => {
1036
+ const name = w.skill || w.file || "?";
1037
+ const detail = (w.warnings || []).join("<br>");
1038
+ return `<tr class="warn-row"><td>${name}</td><td>${detail}</td></tr>`;
1039
+ }).join("");
1040
+
1041
+ const s = d.summary || {};
1042
+
1043
+ return `<!DOCTYPE html>
1044
+ <html lang="en">
1045
+ <head>
1046
+ <meta charset="utf-8">
1047
+ <meta http-equiv="refresh" content="5">
1048
+ <title>rdc:self-test — ${d.verdict}</title>
1049
+ <style>
1050
+ body { font-family: monospace; background: #0d1117; color: #e6edf3; margin: 2rem; }
1051
+ h1 { font-size: 1.2rem; margin-bottom: 0.25rem; }
1052
+ .meta { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
1053
+ .badge { padding: 0.2rem 0.6rem; border-radius: 4px; font-weight: bold; font-size: 1rem; }
1054
+ .badge.pass { background: #1a4731; color: #3fb950; }
1055
+ .badge.fail { background: #4a1515; color: #f85149; }
1056
+ table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
1057
+ th { text-align: left; padding: 0.4rem 0.8rem; background: #161b22; color: #8b949e; font-size: 0.8rem; }
1058
+ td { padding: 0.4rem 0.8rem; border-top: 1px solid #21262d; vertical-align: top; }
1059
+ .fail-row td:first-child { color: #f85149; font-weight: bold; }
1060
+ .warn-row td:first-child { color: #d29922; }
1061
+ .summary { display: flex; gap: 2rem; margin-bottom: 1rem; }
1062
+ .stat { text-align: center; }
1063
+ .stat .n { font-size: 1.8rem; font-weight: bold; }
1064
+ .stat .n.red { color: #f85149; }
1065
+ .stat .n.green { color: #3fb950; }
1066
+ .stat .n.yellow { color: #d29922; }
1067
+ .stat .label { color: #8b949e; font-size: 0.8rem; }
1068
+ .section-title { color: #8b949e; font-size: 0.8rem; text-transform: uppercase;
1069
+ letter-spacing: 0.05em; margin: 1.5rem 0 0.5rem; }
1070
+ .refresh { color: #8b949e; font-size: 0.75rem; float: right; }
1071
+ </style>
1072
+ </head>
1073
+ <body>
1074
+ <h1>rdc:self-test &nbsp; ${badge} &nbsp; Tier ${d.tier}</h1>
1075
+ <div class="meta">${ts} &nbsp;·&nbsp; exit ${d.exit_code}${d.strict ? " &nbsp;·&nbsp; strict" : ""}<span class="refresh">auto-refresh 5s</span></div>
1076
+
1077
+ <div class="summary">
1078
+ <div class="stat"><div class="n">${s.total ?? "?"}</div><div class="label">total</div></div>
1079
+ <div class="stat"><div class="n ${(s.failed || 0) > 0 ? "red" : "green"}">${s.failed ?? s.fail ?? 0}</div><div class="label">failed</div></div>
1080
+ ${s.warned != null ? `<div class="stat"><div class="n ${(s.warned) > 0 ? "yellow" : ""}">${s.warned}</div><div class="label">warned</div></div>` : ""}
1081
+ <div class="stat"><div class="n green">${s.passed ?? s.pass ?? 0}</div><div class="label">passed</div></div>
1082
+ ${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>` : ""}
1083
+ </div>
1084
+
1085
+ ${failRows ? `<div class="section-title">Failures</div>
1086
+ <table><tr><th>Skill / Scope</th><th>Detail</th></tr>${failRows}</table>` : ""}
1087
+
1088
+ ${warnRows ? `<div class="section-title">Warnings</div>
1089
+ <table><tr><th>Skill / Scope</th><th>Detail</th></tr>${warnRows}</table>` : ""}
1090
+
1091
+ ${!failRows && !warnRows ? `<p style="color:#3fb950;margin-top:1rem">All checks passed.</p>` : ""}
1092
+ </body>
1093
+ </html>`;
1094
+ }
1095
+
1096
+ if (TIER2) {
1097
+ runTier2().catch((e) => {
1098
+ console.error(`FATAL: ${e.stack || e.message}`);
1099
+ process.exit(2);
1100
+ });
1101
+ } else {
1102
+ try {
1103
+ main();
1104
+ } catch (e) {
1105
+ console.error(`FATAL: ${e.stack || e.message}`);
1106
+ process.exit(2);
1107
+ }
1108
+ }