roast-my-design-system 8.6.2 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Your AI can write the UI. This makes sure it writes *your* UI.
10
10
 
11
- A free CLI tool, and a Claude Code plugin with two skills and a bundled local MCP server, that roasts your repo's design system with real data, reviews what you change against it, and generates the rules that keep your AI agent on-system.
11
+ A free CLI tool, and a Claude Code plugin with two skills, a bundled local MCP server and an edit hook, that roasts your repo's design system with real data, reviews what you change against it, and generates the rules that keep your AI agent on-system.
12
12
 
13
13
  > **New in 8.0: support for the four big component kits.**
14
14
  >
@@ -97,6 +97,7 @@ These need an agent.
97
97
  |---|---|
98
98
  | <code>...&nbsp;--notes&nbsp;&lt;file.md&gt;</code> | The agent's read of this scan, embedded in the report as **"What the numbers mean"**: which findings matter, which good numbers are accidents, what to fix first. Labelled as written by AI and kept apart from the measured numbers. The Claude Code skill writes and passes it automatically; the flag is here so any agent can |
99
99
  | <code>...&nbsp;--section&nbsp;"Title"&nbsp;&lt;file.md&gt;</code> | A further agent-written chapter after the notes, same styling, same label, sub-headings allowed. Repeatable, so analysis that outgrows the notes still lives inside the report instead of a hand-built page |
100
+ | `... --hook` | The check the Claude Code plugin runs after every edit, for hand-installed setups: reads the hook event on stdin, checks the one file that changed, prints only the findings the edit added as JSON. Always exits 0 |
100
101
  | `... --mcp` | The scan as a local MCP server: 5 tools your agent calls while writing UI, from "is there a Button already?" to "review my changes", plus the `roast-fix` prompt that serves the top fix from a fresh scan. See [Live answers over MCP](#live-answers-over-mcp) |
101
102
  | <code>/roast-my-design-system</code> (in&nbsp;Claude&nbsp;Code) | The full experience: the roast in chat *and* embedded in the report as "What the numbers mean", the rules offer, and the fix loop with Claude on your own numbers |
102
103
 
@@ -285,7 +286,7 @@ Run it inside any repo. Same scanner, same report, straight from npm. The Claude
285
286
  /plugin install roast-my-design-system@roast-my-design-system
286
287
  ```
287
288
 
288
- What it installs: two skills and one local MCP server, nothing else. **roast** (`/roast-my-design-system`, or "roast my design system") scans the whole repo and writes the report with Claude's read of the numbers inside it, then walks the fixes with you. **review** (`/roast-my-design-system:review`, or "review my UI changes") checks only what changed, in about a second. The bundled server gives your agent five read-only tools while it writes UI. Local, no network, no telemetry.
289
+ What it installs: two skills, one local MCP server and one edit hook, nothing else. **roast** (`/roast-my-design-system`, or "roast my design system") scans the whole repo and writes the report with Claude's read of the numbers inside it, then walks the fixes with you. **review** (`/roast-my-design-system:review`, or "review my UI changes") checks only what changed, in about a second. The bundled server gives your agent five read-only tools while it writes UI. The hook runs after every file the agent edits and hands back the findings that edit added, so the check happens whether or not the agent thought to ask; a file with nothing new gets no message. Local, no network, no telemetry.
289
290
 
290
291
  If those commands error, your Claude Code is likely older than the plugin marketplace feature. Update Claude Code and retry, or use the manual route below: it works everywhere and installs the same skill.
291
292
 
package/cli/roast.mjs CHANGED
@@ -90,6 +90,8 @@ Run it yourself
90
90
  number of files it removed, so a scoped scan says so
91
91
  --json print the scan summary as JSON on stdout (implies --no-open)
92
92
  --check check the working tree's changed files (git diff + untracked)
93
+ --hook Claude Code edit hook: reads the PostToolUse event on stdin,
94
+ checks the one file that changed, answers with findings
93
95
  against the design system and print the findings; exits 1
94
96
  when something is over the line, so it composes with scripts
95
97
 
@@ -123,6 +125,20 @@ if (argv.includes('--mcp')) {
123
125
  // the server owns the process now; readline keeps it alive until the client
124
126
  // closes stdin, and nothing below (the report pipeline) may run
125
127
  await new Promise(() => {});
128
+ } else if (argv.includes('--hook')) {
129
+ // Claude Code's PostToolUse hook: the event arrives as JSON on stdin, the
130
+ // findings leave as JSON on stdout. Exit 0 whatever happens — a hook that
131
+ // fails must never stop the agent editing; it only loses one check.
132
+ let raw = '';
133
+ try { raw = readFileSync(0, 'utf8'); } catch { /* no stdin */ }
134
+ try {
135
+ const { hookResult } = await import(pathToFileURL(join(SCRIPTS, 'mcp/hook.mjs')).href);
136
+ const r = hookResult(raw.trim() ? JSON.parse(raw) : {});
137
+ if (r) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.text } }));
138
+ } catch (e) {
139
+ console.error(`roast-my-design-system --hook: ${e.message}`);
140
+ }
141
+ process.exit(0);
126
142
  } else if (argv.includes('--check')) {
127
143
  argv.splice(argv.indexOf('--check'), 1);
128
144
  const root = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "8.6.2",
3
+ "version": "8.7.0",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
5
5
  "description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 112 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
6
6
  "keywords": [
@@ -1,7 +1,7 @@
1
1
  // Single version constant for the engine — imported by diagnose (report
2
2
  // footer) and rules (generated-by line). This is the bump spot that used to
3
3
  // live as a const inside diagnose/index.mjs.
4
- export const VERSION = '8.6.2';
4
+ export const VERSION = '8.7.0';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The edit hook — roast_validate without the agent having to ask.
3
+ *
4
+ * The MCP tools only help when the agent decides to call them. In the
5
+ * September 2026 runs Sonnet asked in about a third of sessions and Haiku
6
+ * never did, so the drift it would have caught went straight into the
7
+ * diff. A Claude Code plugin can also register a hook that runs after every
8
+ * Edit or Write; this is that hook. It reads the event, judges the one file
9
+ * that changed with the same engine and the same words as roast_validate,
10
+ * and hands the findings back as context. Silent when there is nothing to
11
+ * say: a hook that talks on every edit is a hook people switch off.
12
+ */
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
17
+ import { validateContent } from './engine.mjs';
18
+ import { loadKnowledge } from './knowledge.mjs';
19
+ import { RELEVANT, beforeOf, findingLines } from './tools.mjs';
20
+
21
+ const gitTop = (dir) => {
22
+ try {
23
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
24
+ } catch { return null; }
25
+ };
26
+
27
+ // The files a shell command may have written: everything changed or untracked
28
+ // in the repo that the review would judge. Cheap (two git calls), and the
29
+ // mtime ledger below keeps it from re-judging what the hook already saw.
30
+ function changedFiles(root) {
31
+ try {
32
+ const git = (...a) => execFileSync('git', a, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
33
+ const top = realpathSync.native(git('rev-parse', '--show-toplevel').trim());
34
+ const names = [...git('diff', 'HEAD', '--name-only').split('\n'), ...git('ls-files', '--others', '--exclude-standard', '--full-name').split('\n')];
35
+ return [...new Set(names.map((s) => s.trim()).filter((f) => f && RELEVANT.test(f)))].map((f) => join(top, f));
36
+ } catch { return []; }
37
+ }
38
+
39
+ // What the hook has judged this session, file → mtime, so a file is judged
40
+ // once per change however it was written, and never twice for one save.
41
+ const ledgerPath = (session) => join(tmpdir(), `roast-hook-${String(session).replace(/[^A-Za-z0-9_-]/g, '')}.json`);
42
+ function readLedger(session) {
43
+ if (!session) return {};
44
+ try { return JSON.parse(readFileSync(ledgerPath(session), 'utf8')); } catch { return {}; }
45
+ }
46
+ function writeLedger(session, ledger) {
47
+ if (!session) return;
48
+ try { writeFileSync(ledgerPath(session), JSON.stringify(ledger)); } catch { /* a lost ledger costs one repeat, nothing more */ }
49
+ }
50
+
51
+ const keyOf = (f) => `${f.rule}|${f.message}`;
52
+ /** The findings in `now` that `prior` does not account for, kind by kind. */
53
+ export function newSince(now, prior) {
54
+ const budget = new Map();
55
+ for (const f of prior) budget.set(keyOf(f), (budget.get(keyOf(f)) ?? 0) + 1);
56
+ return now.filter((f) => {
57
+ const n = budget.get(keyOf(f)) ?? 0;
58
+ if (!n) return true;
59
+ budget.set(keyOf(f), n - 1);
60
+ return false;
61
+ });
62
+ }
63
+
64
+ /**
65
+ * @param payload the PostToolUse event Claude Code writes to stdin
66
+ * @returns { text, total } when a changed file has new findings, else null
67
+ */
68
+ export function hookResult(payload) {
69
+ const cwd = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
70
+ const named = payload?.tool_input?.file_path;
71
+ const fromEditor = typeof named === 'string' && RELEVANT.test(named);
72
+ // An Edit or Write names its file. A shell command (cat > file, sed -i, a
73
+ // generator) names nothing, so every changed UI file is a candidate and
74
+ // the ledger decides which ones are new since the hook last looked.
75
+ const candidates = fromEditor
76
+ ? [isAbsolute(named) ? named : resolve(cwd, named)]
77
+ : payload?.tool_name === 'Bash' ? changedFiles(cwd) : [];
78
+ const session = payload?.session_id;
79
+ const ledger = readLedger(session);
80
+ const blocks = [];
81
+ let total = 0, knowledge = null;
82
+ for (const abs of candidates) {
83
+ if (!existsSync(abs)) continue;
84
+ const real = realpathSync.native(abs);
85
+ const mtime = statSync(real).mtimeMs;
86
+ if (!fromEditor && ledger[real] === mtime) continue;
87
+ ledger[real] = mtime;
88
+ // The repo is the session's working directory when the file lives under
89
+ // it (the same root the MCP server would be pointed at); a file edited
90
+ // elsewhere is judged against its own repository.
91
+ const under = (root) => root && real.startsWith(realpathSync.native(root) + '/');
92
+ const root = under(cwd) ? cwd : gitTop(dirname(real));
93
+ if (!root) continue;
94
+ const k = knowledge?.root === root ? knowledge : (knowledge = loadKnowledge(root));
95
+ const r = judge(real, root, k);
96
+ if (r) { blocks.push(r.text); total += r.total; }
97
+ }
98
+ writeLedger(session, ledger);
99
+ if (!blocks.length) return null;
100
+ blocks.push('Fix these now, in the repo\'s own vocabulary as each fix says (a token, a spacing step, a theme path), not by deleting the code. A value that is deliberate stays, with a one-line comment saying why.');
101
+ return { text: blocks.join('\n'), total };
102
+ }
103
+
104
+ /** One file against the system: only what the working copy added since HEAD. */
105
+ function judge(real, root, k) {
106
+ const rel = relative(realpathSync.native(root), real).split('\\').join('/');
107
+ const text = readFileSync(real, 'utf8');
108
+ const before = beforeOf(k, rel);
109
+ const { findings: all, exempt } = validateContent({ text, file: rel, before }, k);
110
+ if (exempt || !all.length) return null;
111
+ // Only what this edit added. A legacy file carries its old findings on
112
+ // every touch; repeating them after each edit is noise, and the review
113
+ // (--check, roast_review) already reports them once at the end. Same rule
114
+ // as the September runs measured by: a finding is new when the file has
115
+ // more of it than the committed version did.
116
+ const findings = typeof before === 'string' ? newSince(all, validateContent({ text: before, file: rel }, k).findings) : all;
117
+ if (!findings.length) return null;
118
+ return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k).join('\n')}`, total: findings.length };
119
+ }
@@ -15,6 +15,8 @@ import { hexRgb } from '../lib/nearpairs.mjs';
15
15
  import { distinctTypefaces } from '../lib/typefaces.mjs';
16
16
 
17
17
  const CONTEXT_BUDGET = 1600; // chars ≈ 400 tokens
18
+ // the files a review or an edit hook judges; everything else is not UI
19
+ export const RELEVANT = /\.(tsx|jsx|ts|js|vue|svelte|css|scss|sass|less)$/i;
18
20
  const MAX_VALIDATE_CHARS = 200_000; // a whole file, not a whole bundle
19
21
  const approxTokens = (s) => Math.ceil(s.length / 4);
20
22
 
@@ -288,7 +290,7 @@ function headVersion(gitRoot, gitPath) {
288
290
  } catch { return undefined; }
289
291
  }
290
292
  }
291
- function beforeOf(k, file) {
293
+ export function beforeOf(k, file) {
292
294
  if (!file) return undefined;
293
295
  try {
294
296
  const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: k.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
@@ -304,13 +306,18 @@ export function validate(k, { code, file = null } = {}) {
304
306
  const { findings, exempt } = validateContent({ text: code, file, before: beforeOf(k, file) }, k);
305
307
  if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
306
308
  if (!findings.length) return cleanResultText(k);
309
+ return findingLines(findings, k).join('\n');
310
+ }
311
+
312
+ /** The finding list as roast_validate prints it; the edit hook prints the same. */
313
+ export function findingLines(findings, k) {
307
314
  const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
308
315
  for (const f of findings.slice(0, 12)) {
309
316
  L.push(`${f.severity === 'violation' ? '✕' : '⚠'} L${f.line} ${f.message}${f.fix ? `\n Fix: ${f.fix}` : ''}`);
310
317
  }
311
318
  if (findings.length > 12) L.push(`(+${findings.length - 12} more of the same kinds)`);
312
319
  L.push(`Checked: ${checksFor(k).join(', ')}.`);
313
- return L.join('\n');
320
+ return L;
314
321
  }
315
322
 
316
323
  // ---------- roast_review ----------
@@ -336,7 +343,6 @@ export function reviewData(k) {
336
343
  } catch {
337
344
  return { text: 'Not a git repository (or git is unavailable), so there is no diff to review. Use roast_validate with the code instead.', total: 0 };
338
345
  }
339
- const RELEVANT = /\.(tsx|jsx|ts|js|vue|svelte|css|scss|sass|less)$/i;
340
346
  const changed = names.filter((f) => RELEVANT.test(f));
341
347
  if (!changed.length) return { text: 'No changed UI or style files in the working tree. Nothing to review.', total: 0 };
342
348