@rune-kit/rune 2.12.0 → 2.12.3

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.
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import * as antigravity from '../adapters/hooks/antigravity.js';
8
9
  import { installHooks } from '../commands/hooks/install.js';
9
10
  import { hookStatus } from '../commands/hooks/status.js';
10
11
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
11
12
 
12
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
13
15
  const RULES_DIR = '.antigravity/rules';
14
16
 
15
17
  let tmpRoot;
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import * as cursor from '../adapters/hooks/cursor.js';
8
9
  import { installHooks } from '../commands/hooks/install.js';
9
10
  import { hookStatus } from '../commands/hooks/status.js';
10
11
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
11
12
 
12
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
13
15
  const RULES_DIR = '.cursor/rules';
14
16
 
15
17
  let tmpRoot;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Doctor tier-coverage check — warns when a tier's hooks are installed on
3
+ * Claude but missing on other detected platforms, or when required env vars
4
+ * are unset so installed hooks would fail at runtime.
5
+ *
6
+ * Checks are advisory (WARN) — never block the doctor pipeline.
7
+ */
8
+
9
+ import assert from 'node:assert';
10
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import path from 'node:path';
13
+ import { afterEach, beforeEach, describe, test } from 'node:test';
14
+ import { listDetectedTiers } from '../commands/hooks/tiers.js';
15
+ import { checkTierCoverage } from '../doctor.js';
16
+
17
+ const PRO_MANIFEST = {
18
+ name: 'Rune Pro Hooks',
19
+ description: 'Pro tier hooks',
20
+ tier: 'pro',
21
+ version: '1.0.0',
22
+ requires: ['RUNE_PRO_ROOT'],
23
+ entries: [
24
+ {
25
+ id: 'context-inject',
26
+ skill: 'context-inject',
27
+ event: 'UserPromptSubmit',
28
+ matcher: '.*',
29
+ command: 'node "${RUNE_PRO_ROOT}/hooks/run-hook.cjs" context-inject',
30
+ },
31
+ {
32
+ id: 'context-sense',
33
+ skill: 'context-sense',
34
+ event: 'PreToolUse',
35
+ matcher: 'Edit|Write',
36
+ command: 'node "${RUNE_PRO_ROOT}/hooks/run-hook.cjs" context-sense',
37
+ },
38
+ ],
39
+ };
40
+
41
+ let tmpRoot;
42
+ let tierRoot;
43
+ let originalRunePro;
44
+
45
+ async function seedTierRoot(tier, manifest) {
46
+ const dir = await mkdtemp(path.join(tmpdir(), `rune-doc-${tier}-`));
47
+ await mkdir(path.join(dir, 'hooks'), { recursive: true });
48
+ await writeFile(path.join(dir, 'hooks', 'manifest.json'), JSON.stringify(manifest, null, 2));
49
+ return dir;
50
+ }
51
+
52
+ async function seedPlatform(root, platform) {
53
+ const map = {
54
+ claude: { dir: '.claude', settings: true },
55
+ cursor: { dir: '.cursor/rules' },
56
+ windsurf: { dir: '.windsurf/workflows' },
57
+ antigravity: { dir: '.antigravity/rules' },
58
+ };
59
+ const spec = map[platform];
60
+ await mkdir(path.join(root, spec.dir), { recursive: true });
61
+ if (spec.settings) {
62
+ await writeFile(path.join(root, '.claude', 'settings.json'), '{}');
63
+ }
64
+ }
65
+
66
+ async function seedTierHooksOn(root, platform, tier) {
67
+ if (platform === 'claude') {
68
+ const settingsPath = path.join(root, '.claude', 'settings.json');
69
+ const settings = {
70
+ hooks: {
71
+ UserPromptSubmit: [
72
+ {
73
+ matcher: '.*',
74
+ hooks: [
75
+ { type: 'command', command: 'node "${RUNE_' + tier.toUpperCase() + '_ROOT}/hooks/run-hook.cjs" ctx' },
76
+ ],
77
+ },
78
+ ],
79
+ },
80
+ };
81
+ await mkdir(path.dirname(settingsPath), { recursive: true });
82
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2));
83
+ return;
84
+ }
85
+ const dirs = {
86
+ cursor: path.join(root, '.cursor/rules'),
87
+ windsurf: path.join(root, '.windsurf/workflows'),
88
+ antigravity: path.join(root, '.antigravity/rules'),
89
+ };
90
+ const ext = { cursor: '.mdc', windsurf: '.md', antigravity: '.md' }[platform];
91
+ const dir = dirs[platform];
92
+ await mkdir(dir, { recursive: true });
93
+ await writeFile(path.join(dir, `rune-${tier}-context-inject${ext}`), '# tier hook\n');
94
+ }
95
+
96
+ beforeEach(async () => {
97
+ tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-doctor-tier-'));
98
+ tierRoot = await seedTierRoot('pro', PRO_MANIFEST);
99
+ originalRunePro = process.env.RUNE_PRO_ROOT;
100
+ process.env.RUNE_PRO_ROOT = tierRoot;
101
+ });
102
+
103
+ afterEach(async () => {
104
+ if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
105
+ if (tierRoot) await rm(tierRoot, { recursive: true, force: true });
106
+ if (originalRunePro === undefined) delete process.env.RUNE_PRO_ROOT;
107
+ else process.env.RUNE_PRO_ROOT = originalRunePro;
108
+ });
109
+
110
+ describe('listDetectedTiers', () => {
111
+ test('returns empty array when no tier env vars set and no monorepo sibling', async () => {
112
+ delete process.env.RUNE_PRO_ROOT;
113
+ const result = await listDetectedTiers(tmpRoot);
114
+ assert.deepStrictEqual(result, []);
115
+ });
116
+
117
+ test('returns Pro tier when RUNE_PRO_ROOT resolves', async () => {
118
+ const result = await listDetectedTiers(tmpRoot);
119
+ assert.strictEqual(result.length, 1);
120
+ assert.strictEqual(result[0].tier, 'pro');
121
+ assert.strictEqual(result[0].found, true);
122
+ assert.strictEqual(result[0].requiresOk, true);
123
+ assert.deepStrictEqual(result[0].requiresMissing, []);
124
+ });
125
+
126
+ test('reports requiresMissing when env var gets unset after locate', async () => {
127
+ // Manifest locates via a secondary path (env set at locate, unset at requires check).
128
+ // Simulate by forcing a manifest with extra required env.
129
+ const extraReq = { ...PRO_MANIFEST, requires: ['RUNE_PRO_ROOT', 'SOME_OTHER_VAR'] };
130
+ await writeFile(path.join(tierRoot, 'hooks', 'manifest.json'), JSON.stringify(extraReq, null, 2));
131
+ const result = await listDetectedTiers(tmpRoot);
132
+ assert.strictEqual(result[0].requiresOk, false);
133
+ assert.deepStrictEqual(result[0].requiresMissing, ['SOME_OTHER_VAR']);
134
+ });
135
+ });
136
+
137
+ describe('checkTierCoverage', () => {
138
+ test('silent when no tiers detected', async () => {
139
+ delete process.env.RUNE_PRO_ROOT;
140
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
141
+ assert.strictEqual(result.checks.length, 0);
142
+ assert.strictEqual(result.warnings.length, 0);
143
+ });
144
+
145
+ test('silent when tier detected but no platform dirs exist', async () => {
146
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
147
+ assert.strictEqual(result.checks.length, 0);
148
+ assert.strictEqual(result.warnings.length, 0);
149
+ });
150
+
151
+ test('pass when Pro installed on every detected platform', async () => {
152
+ await seedPlatform(tmpRoot, 'claude');
153
+ await seedPlatform(tmpRoot, 'cursor');
154
+ await seedTierHooksOn(tmpRoot, 'claude', 'pro');
155
+ await seedTierHooksOn(tmpRoot, 'cursor', 'pro');
156
+
157
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
158
+ const tierCheck = result.checks.find((c) => c.name === 'Tier coverage');
159
+ assert.ok(tierCheck, 'should emit a tier-coverage check');
160
+ assert.strictEqual(tierCheck.status, 'pass');
161
+ assert.strictEqual(result.warnings.length, 0);
162
+ });
163
+
164
+ test('warns when Pro on Claude but missing on detected Cursor', async () => {
165
+ await seedPlatform(tmpRoot, 'claude');
166
+ await seedPlatform(tmpRoot, 'cursor');
167
+ await seedTierHooksOn(tmpRoot, 'claude', 'pro');
168
+ // Cursor platform seeded but NO pro-prefixed files — the gap we want to catch.
169
+
170
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
171
+ const tierCheck = result.checks.find((c) => c.name === 'Tier coverage');
172
+ assert.strictEqual(tierCheck.status, 'warn');
173
+ assert.strictEqual(result.warnings.length >= 1, true);
174
+ const msg = result.warnings.join('\n');
175
+ assert.match(msg, /pro/);
176
+ assert.match(msg, /cursor/);
177
+ assert.match(msg, /rune hooks install/);
178
+ });
179
+
180
+ test('warns when tier manifest requires unset env var', async () => {
181
+ await seedPlatform(tmpRoot, 'claude');
182
+ await seedTierHooksOn(tmpRoot, 'claude', 'pro');
183
+ // Rewrite manifest to add a req that is definitely unset.
184
+ const extraReq = { ...PRO_MANIFEST, requires: ['RUNE_PRO_ROOT', 'DOCTOR_TIER_MISSING_ENV'] };
185
+ await writeFile(path.join(tierRoot, 'hooks', 'manifest.json'), JSON.stringify(extraReq, null, 2));
186
+
187
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
188
+ const envWarn = result.warnings.find((w) => w.includes('DOCTOR_TIER_MISSING_ENV'));
189
+ assert.ok(envWarn, `expected missing-env warning, got: ${result.warnings.join('\n')}`);
190
+ assert.match(envWarn, /pro/);
191
+ assert.match(envWarn, /will FAIL at runtime/);
192
+ });
193
+
194
+ test('no false-positive: Pro on Cursor alone (no Claude dir) is not a gap', async () => {
195
+ await seedPlatform(tmpRoot, 'cursor');
196
+ await seedTierHooksOn(tmpRoot, 'cursor', 'pro');
197
+
198
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
199
+ const tierCheck = result.checks.find((c) => c.name === 'Tier coverage');
200
+ // Installed on every detected platform (just cursor) → pass, not warn.
201
+ assert.strictEqual(tierCheck.status, 'pass');
202
+ });
203
+
204
+ test('windsurf workflow OR rule file counts as installed', async () => {
205
+ await seedPlatform(tmpRoot, 'windsurf');
206
+ const rulesDir = path.join(tmpRoot, '.windsurf/rules');
207
+ await mkdir(rulesDir, { recursive: true });
208
+ await writeFile(path.join(rulesDir, 'rune-pro-context-inject-rule.md'), '# rule\n');
209
+
210
+ const result = await checkTierCoverage({ projectRoot: tmpRoot });
211
+ const tierCheck = result.checks.find((c) => c.name === 'Tier coverage');
212
+ assert.strictEqual(tierCheck.status, 'pass');
213
+ });
214
+ });
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
7
8
  import { installHooks } from '../commands/hooks/install.js';
8
9
  import { SETTINGS_REL_PATH } from '../commands/hooks/presets.js';
9
10
  import { hookStatus } from '../commands/hooks/status.js';
10
11
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
11
12
 
12
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
13
15
 
14
16
  let tmpRoot;
15
17
 
@@ -18,18 +18,25 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
18
18
  import { tmpdir } from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { afterEach, beforeEach, describe, test } from 'node:test';
21
+ import { fileURLToPath } from 'node:url';
21
22
  import { installHooks } from '../commands/hooks/install.js';
22
23
  import { SETTINGS_REL_PATH } from '../commands/hooks/presets.js';
23
24
  import { hookStatus } from '../commands/hooks/status.js';
24
25
  import {
26
+ _resetFreeVersionCache,
27
+ assertFreeVersionCompat,
25
28
  checkManifestRequires,
29
+ compareSemver,
30
+ getFreeVersion,
26
31
  loadTierManifest,
27
32
  locateTierManifest,
33
+ parseSemver,
28
34
  resolveTier,
29
35
  validateManifest,
30
36
  } from '../commands/hooks/tiers.js';
31
37
 
32
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
33
40
 
34
41
  let tmpRoot;
35
42
  let tierRoot;
@@ -494,6 +501,100 @@ describe('review fixes: M3 overrides consumption', () => {
494
501
  });
495
502
  });
496
503
 
504
+ describe('Gap 2: minFreeVersion version gate', () => {
505
+ test('parseSemver accepts x.y.z and ignores prerelease/build', () => {
506
+ assert.deepStrictEqual(parseSemver('2.12.0'), [2, 12, 0]);
507
+ assert.deepStrictEqual(parseSemver('2.12.0-rc.1'), [2, 12, 0]);
508
+ assert.deepStrictEqual(parseSemver('2.12.0+sha.abc'), [2, 12, 0]);
509
+ assert.strictEqual(parseSemver('not-a-version'), null);
510
+ assert.strictEqual(parseSemver(null), null);
511
+ });
512
+
513
+ test('compareSemver returns -1/0/1 ordering', () => {
514
+ assert.strictEqual(compareSemver('2.12.0', '2.12.0'), 0);
515
+ assert.strictEqual(compareSemver('2.11.9', '2.12.0'), -1);
516
+ assert.strictEqual(compareSemver('2.12.1', '2.12.0'), 1);
517
+ assert.strictEqual(compareSemver('3.0.0', '2.12.0'), 1);
518
+ assert.strictEqual(compareSemver('bogus', '2.12.0'), null);
519
+ });
520
+
521
+ test('validateManifest accepts optional minFreeVersion', () => {
522
+ const m = validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '2.12.0' });
523
+ assert.strictEqual(m.minFreeVersion, '2.12.0');
524
+ });
525
+
526
+ test('validateManifest rejects non-semver minFreeVersion', () => {
527
+ assert.throws(
528
+ () => validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: 'latest' }),
529
+ /minFreeVersion.*must be semver/,
530
+ );
531
+ });
532
+
533
+ test('validateManifest rejects empty minFreeVersion string', () => {
534
+ assert.throws(() => validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '' }), /minFreeVersion.*non-empty/);
535
+ });
536
+
537
+ test('assertFreeVersionCompat is a no-op when minFreeVersion unset', () => {
538
+ const m = validateManifest(PRO_MANIFEST_FIXTURE);
539
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '0.0.1'));
540
+ });
541
+
542
+ test('assertFreeVersionCompat passes when current >= minFreeVersion', () => {
543
+ const m = validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '2.12.0' });
544
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '2.12.0'));
545
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '2.12.1'));
546
+ assert.doesNotThrow(() => assertFreeVersionCompat(m, '3.0.0'));
547
+ });
548
+
549
+ test('assertFreeVersionCompat throws helpful upgrade error when current < minFreeVersion', () => {
550
+ const m = validateManifest({ ...PRO_MANIFEST_FIXTURE, minFreeVersion: '2.12.0' });
551
+ assert.throws(
552
+ () => assertFreeVersionCompat(m, '2.11.0'),
553
+ (err) => {
554
+ assert.match(err.message, /requires Rune Free >= 2\.12\.0/);
555
+ assert.match(err.message, /installed compiler is 2\.11\.0/);
556
+ assert.match(err.message, /npm i -g @rune-kit\/rune@latest/);
557
+ assert.match(err.message, /--tier pro/);
558
+ return true;
559
+ },
560
+ );
561
+ });
562
+
563
+ test('resolveTier enforces minFreeVersion via assertFreeVersionCompat', async () => {
564
+ const futureManifest = { ...PRO_MANIFEST_FIXTURE, minFreeVersion: '999.0.0' };
565
+ await writeFile(path.join(tierRoot, 'hooks', 'manifest.json'), JSON.stringify(futureManifest, null, 2));
566
+ await assert.rejects(resolveTier('pro', tmpRoot), /requires Rune Free >= 999\.0\.0/);
567
+ });
568
+
569
+ test('getFreeVersion reads package.json semver', () => {
570
+ _resetFreeVersionCache();
571
+ const v = getFreeVersion();
572
+ assert.match(v, /^\d+\.\d+\.\d+/);
573
+ });
574
+ });
575
+
576
+ describe('Gap 3: actionable missing-manifest error', () => {
577
+ test('resolveTier error lists both env var and monorepo sibling path', async () => {
578
+ delete process.env.RUNE_PRO_ROOT;
579
+ await assert.rejects(resolveTier('pro', tmpRoot), (err) => {
580
+ assert.match(err.message, /RUNE_PRO_ROOT/);
581
+ assert.match(err.message, /monorepo sibling/);
582
+ assert.match(err.message, /Drop --tier pro/);
583
+ assert.match(err.message, /rune\.dev\/docs\/hooks/);
584
+ return true;
585
+ });
586
+ });
587
+
588
+ test('resolveTier error for unknown tier (no env var) still includes sibling + docs hint', async () => {
589
+ await assert.rejects(resolveTier('unknowntier', tmpRoot), (err) => {
590
+ assert.match(err.message, /monorepo sibling fallback/);
591
+ assert.match(err.message, /Drop --tier unknowntier/);
592
+ assert.match(err.message, /rune\.dev\/docs\/hooks/);
593
+ return true;
594
+ });
595
+ });
596
+ });
597
+
497
598
  describe('regression: Pro+Claude parity', () => {
498
599
  test('Pro entries present BEFORE and AFTER migration produce equivalent settings.json shape', async () => {
499
600
  await mkdir(path.join(tmpRoot, '.claude'), { recursive: true });
@@ -3,12 +3,14 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promis
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, test } from 'node:test';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import * as windsurf from '../adapters/hooks/windsurf.js';
7
8
  import { installHooks } from '../commands/hooks/install.js';
8
9
  import { hookStatus } from '../commands/hooks/status.js';
9
10
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
10
11
 
11
- const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const RUNE_ROOT = path.resolve(__dirname, '..', '..');
12
14
  const WF_DIR = '.windsurf/workflows';
13
15
  const RULES_DIR = '.windsurf/rules';
14
16
 
@@ -16,9 +16,10 @@
16
16
  * third-party) plugs in by shipping a manifest at a known path.
17
17
  */
18
18
 
19
- import { existsSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
22
23
 
23
24
  /** Known tier env vars. Adding a new tier = add its env var here. */
24
25
  export const TIER_ENV_VARS = Object.freeze({
@@ -26,6 +27,56 @@ export const TIER_ENV_VARS = Object.freeze({
26
27
  business: 'RUNE_BUSINESS_ROOT',
27
28
  });
28
29
 
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ /**
33
+ * Read Free compiler version from the bundled package.json. Cached after first read.
34
+ * @returns {string} semver string, e.g. "2.12.1"
35
+ */
36
+ let _freeVersionCache = null;
37
+ export function getFreeVersion() {
38
+ if (_freeVersionCache) return _freeVersionCache;
39
+ const pkgPath = path.resolve(__dirname, '..', '..', '..', 'package.json');
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
42
+ _freeVersionCache = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
43
+ } catch {
44
+ _freeVersionCache = '0.0.0';
45
+ }
46
+ return _freeVersionCache;
47
+ }
48
+
49
+ /** For test/override scenarios — resets the cache. */
50
+ export function _resetFreeVersionCache() {
51
+ _freeVersionCache = null;
52
+ }
53
+
54
+ /**
55
+ * Parse a semver `x.y.z` (ignores pre-release/build) into a tuple. Returns null on malformed input.
56
+ * @param {string} v
57
+ * @returns {[number,number,number]|null}
58
+ */
59
+ export function parseSemver(v) {
60
+ if (typeof v !== 'string') return null;
61
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
62
+ if (!m) return null;
63
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
64
+ }
65
+
66
+ /**
67
+ * Compare two semver strings. Returns -1, 0, 1 or null if either is malformed.
68
+ */
69
+ export function compareSemver(a, b) {
70
+ const pa = parseSemver(a);
71
+ const pb = parseSemver(b);
72
+ if (!pa || !pb) return null;
73
+ for (let i = 0; i < 3; i++) {
74
+ if (pa[i] < pb[i]) return -1;
75
+ if (pa[i] > pb[i]) return 1;
76
+ }
77
+ return 0;
78
+ }
79
+
29
80
  /** Valid event names a manifest entry may declare. */
30
81
  const VALID_EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'statusLine']);
31
82
 
@@ -148,11 +199,23 @@ export function validateManifest(manifest, source = '<memory>') {
148
199
  };
149
200
  });
150
201
 
202
+ const minFreeVersion =
203
+ typeof manifest.minFreeVersion === 'string' && manifest.minFreeVersion.length > 0 ? manifest.minFreeVersion : null;
204
+ if (manifest.minFreeVersion !== undefined && minFreeVersion === null) {
205
+ throw new Error(`Manifest ${source}: 'minFreeVersion' must be a non-empty string if present`);
206
+ }
207
+ if (minFreeVersion && !parseSemver(minFreeVersion)) {
208
+ throw new Error(
209
+ `Manifest ${source}: 'minFreeVersion' must be semver x.y.z (got ${JSON.stringify(minFreeVersion)})`,
210
+ );
211
+ }
212
+
151
213
  return {
152
214
  name: typeof manifest.name === 'string' ? manifest.name : `Rune ${manifest.tier} Hooks`,
153
215
  description: typeof manifest.description === 'string' ? manifest.description : '',
154
216
  tier: manifest.tier,
155
217
  version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
218
+ minFreeVersion,
156
219
  requires: Array.isArray(manifest.requires) ? [...manifest.requires] : [],
157
220
  entries,
158
221
  overrides: manifest.overrides && typeof manifest.overrides === 'object' ? { ...manifest.overrides } : {},
@@ -160,6 +223,29 @@ export function validateManifest(manifest, source = '<memory>') {
160
223
  };
161
224
  }
162
225
 
226
+ /**
227
+ * Assert the current Free compiler satisfies a manifest's `minFreeVersion`.
228
+ * Throws with an actionable upgrade message when the local Free is too old.
229
+ *
230
+ * @param {import('./tiers.js').TierManifest} manifest
231
+ * @param {string} [currentFreeVersion] — defaults to `getFreeVersion()`. Override for tests.
232
+ */
233
+ export function assertFreeVersionCompat(manifest, currentFreeVersion) {
234
+ if (!manifest || !manifest.minFreeVersion) return;
235
+ const current = currentFreeVersion ?? getFreeVersion();
236
+ const cmp = compareSemver(current, manifest.minFreeVersion);
237
+ if (cmp === null) {
238
+ // Malformed input — surface but don't block (defensive).
239
+ return;
240
+ }
241
+ if (cmp < 0) {
242
+ throw new Error(
243
+ `Tier '${manifest.tier}' requires Rune Free >= ${manifest.minFreeVersion} but the installed compiler is ${current}. ` +
244
+ `Upgrade Free first: \`npm i -g @rune-kit/rune@latest\` (or \`npx @rune-kit/rune@latest hooks install --tier ${manifest.tier}\`).`,
245
+ );
246
+ }
247
+ }
248
+
163
249
  /**
164
250
  * Check whether a manifest's `requires` list is satisfied by current env.
165
251
  * Returns { ok, missing }.
@@ -169,6 +255,48 @@ export function checkManifestRequires(manifest) {
169
255
  return { ok: missing.length === 0, missing };
170
256
  }
171
257
 
258
+ /**
259
+ * Enumerate known tiers and return status for each that can be located.
260
+ * Tiers that can't be found (no env var, no monorepo sibling) are skipped.
261
+ * Invalid/corrupt manifests are reported with { found: false, error }.
262
+ *
263
+ * @param {string} projectRoot
264
+ * @returns {Promise<Array<{tier: string, found: boolean, manifestPath: string|null, version?: string, requires: string[], requiresOk: boolean, requiresMissing: string[], entries: number, error?: string}>>}
265
+ */
266
+ export async function listDetectedTiers(projectRoot) {
267
+ const out = [];
268
+ for (const tier of Object.keys(TIER_ENV_VARS)) {
269
+ const manifestPath = locateTierManifest(tier, projectRoot);
270
+ if (!manifestPath) continue;
271
+ try {
272
+ const manifest = await loadTierManifest(manifestPath);
273
+ const req = checkManifestRequires(manifest);
274
+ out.push({
275
+ tier,
276
+ found: true,
277
+ manifestPath,
278
+ version: manifest.version,
279
+ requires: [...(manifest.requires || [])],
280
+ requiresOk: req.ok,
281
+ requiresMissing: req.missing,
282
+ entries: manifest.entries.length,
283
+ });
284
+ } catch (err) {
285
+ out.push({
286
+ tier,
287
+ found: false,
288
+ manifestPath,
289
+ requires: [],
290
+ requiresOk: false,
291
+ requiresMissing: [],
292
+ entries: 0,
293
+ error: err.message,
294
+ });
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+
172
300
  /**
173
301
  * Resolve a tier request (`--tier pro`) into a loaded, validated manifest,
174
302
  * or throw with a helpful upgrade/missing-env message.
@@ -180,16 +308,32 @@ export function checkManifestRequires(manifest) {
180
308
  export async function resolveTier(tier, projectRoot) {
181
309
  const manifestPath = locateTierManifest(tier, projectRoot);
182
310
  if (!manifestPath) {
183
- const envHint = TIER_ENV_VARS[tier] ? ` Set $${TIER_ENV_VARS[tier]} to your ${tier} install dir.` : '';
184
- throw new Error(
185
- `Could not locate '${tier}' tier manifest.${envHint}` +
186
- ` See https://rune.dev/docs/hooks#tiers for install instructions.`,
187
- );
311
+ const envVar = TIER_ENV_VARS[tier];
312
+ const capitalized = tier.charAt(0).toUpperCase() + tier.slice(1);
313
+ const siblingPath = path.resolve(projectRoot, '..', capitalized, 'hooks', 'manifest.json');
314
+ const lines = [`Could not locate '${tier}' tier manifest. Rune looked in:`];
315
+ if (envVar) {
316
+ lines.push(` 1. $${envVar}/hooks/manifest.json (env var — set this if ${capitalized} is installed elsewhere)`);
317
+ lines.push(` 2. ${siblingPath} (monorepo sibling fallback)`);
318
+ } else {
319
+ lines.push(` • ${siblingPath} (monorepo sibling fallback)`);
320
+ }
321
+ lines.push('');
322
+ lines.push(`Fix one of:`);
323
+ if (envVar) {
324
+ lines.push(` • export ${envVar}=/path/to/${capitalized} # point at your ${tier} install`);
325
+ }
326
+ lines.push(` • Clone ${capitalized} next to Free so the sibling path resolves`);
327
+ lines.push(` • Drop --tier ${tier} to install Free-only hooks`);
328
+ lines.push('');
329
+ lines.push(`See https://rune.dev/docs/hooks#tiers for details.`);
330
+ throw new Error(lines.join('\n'));
188
331
  }
189
332
  const manifest = await loadTierManifest(manifestPath);
190
333
  if (manifest.tier !== tier) {
191
334
  throw new Error(`Manifest at ${manifestPath} declares tier='${manifest.tier}' but was requested as '${tier}'`);
192
335
  }
336
+ assertFreeVersionCompat(manifest);
193
337
  return manifest;
194
338
  }
195
339
 
@@ -8,6 +8,8 @@
8
8
  import { existsSync } from 'node:fs';
9
9
  import { readdir, readFile } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
+ import { detectPlatforms } from './adapters/hooks/index.js';
12
+ import { listDetectedTiers } from './commands/hooks/tiers.js';
11
13
  import { parseOrgConfig, parsePack, parseTemplate } from './parser.js';
12
14
 
13
15
  /**
@@ -46,6 +48,9 @@ export async function runDoctor({ outputRoot, adapter, config, runeRoot }) {
46
48
  // Check 2: Output directory exists
47
49
  if (adapter.name === 'claude') {
48
50
  results.checks.push({ name: 'Output directory', status: 'skip', detail: 'Claude Code uses source directly' });
51
+ const tierCoverage = await checkTierCoverage({ projectRoot: outputRoot });
52
+ for (const check of tierCoverage.checks) results.checks.push(check);
53
+ for (const warn of tierCoverage.warnings) results.warnings.push(warn);
49
54
  return results;
50
55
  }
51
56
 
@@ -164,11 +169,137 @@ export async function runDoctor({ outputRoot, adapter, config, runeRoot }) {
164
169
  results.warnings.push(...orgErrors);
165
170
  }
166
171
 
172
+ // Check 11: Tier hook coverage across detected platforms (advisory)
173
+ const tierCoverage = await checkTierCoverage({ projectRoot: outputRoot });
174
+ for (const check of tierCoverage.checks) results.checks.push(check);
175
+ for (const warn of tierCoverage.warnings) results.warnings.push(warn);
176
+
167
177
  if (results.errors.length > 0) results.healthy = false;
168
178
 
169
179
  return results;
170
180
  }
171
181
 
182
+ const PLATFORM_TIER_DIRS = Object.freeze({
183
+ cursor: '.cursor/rules',
184
+ windsurf: '.windsurf/workflows',
185
+ antigravity: '.antigravity/rules',
186
+ });
187
+
188
+ const PLATFORM_TIER_EXTS = Object.freeze({
189
+ cursor: '.mdc',
190
+ windsurf: '.md',
191
+ antigravity: '.md',
192
+ });
193
+
194
+ /**
195
+ * True if the platform's config shows tier hooks installed for `tier`.
196
+ * Claude: scans `.claude/settings.json` for the `${RUNE_<TIER>_ROOT}` substring
197
+ * that the installer emits into every tier command string.
198
+ * Others: scans the platform's rules/workflow dir for a `rune-<tier>-*` file.
199
+ * Windsurf additionally checks `.windsurf/rules` since a tier entry
200
+ * writes both a workflow and a rule file.
201
+ */
202
+ async function platformHasTier(platformId, projectRoot, tier) {
203
+ if (platformId === 'claude') {
204
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
205
+ if (!existsSync(settingsPath)) return false;
206
+ const raw = await readFile(settingsPath, 'utf-8');
207
+ return raw.includes(`\${RUNE_${tier.toUpperCase()}_ROOT}`);
208
+ }
209
+ const relDir = PLATFORM_TIER_DIRS[platformId];
210
+ if (!relDir) return false;
211
+ const ext = PLATFORM_TIER_EXTS[platformId];
212
+ const prefix = `rune-${tier}-`;
213
+
214
+ const scanDir = async (dir) => {
215
+ if (!existsSync(dir)) return false;
216
+ const entries = await readdir(dir, { withFileTypes: true });
217
+ return entries.some((e) => e.isFile() && e.name.startsWith(prefix) && e.name.endsWith(ext));
218
+ };
219
+
220
+ if (await scanDir(path.join(projectRoot, relDir))) return true;
221
+ if (platformId === 'windsurf') {
222
+ if (await scanDir(path.join(projectRoot, '.windsurf/rules'))) return true;
223
+ }
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * Advisory tier-coverage check. Silent when no tiers are detected or no
229
+ * tier hooks are installed anywhere. Warns when:
230
+ * 1. A tier's hooks are installed on some detected platforms but missing
231
+ * on others — suggests re-running `rune hooks install --tier ... --platform all`.
232
+ * 2. A tier manifest requires env vars that are unset — installed hooks
233
+ * will fail at runtime, and `rune hooks install` surfaces this too.
234
+ *
235
+ * Advisory only: emits WARN, never blocks the doctor pipeline.
236
+ *
237
+ * @param {{projectRoot: string}} options
238
+ * @returns {Promise<{checks: object[], warnings: string[]}>}
239
+ */
240
+ export async function checkTierCoverage({ projectRoot }) {
241
+ const result = { checks: [], warnings: [] };
242
+ const tiers = await listDetectedTiers(projectRoot);
243
+ if (tiers.length === 0) return result;
244
+
245
+ const detected = detectPlatforms(projectRoot);
246
+ const gaps = [];
247
+ let installedSomewhere = false;
248
+ const coverageByTier = [];
249
+
250
+ for (const tierInfo of tiers) {
251
+ if (!tierInfo.found) {
252
+ result.warnings.push(
253
+ `tier ${tierInfo.tier}: manifest at ${tierInfo.manifestPath} failed to load — ${tierInfo.error}`,
254
+ );
255
+ continue;
256
+ }
257
+
258
+ const coverage = { tier: tierInfo.tier, installedOn: [], missingOn: [] };
259
+ for (const id of detected) {
260
+ const has = await platformHasTier(id, projectRoot, tierInfo.tier);
261
+ if (has) coverage.installedOn.push(id);
262
+ else coverage.missingOn.push(id);
263
+ }
264
+ if (coverage.installedOn.length > 0) installedSomewhere = true;
265
+ if (coverage.installedOn.length > 0 && coverage.missingOn.length > 0) {
266
+ gaps.push(coverage);
267
+ }
268
+ coverageByTier.push(coverage);
269
+
270
+ if (!tierInfo.requiresOk && coverage.installedOn.length > 0) {
271
+ result.warnings.push(
272
+ `tier ${tierInfo.tier}: required env var${tierInfo.requiresMissing.length === 1 ? '' : 's'} unset (${tierInfo.requiresMissing.join(', ')}) — hooks will FAIL at runtime. Fix: export ${tierInfo.requiresMissing[0]}=/path/to/${tierInfo.tier} then re-run your shell.`,
273
+ );
274
+ }
275
+ }
276
+
277
+ if (!installedSomewhere) return result;
278
+
279
+ if (gaps.length === 0) {
280
+ result.checks.push({
281
+ name: 'Tier coverage',
282
+ status: 'pass',
283
+ detail: coverageByTier.map((c) => `${c.tier}:${c.installedOn.join('+') || 'none'}`).join(', '),
284
+ });
285
+ return result;
286
+ }
287
+
288
+ const detailParts = gaps.map((g) => `${g.tier} missing on ${g.missingOn.join(', ')}`);
289
+ result.checks.push({
290
+ name: 'Tier coverage',
291
+ status: 'warn',
292
+ detail: detailParts.join('; '),
293
+ });
294
+ for (const gap of gaps) {
295
+ const plural = gap.missingOn.length === 1 ? 'platform' : 'platforms';
296
+ result.warnings.push(
297
+ `tier ${gap.tier} installed on ${gap.installedOn.join(', ')} but missing on detected ${plural} ${gap.missingOn.join(', ')}. Re-run: rune hooks install --preset gentle --tier ${gap.tier} --platform all`,
298
+ );
299
+ }
300
+ return result;
301
+ }
302
+
172
303
  /**
173
304
  * Check that all cross-references in compiled files point to existing files
174
305
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rune-kit/rune",
3
- "version": "2.12.0",
3
+ "version": "2.12.3",
4
4
  "description": "62-skill mesh for AI coding assistants — runtime auto-discipline via native hooks (Claude/Cursor/Windsurf/Antigravity), 5-layer architecture, 215+ connections, multi-platform compiler.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@ name: ba
3
3
  description: Business Analyst agent. Deeply understands user requirements before any planning or coding begins. Asks probing questions, identifies hidden requirements, maps stakeholders, defines scope boundaries, and produces a structured Requirements Document that plan and cook consume.
4
4
  metadata:
5
5
  author: runedev
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  layer: L2
8
8
  model: opus
9
9
  group: creation
@@ -87,6 +87,23 @@ Each question must be asked separately, wait for answer before next.
87
87
  Exception: if user provides a detailed spec/PRD → extract answers from it, confirm with user.
88
88
  </HARD-GATE>
89
89
 
90
+ #### Question Discipline (MANDATORY)
91
+
92
+ Every question the user answers burns attention you don't get back. Protect it.
93
+
94
+ 1. **Max 5 questions total across the whole BA session.** If you find yourself wanting a 6th, the answer is in the first 5 or you're stalling — re-read, don't re-ask.
95
+ 2. **Prefer yes/no or multiple-choice over open-ended.** An open-ended question is a last resort when no reasonable option set exists.
96
+ - BAD: "What auth strategy do you want?"
97
+ - GOOD: "Auth: **(a)** email+password with JWT, **(b)** OAuth (Google/GitHub), **(c)** magic link, **(d)** I'll decide — pick one."
98
+ 3. **Never ask what you can infer.** If the answer is in the repo, the user's message, or the classification from Step 1 — don't ask it.
99
+ - Wrong stack? → read `package.json`, don't ask.
100
+ - Wrong audience? → check the `README`, don't ask.
101
+ - Wrong framework? → check config files, don't ask.
102
+ 4. **Cache the answer.** Write each Q→A pair into the Requirements Document verbatim. If the user restarts the BA session on the same feature, reuse the cached answers — never re-ask what was already answered.
103
+ 5. **Bundle yes/no questions after Q1 if the user is concise.** A user who replies "y" / "n" / "skip" in 1-2 words tolerates a bundle. A user who replies with paragraphs wants the slow pace — keep one-at-a-time.
104
+
105
+ Every Q should earn its slot: removing it must leave the Requirements Document materially worse. If it wouldn't, cut the question.
106
+
90
107
  #### Structured Elicitation Frameworks
91
108
 
92
109
  Choose the framework that fits the requirement type. Use it to STRUCTURE the 5 Questions above, not replace them.
@@ -449,13 +466,16 @@ Saved to `.rune/features/<feature-name>/requirements.md`
449
466
 
450
467
  ## Constraints
451
468
 
452
- 1. MUST ask 5 probing questions before producing requirements — no assumptions
453
- 2. MUST identify hidden requirementsthe obvious ones are never the full picture
454
- 3. MUST define out-of-scope explicitlyprevents scope creep
455
- 4. MUST produce testable acceptance criteria they become test cases
456
- 5. MUST NOT write code or plan implementation BA produces WHAT, plan produces HOW
457
- 6. MUST ask ONE question at a time don't overwhelm user with 5 questions at once
458
- 7. MUST NOT skip BA for non-trivial tasks "just build it" gets redirected to Question 1
469
+ 1. MUST ask up to 5 probing questions before producing requirements — never more, skip any you can infer from context
470
+ 2. MUST prefer yes/no or multiple-choice questions open-ended only when no reasonable option set exists
471
+ 3. MUST NOT ask for information already present in the user's message, the repo, or classification read/grep first, ask second
472
+ 4. MUST cache each Q→A pair in the Requirements Document and reuse on subsequent BA sessions for the same feature
473
+ 5. MUST identify hidden requirements the obvious ones are never the full picture
474
+ 6. MUST define out-of-scope explicitlyprevents scope creep
475
+ 7. MUST produce testable acceptance criteriathey become test cases
476
+ 8. MUST NOT write code or plan implementation — BA produces WHAT, plan produces HOW
477
+ 9. MUST ask ONE question at a time by default; bundle yes/no batches only after user shows concise replies
478
+ 10. MUST NOT skip BA for non-trivial tasks — "just build it" gets redirected to Question 1
459
479
 
460
480
  ## Returns
461
481
 
@@ -476,6 +496,10 @@ Known failure modes for this skill. Check these before declaring done.
476
496
  | Answering own questions instead of asking user | HIGH | Questions require USER input — BA doesn't guess |
477
497
  | Producing implementation details (HOW) instead of requirements (WHAT) | HIGH | BA outputs requirements doc → plan outputs implementation |
478
498
  | All-at-once question dump (asking 5 questions in one message) | MEDIUM | One question at a time, wait for answer before next |
499
+ | Asking open-ended questions when yes/no or multiple-choice would work | HIGH | Question Discipline rule 2 — option sets are faster to answer and easier to cache |
500
+ | Asking for info already in the repo/message (stack, framework, audience) | HIGH | Question Discipline rule 3 — read `package.json`/README/config first, ask only what you genuinely can't find |
501
+ | Exceeding 5 questions when the user seems "engaged" | MEDIUM | Question Discipline rule 1 — hard cap at 5. A 6th question is a sign of stalling, not thoroughness |
502
+ | Re-asking on session restart when answers were cached | MEDIUM | Question Discipline rule 4 — load `.rune/features/<name>/requirements.md` and reuse cached Q→A pairs |
479
503
  | Missing hidden requirements (auth, error handling, edge cases) | HIGH | Step 3 checklist is mandatory scan |
480
504
  | Requirements doc too verbose (>500 lines) | MEDIUM | Max 200 lines — concise, actionable, testable |
481
505
  | Skipping BA for "simple" features that turn out complex | HIGH | Let cook's complexity detection trigger BA, not user judgment |
@@ -5,7 +5,7 @@ context: fork
5
5
  agent: general-purpose
6
6
  metadata:
7
7
  author: runedev
8
- version: "2.4.0"
8
+ version: "2.5.0"
9
9
  layer: L1
10
10
  model: sonnet
11
11
  group: orchestrator
@@ -307,9 +307,10 @@ Contract violations are NON-NEGOTIABLE. If `.rune/contract.md` exists and a plan
307
307
  2. **Feature workspace** (opt-in) — for non-trivial features (3+ phases), suggest creating `.rune/features/<feature-name>/` with `spec.md`, `plan.md`, `decisions.md`, `status.md`. Skip for simple bug fixes, fast mode.
308
308
  3. Create implementation plan: exact files to create/modify, change order, dependencies, active decision constraints
309
309
  4. If multiple valid approaches exist → invoke `rune:brainstorm` for trade-off analysis
310
- 5. Present plan to user for approval
311
- 6. If feature workspace was created, write approved plan to `.rune/features/<name>/plan.md`
312
- 7. Mark Phase 2 as `completed`
310
+ 5. **Frontend detection** — if task touches `.tsx/.jsx/.vue/.svelte/.css`, component files, or mentions "UI/page/screen/design/layout/landing": invoke `rune:design` BEFORE plan approval. Pass hint `mode: "tweaks-default"` — design proposes ONE opinionated default per `.rune/design-system.md` (Step 2.7), not a 5-option menu. User replies with tweaks ("more professional", "darker") rather than picking from a list. If `.rune/design-system.md` is missing, design creates it first.
311
+ 6. Present plan to user for approval
312
+ 7. If feature workspace was created, write approved plan to `.rune/features/<name>/plan.md`
313
+ 8. Mark Phase 2 as `completed`
313
314
 
314
315
  **Gate**: User MUST approve the plan before proceeding. Do NOT skip this.
315
316
 
@@ -751,7 +752,7 @@ Mentally track tool call fingerprints. 3 identical calls → WARN. 5 identical c
751
752
  | 1 | `logic-guardian` | L2 | Conditional: when `.rune/logic-manifest.json` exists — protect complex business logic before any edits |
752
753
  | 2 | `plan` | L2 | Create implementation plan |
753
754
  | 2 | `brainstorm` | L2 | Trade-off analysis / rescue mode |
754
- | 2 | `design` | L2 | UI/design phase for frontend features |
755
+ | 2 | `design` | L2 | UI/design phase for frontend features — invoke with `mode: "tweaks-default"` (one opinionated default + accept natural-language tweaks, not a 5-option menu) |
755
756
  | 2.5 | `adversary` | L2 | Red-team challenge on approved plan |
756
757
  | 3 | `test` | L2 | Write failing tests (RED phase) |
757
758
  | 4 | `fix` | L2 | Implement code changes (GREEN phase) |
@@ -3,7 +3,7 @@ name: design
3
3
  description: Design system reasoning. Maps product domain to style, palette, typography, and platform-specific patterns. Generates .rune/design-system.md as the shared design contract for all UI-generating skills.
4
4
  metadata:
5
5
  author: runedev
6
- version: "0.4.0"
6
+ version: "0.5.0"
7
7
  layer: L2
8
8
  model: sonnet
9
9
  group: creation
@@ -125,6 +125,84 @@ Accept a single mood keyword (or infer from context if obvious). Map mood to con
125
125
 
126
126
  **Skip if**: User says "no preference" or "just follow domain defaults" — proceed to Step 3 with domain-only reasoning.
127
127
 
128
+ ### Step 2.7 — Tweaks, Not Menus (Default Style Pattern)
129
+
130
+ Picking from a 10-option style menu is how AI UI gets generic. Instead:
131
+
132
+ 1. **Propose ONE opinionated default** based on domain + mood (from Step 2.5). Describe it in 2-3 lines — style, palette direction, typography pairing.
133
+ 2. **Ask for tweaks, not choices.** The question is **"Any tweaks to this, or ship it?"** — not "Which of these do you prefer?"
134
+ 3. **Accept natural-language adjustments.** Map phrases → design system edits:
135
+ - "more professional" → heavier type weights, reduce saturation, tighter spacing
136
+ - "less corporate" → looser weights, brighter accent, more whitespace
137
+ - "darker" → swap base for darker neutral, raise contrast on elevated surfaces
138
+ - "more playful" → add subtle animation, soften corners, bolder accent
139
+ - "more trust" → cooler palette (slate/blue), heavier headers, smaller radius
140
+ 4. **If the user asks for a menu**, provide max 3 options — but mark one as the recommended default. Never present a neutral list of 5+ equivalent styles.
141
+
142
+ Why: Every menu option dilutes commitment. A single confident default gets committed, tweaked, and shipped. A menu gets deliberated, A/B'd, and abandoned. This is the **Tweaks Default** pattern from Anthropic's design system guidance — the AI commits first, humans steer second.
143
+
144
+ ### Step 2.9 — Universal Anti-AI Rules (apply to ALL domains)
145
+
146
+ These rules apply regardless of domain, mood, or platform. Every generated design system MUST comply.
147
+
148
+ **Enforcement**: `rune:review` v1.1.0+ reads `.rune/design-system.md` § Scale Minimums and flags violations of all 3 rules below as MEDIUM/HIGH findings. Design defines, review enforces — this is the contract.
149
+
150
+ #### Rule 1 — Scale Minimums
151
+
152
+ Below these thresholds, designs read as "AI boilerplate" no matter how good the palette is.
153
+
154
+ | Element | Minimum | Ideal |
155
+ |---------|---------|-------|
156
+ | Hero/display text | 48px | 56-72px |
157
+ | H1 (page title) | 32px | 36-40px |
158
+ | Body text | 16px (never 14px for primary content) | 16-18px |
159
+ | Secondary/meta text | 14px | 14-15px |
160
+ | Touch targets (mobile) | 44×44px | 48×48px |
161
+ | Touch target gap (mobile) | 8px | 12px |
162
+ | Focus-visible ring | 2px | 3px |
163
+
164
+ Write these minimums to `.rune/design-system.md` under `## Scale Minimums`. Downstream skills (`cook`, `fix`) treat violations as review findings.
165
+
166
+ #### Rule 2 — Placeholder Over Bad SVG
167
+
168
+ If the design calls for an icon, illustration, or graphic that the agent cannot generate at high quality, **ship a boxed placeholder, not a malformed SVG**.
169
+
170
+ ```html
171
+ <!-- GOOD: placeholder -->
172
+ <div class="placeholder" data-icon="dashboard" aria-label="Dashboard icon — design pass needed">
173
+ [ ICON: dashboard ]
174
+ </div>
175
+
176
+ <!-- BAD: AI-generated SVG with broken geometry -->
177
+ <svg viewBox="0 0 24 24">
178
+ <path d="M12 2L2 7l10 5 10-5-10-5z M2 17l10 5 10-5 M2 12l10 5 10-5"/>
179
+ </svg>
180
+ ```
181
+
182
+ - Use **Phosphor Icons** (`@phosphor-icons/react`) or **Huge Icons** as the library default. Never generate custom SVG for standard iconography.
183
+ - For illustrations, reference a placeholder string (e.g., `[ILLUSTRATION: empty-state-dashboard]`) that a human or asset-creator pass fills in later.
184
+ - Malformed SVG is the #1 AI tell. A clean labeled placeholder is honest and professional.
185
+
186
+ #### Rule 3 — Color Derivation via oklch(), not Manual Shading
187
+
188
+ When the design needs a darker hover, lighter surface, or tinted state, **derive from the accent via oklch()** — never eyeball a hex value.
189
+
190
+ ```css
191
+ /* GOOD: relative derivation */
192
+ --accent: oklch(65% 0.2 255);
193
+ --accent-hover: oklch(from var(--accent) calc(l - 0.08) c h);
194
+ --accent-pressed: oklch(from var(--accent) calc(l - 0.15) c h);
195
+ --accent-subtle: oklch(from var(--accent) calc(l + 0.3) calc(c * 0.4) h);
196
+
197
+ /* BAD: manual hex shading — breaks hue/chroma consistency */
198
+ --accent: #3b82f6;
199
+ --accent-hover: #2563eb; /* guessed darker */
200
+ ```
201
+
202
+ Why: HSL shading distorts perceived brightness at different hues. oklch() keeps perceptual lightness consistent, so derived states look intentional rather than "kinda close." Write derived tokens to `.rune/design-system.md` — downstream skills reuse these, not re-derive.
203
+
204
+ Bonus: use `text-wrap: pretty` on headings to prevent widow words. One line, zero ceremony.
205
+
128
206
  ### Step 3 — Apply Domain Reasoning Rules
129
207
 
130
208
  Map domain to design system parameters:
@@ -532,6 +610,10 @@ Trading/Fintech — Data-Dense Dark — Web
532
610
  4. MUST write `.rune/design-system.md` — ephemeral design decisions evaporate; persistence is the point
533
611
  5. MUST NOT overwrite existing design-system.md without user confirmation
534
612
  6. MUST include platform-specific overrides when platform is iOS or Android
613
+ 7. MUST propose ONE opinionated default and ask for tweaks — never present a neutral 5+ option menu (Step 2.7 Tweaks Default)
614
+ 8. MUST enforce Scale Minimums (hero ≥48px, body ≥16px, touch targets ≥44px) in every design system (Step 2.9 Rule 1)
615
+ 9. MUST use Phosphor/Huge icons or boxed placeholders — NEVER generate custom SVG for standard iconography (Step 2.9 Rule 2)
616
+ 10. MUST derive accent variants via `oklch(from var(--accent) ...)` — NEVER hand-shade hex values (Step 2.9 Rule 3)
535
617
 
536
618
  ## Mesh Gates (L1/L2 only)
537
619
 
@@ -541,6 +623,10 @@ Trading/Fintech — Data-Dense Dark — Web
541
623
  | Anti-Pattern Gate | Anti-pattern list derived from domain rules (not generic) | Domain-specific list required |
542
624
  | Persistence Gate | .rune/design-system.md written before reporting done | Write file first |
543
625
  | Platform Gate | Platform detected before generating tokens | Default to web, note assumption |
626
+ | Tweaks-Default Gate | One opinionated default proposed before asking for tweaks | Do NOT present neutral 5-option menus |
627
+ | Scale-Minimums Gate | Hero ≥48px, body ≥16px, touch ≥44px written into design-system.md | Emit minimums block in output |
628
+ | SVG-Placeholder Gate | No hand-rolled SVG for standard icons — Phosphor/Huge or placeholder | Swap to icon library or `[ ICON: name ]` box |
629
+ | oklch-Derivation Gate | All accent variants derived via `oklch(from ...)` | Rewrite manual hex shades as relative oklch |
544
630
 
545
631
  ## Returns
546
632
 
@@ -568,13 +654,20 @@ Known failure modes for this skill. Check these before declaring done.
568
654
  | Visual audit score < 18 shipped without improvement plan | MEDIUM | Step 6.5 flags weak pillars and creates backlog items |
569
655
  | iOS target generating solid-background cards | MEDIUM | Platform Gate: iOS 26 Liquid Glass deprecates this pattern |
570
656
  | Android target using hardcoded hex colors | MEDIUM | Platform Gate: MaterialTheme.colorScheme is mandatory for dynamic color |
657
+ | Presenting a neutral 5+ option style menu (deliberation death) | HIGH | Step 2.7 Tweaks Default — propose ONE opinionated default, ask for tweaks |
658
+ | Body text at 14px or hero at <40px (AI boilerplate scale) | HIGH | Step 2.9 Rule 1 — enforce Scale Minimums table in every design system |
659
+ | Hand-rolled SVG for dashboard/menu/close icons (malformed geometry) | HIGH | Step 2.9 Rule 2 — Phosphor/Huge Icons or `[ ICON: name ]` placeholder, never custom |
660
+ | Accent variants shaded by eyeball (inconsistent perceived brightness) | MEDIUM | Step 2.9 Rule 3 — `oklch(from var(--accent) calc(l - 0.1) c h)` |
661
+ | Missing `text-wrap: pretty` on headings (widow words) | LOW | One-line CSS — add to base heading styles |
571
662
 
572
663
  ## Done When
573
664
 
574
665
  - Design reference loaded (user override or baseline)
575
666
  - Domain classified (one of the 10 categories or explicit custom reasoning)
576
667
  - Mood mapped to constraints (or explicitly skipped with "domain defaults")
577
- - Design system generated with: colors (primitive + semantic), typography, spacing, effects, anti-patterns
668
+ - Opinionated default proposed (Step 2.7) user confirmed or requested tweaks
669
+ - Universal anti-AI rules applied (Step 2.9): Scale Minimums, Placeholder-over-bad-SVG, oklch() color derivation
670
+ - Design system generated with: colors (primitive + semantic, oklch-derived variants), typography, spacing, effects, anti-patterns
578
671
  - Platform-specific overrides applied (if iOS/Android target)
579
672
  - UI-SPEC.md written with locked layout, hierarchy, and component decisions
580
673
  - Accessibility review completed (6 checks: contrast, focus, touch targets, labels, semantic HTML, motion)
@@ -3,7 +3,7 @@ name: graft
3
3
  description: "Clone, port, or convert features from any GitHub repo into your project. Understand before copy, challenge before implement. 4 modes: port (rewrite), compare (analysis), copy (transplant), improve (copy + optimize)."
4
4
  metadata:
5
5
  author: runedev
6
- version: "0.1.0"
6
+ version: "0.2.0"
7
7
  layer: L2
8
8
  model: sonnet
9
9
  group: creation
@@ -18,6 +18,17 @@ metadata:
18
18
 
19
19
  External code intelligence — structured workflow for learning from, adapting, and integrating features from any public repository into your project. Graft is NOT a copy-paste tool. It enforces understanding before adoption through a mandatory challenge gate that evaluates license compatibility, stack fit, scope, quality, and maintenance health before any code touches your codebase.
20
20
 
21
+ ## Core Rule: The Tree is a Menu, Not the Meal
22
+
23
+ When you clone a repo you see hundreds of files. **That tree is a menu — options to order from, not a meal to eat.** Grafting the whole tree is how context windows die and foreign patterns leak into your codebase.
24
+
25
+ - Read the README + the **2-5 files that implement the target feature**. Skip the rest.
26
+ - If you cannot name the specific files you need before reading, you do not know what you want yet — go back to Step 0 and narrow scope.
27
+ - `WebFetch` on raw GitHub URLs beats `git clone` whenever you know the exact files. Use clone only when discovery is genuinely needed.
28
+ - A graft that reads >10 source files is almost always a scoping failure, not a thorough one.
29
+
30
+ This rule applies to ALL modes. Copy mode is not an excuse to import a directory wholesale — you still select files deliberately.
31
+
21
32
  <HARD-GATE>
22
33
  Challenge gate (Step 4) MUST complete before adaptation planning (Step 5).
23
34
  No implementation without confronting trade-offs. This applies to ALL modes except compare.
@@ -123,14 +134,16 @@ git sparse-checkout set <target-dir>
123
134
 
124
135
  For specific files or small repos: use `WebFetch` on raw GitHub URLs instead of cloning.
125
136
 
126
- **Read in this order** (stop when you have enough context):
137
+ **Read in this order** (stop when you have enough context — see Core Rule: the tree is a menu):
127
138
  1. README.md — purpose, architecture overview
128
- 2. Target dir's files — the actual code to graft
139
+ 2. Target dir's files — the actual code to graft (aim for 2-5 files, hard-cap at 10)
129
140
  3. package.json / pyproject.toml / Cargo.toml — dependencies and stack
130
141
  4. Tests for target feature — understand expected behavior
131
142
 
132
143
  **Scope guard**: If target feature spans >15 files or >2000 LOC → WARN user: "Feature is large. Suggest narrowing to [specific module]. Continue anyway?"
133
144
 
145
+ **Menu discipline**: Before reading file #6, pause and ask "do I actually need this, or am I eating the menu?" If the answer isn't a concrete reason tied to the target feature, stop reading and move to Step 2.
146
+
134
147
  ### Step 2 — Analyze Source
135
148
 
136
149
  Understand the target feature's architecture:
@@ -289,6 +302,7 @@ graft.complete:
289
302
  5. MUST respect local conventions — grafted code should look native, not foreign
290
303
  6. MUST NOT modify the source repository — read-only access only
291
304
  7. MUST NOT graft without scoping — always narrow to specific feature/module
305
+ 8. MUST treat the source file tree as a menu, not a meal — read the 2-5 files the feature actually needs, not every file you can see
292
306
 
293
307
  ## Mesh Gates
294
308
 
@@ -305,6 +319,7 @@ graft.complete:
305
319
  | Grafting GPL code into MIT project | CRITICAL | Challenge gate checks license — blocks incompatible |
306
320
  | Blindly copying code without understanding | CRITICAL | HARD-GATE: challenge before implement |
307
321
  | Context overflow from large source files | HIGH | Scope guard: >15 files or >2000 LOC triggers warning |
322
+ | Reading the whole repo instead of the feature | HIGH | "Tree is a menu" rule — pause before file #6, justify each read |
308
323
  | Grafted code doesn't match local conventions | HIGH | Step 3 scans local patterns, Step 5 plans adaptation |
309
324
  | Stale source (abandoned repo) | MEDIUM | Maintenance dimension in challenge gate |
310
325
  | Private repo URL fails | MEDIUM | Fallback to WebFetch raw URLs or manual paste |
@@ -320,6 +335,7 @@ SELF-VALIDATION (run before emitting graft.complete):
320
335
  - [ ] License compatibility confirmed (or user override documented)
321
336
  - [ ] Temp clone directory cleaned up
322
337
  - [ ] Grafted code compiles/lints without new errors
338
+ - [ ] Source files read count ≤10 (menu discipline) — if >10, document why in the output
323
339
  IF ANY check fails → fix before reporting done. Do NOT defer to completion-gate.
324
340
  ```
325
341
 
@@ -19,12 +19,14 @@
19
19
  import { existsSync } from 'node:fs';
20
20
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
21
  import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
22
23
  import { parseArgs } from 'node:util';
23
24
  import { detectInvariants, renderInvariants } from './detect-invariants.js';
24
25
  import { applyInvariantsPointer } from './inject-claude-md.js';
25
26
 
26
27
  const AUTO_HEADER = '## Auto-detected (new)';
27
28
  const TEMPLATE_REL_PATH = '../references/invariants-template.md';
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
30
 
29
31
  /**
30
32
  * Locate the next `## `-level section boundary in `text`, ignoring any `##`
@@ -60,7 +62,7 @@ function findNextSectionOffset(text) {
60
62
  }
61
63
 
62
64
  export async function loadTemplate(templatePath) {
63
- const resolved = templatePath ?? path.resolve(import.meta.dirname, TEMPLATE_REL_PATH);
65
+ const resolved = templatePath ?? path.resolve(__dirname, TEMPLATE_REL_PATH);
64
66
  return readFile(resolved, 'utf8');
65
67
  }
66
68
 
@@ -3,7 +3,7 @@ name: preflight
3
3
  description: Pre-commit quality gate that catches "almost right" code. Goes beyond linting — checks logic correctness, error handling, regressions, and completeness.
4
4
  metadata:
5
5
  author: runedev
6
- version: "1.0.0"
6
+ version: "1.1.0"
7
7
  layer: L2
8
8
  model: sonnet
9
9
  group: quality
@@ -224,14 +224,20 @@ For each detected domain, run its checks on the relevant files in the diff:
224
224
 
225
225
  #### UI/Frontend Domain Checks
226
226
 
227
- When UI/Frontend hook is triggered, run these checks on all `.tsx`/`.jsx`/`.svelte`/`.vue` files in the diff:
227
+ When UI/Frontend hook is triggered, run these checks on all `.tsx`/`.jsx`/`.svelte`/`.vue` files in the diff.
228
+
229
+ **Preamble — load design contract**: If `.rune/design-system.md` exists, read it once. Apply the project's **Scale Minimums** block over the defaults below (e.g., a project declaring `body ≥18px` should flag 16px body text). If the file is absent, use defaults and emit a LOW advisory: "No `.rune/design-system.md` — run `rune design` to lock visual decisions."
228
230
 
229
231
  | Check | What to Scan | Severity |
230
232
  |-------|-------------|----------|
231
233
  | **Design token compliance** | Hardcoded colors (`#fff`, `rgb(`, `hsl(`) instead of CSS variables or Tailwind tokens | WARN: "Hardcoded color at {file}:{line} — use design token" |
232
234
  | **UI-SPEC drift** | If `.rune/ui-spec.md` exists, compare component decisions (card style, form layout, nav type) against spec | BLOCK: "Component at {file} uses bordered cards but UI-SPEC locks elevated cards" |
233
235
  | **Animation accessibility** | Animations/transitions without `prefers-reduced-motion` guard | WARN: "Animation at {file}:{line} missing reduced-motion check" |
234
- | **Touch target size** | Interactive elements with explicit small sizing (`w-5 h-5`, `p-0.5` on buttons/links) < 44×44px | WARN: "Touch target too small at {file}:{line}" |
236
+ | **Touch target size** | Interactive elements with explicit small sizing (`w-5 h-5`, `p-0.5` on buttons/links) < 44×44px (or project override from design-system.md) | WARN: "Touch target too small at {file}:{line}" |
237
+ | **Scale Minimum — body text** | `text-sm` / `text-xs` / explicit `font-size: 14px` on `<p>` or primary body regions (not meta/secondary) | WARN: "Body text below 16px at {file}:{line} — reads as AI boilerplate" |
238
+ | **Scale Minimum — hero display** | `<h1>` with `text-3xl` or smaller (30px) when the heading is in a hero/landing section | WARN: "Hero heading below 48px at {file}:{line} — insufficient visual hierarchy" |
239
+ | **Hand-rolled SVG for standard icons** | Inline `<svg viewBox=` in JSX when the surrounding comment/class names indicate standard iconography (dashboard, menu, close, chevron, arrow, search, home, user, settings, bell, trash) | WARN: "Hand-rolled SVG at {file}:{line} — use @phosphor-icons/react or huge-icons, or ship boxed placeholder" |
240
+ | **Manual hex accent shading** | CSS/Tailwind config defining 2+ sibling `--accent-hover` / `--accent-pressed` / `--accent-active` with hex literals (no `oklch(from ...)` or design-token chain) | WARN: "Manual hex shade at {file}:{line} — derive via oklch(from var(--accent) calc(l - 0.08) c h)" |
235
241
  | **Missing states** | Components fetching data without loading/error/empty states | WARN: "Async component at {file} missing [loading|error|empty] state" |
236
242
  | **Icon accessibility** | Decorative icons without `aria-hidden="true"`, functional icons without `aria-label` | WARN: "Icon at {file}:{line} missing aria attribute" |
237
243
  | **Inline styles** | `style={{` or `style=` attribute usage instead of classes/tokens | WARN: "Inline style at {file}:{line} — use CSS class or Tailwind" |
@@ -240,6 +246,10 @@ When UI/Frontend hook is triggered, run these checks on all `.tsx`/`.jsx`/`.svel
240
246
 
241
247
  **Skip if**: Diff contains only test files, config files, or non-UI code (detected by absence of JSX/template syntax).
242
248
 
249
+ **Exception for Scale Minimums**: Secondary/meta text (`<time>`, `<small>`, form hints, table captions) is allowed at 14px. The check only fires on primary body regions — paragraphs inside `<main>`, `<article>`, card body, marketing hero/features. Use common sense or an explicit `data-scale="meta"` attribute to opt out.
250
+
251
+ **Exception for hand-rolled SVG**: Project logos, data visualizations (charts/graphs via d3/recharts/visx), and human-designed illustrations are never flagged. The check fires only when class/comment context names a standard icon.
252
+
243
253
  #### Pack Integration
244
254
 
245
255
  When a domain pack is installed (e.g., `@rune-pro/finance`, `@rune-pro/legal`), preflight checks the pack's **Hard-Stop Thresholds** table and applies matching rules to staged files. This means:
@@ -3,7 +3,7 @@ name: review
3
3
  description: Code quality review — patterns, security, performance, correctness. Finds bugs, suggests improvements, triggers fix for issues found. Escalates to opus for security-critical code.
4
4
  metadata:
5
5
  author: runedev
6
- version: "1.0.0"
6
+ version: "1.1.0"
7
7
  layer: L2
8
8
  model: sonnet
9
9
  group: development
@@ -381,6 +381,9 @@ Apply **only** when `.tsx`, `.jsx`, `.svelte`, `.vue`, or `.html` files are in t
381
381
 
382
382
  These are the **"AI UI signature"** — patterns that make AI-generated frontends visually identifiable as non-human-designed. Flag each as MEDIUM severity.
383
383
 
384
+ **Preamble — load design contract first:**
385
+ If `.rune/design-system.md` exists, read it first. Pull the project's **Scale Minimums** block (if authored by `rune:design` v0.5.0+) and apply those thresholds instead of the defaults below. Missing design-system.md → use defaults and add a LOW finding: "Project has no design-system.md — run `rune design` to lock visual decisions." Never enforce stale defaults against a project that has already declared stricter/looser minimums.
386
+
384
387
  **AI_ANTIPATTERN — Purple/indigo default accent with no domain justification:**
385
388
  ```tsx
386
389
  // BAD: LLM default color bias — signals "AI-generated" to experienced designers
@@ -415,6 +418,55 @@ className="bg-indigo-600 text-white" // every button/CTA is indigo
415
418
  <span className="font-mono text-2xl font-bold">${price}</span>
416
419
  ```
417
420
 
421
+ **AI_ANTIPATTERN — Scale Minimum violations (AI boilerplate tell):**
422
+ ```tsx
423
+ // BAD: body text at 14px (AI default) — primary content must be ≥16px
424
+ <p className="text-sm">Welcome to the dashboard.</p>
425
+
426
+ // BAD: hero/display text below 40px — reads as "section heading", not "hero"
427
+ <h1 className="text-3xl font-bold">Ship Faster</h1> // 30px
428
+
429
+ // BAD: touch target below 44×44px on mobile
430
+ <button className="w-8 h-8"><XIcon /></button> // 32px — WCAG 2.5.8 failure
431
+
432
+ // GOOD: hero ≥48px, body ≥16px, touch ≥44×44px
433
+ <h1 className="text-5xl md:text-6xl font-bold">Ship Faster</h1> // 48-60px
434
+ <p className="text-base">Welcome to the dashboard.</p> // 16px
435
+ <button className="w-11 h-11"><XIcon /></button> // 44px
436
+ ```
437
+ Pull project-specific overrides from `.rune/design-system.md` § Scale Minimums.
438
+
439
+ **AI_ANTIPATTERN — Hand-rolled SVG for standard iconography:**
440
+ ```tsx
441
+ // BAD: custom <svg> for dashboard/menu/close/chevron — AI geometry almost always malformed
442
+ <svg viewBox="0 0 24 24"><path d="M3 3h18v18H3z M3 9h18 M9 3v18"/></svg>
443
+
444
+ // GOOD: Phosphor Icons (preferred) or Huge Icons
445
+ import { House, List, X } from '@phosphor-icons/react';
446
+ <House weight="bold" size={24} />
447
+
448
+ // GOOD: labeled placeholder when no icon library available yet
449
+ <span className="icon-placeholder" aria-label="Dashboard icon — design pass needed">
450
+ [ ICON: dashboard ]
451
+ </span>
452
+ ```
453
+ Exceptions: inline SVG for project-unique logos, data visualizations (charts/graphs), or decorative illustrations generated by a human designer — these are not "standard iconography."
454
+
455
+ **AI_ANTIPATTERN — Manual hex shading for accent states (oklch() violation):**
456
+ ```css
457
+ /* BAD: hand-darkened hex — breaks perceived lightness consistency */
458
+ --accent: #3b82f6;
459
+ --accent-hover: #2563eb; /* guessed darker */
460
+ --accent-pressed: #1d4ed8; /* guessed even darker */
461
+
462
+ /* GOOD: relative oklch() derivation */
463
+ --accent: oklch(62% 0.19 258);
464
+ --accent-hover: oklch(from var(--accent) calc(l - 0.08) c h);
465
+ --accent-pressed: oklch(from var(--accent) calc(l - 0.15) c h);
466
+ --accent-subtle: oklch(from var(--accent) calc(l + 0.3) calc(c * 0.4) h);
467
+ ```
468
+ Flag any CSS file defining 2+ hover/pressed/active variants with sibling hex literals. Not a finding if accent uses a design-token library (Radix Colors, Tailwind palette) that already ships perceptually-tuned scales.
469
+
418
470
  **AI_ANTIPATTERN — Missing UI states (only happy path rendered):**
419
471
  ```tsx
420
472
  // BAD: data rendering without empty/error/loading states