@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.
- package/compiler/__tests__/hooks-antigravity.test.js +3 -1
- package/compiler/__tests__/hooks-cursor.test.js +3 -1
- package/compiler/__tests__/hooks-doctor-tier.test.js +214 -0
- package/compiler/__tests__/hooks-install.test.js +3 -1
- package/compiler/__tests__/hooks-tiers.test.js +102 -1
- package/compiler/__tests__/hooks-windsurf.test.js +3 -1
- package/compiler/commands/hooks/tiers.js +150 -6
- package/compiler/doctor.js +131 -0
- package/package.json +1 -1
- package/skills/ba/SKILL.md +32 -8
- package/skills/cook/SKILL.md +6 -5
- package/skills/design/SKILL.md +95 -2
- package/skills/graft/SKILL.md +19 -3
- package/skills/onboard/scripts/onboard-invariants.js +3 -1
- package/skills/preflight/SKILL.md +13 -3
- package/skills/review/SKILL.md +53 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
package/compiler/doctor.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/skills/ba/SKILL.md
CHANGED
|
@@ -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
|
+
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 —
|
|
453
|
-
2. MUST
|
|
454
|
-
3. MUST
|
|
455
|
-
4. MUST
|
|
456
|
-
5. MUST
|
|
457
|
-
6. MUST
|
|
458
|
-
7. MUST
|
|
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 explicitly — prevents scope creep
|
|
475
|
+
7. MUST produce testable acceptance criteria — they 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 |
|
package/skills/cook/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ context: fork
|
|
|
5
5
|
agent: general-purpose
|
|
6
6
|
metadata:
|
|
7
7
|
author: runedev
|
|
8
|
-
version: "2.
|
|
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.
|
|
311
|
-
6.
|
|
312
|
-
7.
|
|
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) |
|
package/skills/design/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
-
-
|
|
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)
|
package/skills/graft/SKILL.md
CHANGED
|
@@ -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.
|
|
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(
|
|
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.
|
|
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:
|
package/skills/review/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|