@rune-kit/rune 2.13.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/compiler/__tests__/adapter-model-mapping.test.js +152 -0
- package/compiler/__tests__/adr-scoring.test.js +91 -0
- package/compiler/__tests__/context-md-format.test.js +180 -0
- package/compiler/__tests__/context-pack-smell-tests.test.js +215 -0
- package/compiler/__tests__/context-preview-signal.test.js +166 -0
- package/compiler/__tests__/diversity-score.test.js +117 -0
- package/compiler/__tests__/improve-architecture.test.js +171 -0
- package/compiler/__tests__/openclaw-adapter.test.js +11 -6
- package/compiler/__tests__/oracle-bundle-format.test.js +210 -0
- package/compiler/__tests__/oracle-pending-schema.test.js +209 -0
- package/compiler/__tests__/out-of-scope-format.test.js +303 -0
- package/compiler/__tests__/skill-description-quality.test.js +147 -0
- package/compiler/__tests__/zoom-out-output.test.js +112 -0
- package/compiler/adapters/antigravity.js +17 -1
- package/compiler/adapters/claude.js +4 -0
- package/compiler/adapters/codex.js +16 -1
- package/compiler/adapters/cursor.js +4 -0
- package/compiler/adapters/generic.js +14 -1
- package/compiler/adapters/openclaw.js +15 -1
- package/compiler/adapters/opencode.js +17 -1
- package/compiler/adapters/windsurf.js +4 -0
- package/package.json +2 -2
- package/skills/adversary/SKILL.md +61 -1
- package/skills/adversary/evals.md +79 -0
- package/skills/adversary/references/context-bundle-format.md +122 -0
- package/skills/adversary/references/oracle-mode.md +116 -0
- package/skills/asset-creator/SKILL.md +1 -1
- package/skills/audit/SKILL.md +3 -1
- package/skills/autopsy/SKILL.md +1 -1
- package/skills/ba/SKILL.md +242 -83
- package/skills/ba/references/context-md-format.md +136 -0
- package/skills/ba/references/explore-first.md +107 -0
- package/skills/ba/references/out-of-scope-format.md +152 -0
- package/skills/brainstorm/SKILL.md +78 -2
- package/skills/brainstorm/references/design-it-twice.md +155 -0
- package/skills/browser-pilot/SKILL.md +1 -1
- package/skills/completion-gate/SKILL.md +1 -1
- package/skills/constraint-check/SKILL.md +1 -1
- package/skills/context-engine/SKILL.md +85 -1
- package/skills/context-engine/references/preview-gate.md +139 -0
- package/skills/context-pack/SKILL.md +68 -25
- package/skills/context-pack/evals.md +122 -0
- package/skills/context-pack/references/brief-template.md +121 -0
- package/skills/context-pack/references/durability-rules.md +109 -0
- package/skills/cook/SKILL.md +5 -4
- package/skills/db/SKILL.md +1 -1
- package/skills/debug/SKILL.md +7 -4
- package/skills/dependency-doctor/SKILL.md +1 -1
- package/skills/design/SKILL.md +1 -1
- package/skills/doc-processor/SKILL.md +1 -1
- package/skills/docs/SKILL.md +1 -1
- package/skills/docs-seeker/SKILL.md +1 -1
- package/skills/fix/SKILL.md +5 -3
- package/skills/git/SKILL.md +1 -1
- package/skills/hallucination-guard/SKILL.md +1 -1
- package/skills/improve-architecture/SKILL.md +275 -0
- package/skills/improve-architecture/evals.md +145 -0
- package/skills/improve-architecture/references/deepening.md +87 -0
- package/skills/improve-architecture/references/interface-design.md +68 -0
- package/skills/improve-architecture/references/language.md +81 -0
- package/skills/improve-architecture/references/scoring.md +122 -0
- package/skills/integrity-check/SKILL.md +1 -1
- package/skills/journal/SKILL.md +34 -7
- package/skills/journal/references/adr-criteria.md +109 -0
- package/skills/logic-guardian/SKILL.md +1 -1
- package/skills/marketing/SKILL.md +1 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/onboard/SKILL.md +1 -1
- package/skills/perf/SKILL.md +1 -1
- package/skills/plan/SKILL.md +1 -1
- package/skills/preflight/SKILL.md +1 -1
- package/skills/research/SKILL.md +1 -1
- package/skills/retro/SKILL.md +1 -1
- package/skills/review/SKILL.md +3 -2
- package/skills/review-intake/SKILL.md +26 -3
- package/skills/safeguard/SKILL.md +1 -1
- package/skills/scaffold/SKILL.md +1 -1
- package/skills/scout/SKILL.md +33 -1
- package/skills/sentinel/SKILL.md +1 -1
- package/skills/sentinel-env/SKILL.md +1 -1
- package/skills/session-bridge/SKILL.md +91 -4
- package/skills/session-bridge/references/detach-protocol.md +111 -0
- package/skills/skill-forge/SKILL.md +1 -1
- package/skills/slides/SKILL.md +1 -1
- package/skills/surgeon/SKILL.md +17 -2
- package/skills/team/SKILL.md +3 -0
- package/skills/test/SKILL.md +48 -1
- package/skills/test/evals.md +137 -0
- package/skills/test/references/mocking-policy.md +121 -0
- package/skills/test/references/test-quality.md +124 -0
- package/skills/test/references/vertical-tdd.md +110 -0
- package/skills/trend-scout/SKILL.md +1 -1
- package/skills/video-creator/SKILL.md +1 -1
- package/skills/watchdog/SKILL.md +1 -1
- package/skills/worktree/SKILL.md +1 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .out-of-scope/ KB format validation.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - Frontmatter parses as YAML
|
|
6
|
+
* - Required fields present (concept, aliases, decision, rejected_at, rejected_by, prior_requests)
|
|
7
|
+
* - decision == "rejected" (deferrals don't belong here)
|
|
8
|
+
* - concept matches filename (slug rule)
|
|
9
|
+
* - prior_requests has >=1 entry
|
|
10
|
+
* - Slug rules: kebab-case, lowercase, max 40 chars
|
|
11
|
+
*
|
|
12
|
+
* Operates on test fixtures (no real .out-of-scope/ in this repo yet).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert';
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { describe, test } from 'node:test';
|
|
20
|
+
|
|
21
|
+
// Minimal YAML frontmatter parser — we only need flat key:value + simple lists/objects.
|
|
22
|
+
// Mirrors what ba and review-intake expect at runtime.
|
|
23
|
+
function parseFrontmatter(text) {
|
|
24
|
+
const match = text.match(/^---\n([\s\S]*?)\n---/);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
const block = match[1];
|
|
27
|
+
const lines = block.split('\n');
|
|
28
|
+
const out = {};
|
|
29
|
+
let currentKey = null;
|
|
30
|
+
let currentList = null;
|
|
31
|
+
let currentObj = null;
|
|
32
|
+
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
if (line.startsWith(' - ')) {
|
|
36
|
+
// List item, or object-list item starting with `- key: value`
|
|
37
|
+
const inner = line.slice(4);
|
|
38
|
+
if (inner.includes(': ')) {
|
|
39
|
+
// First field of a new object in the list
|
|
40
|
+
currentObj = {};
|
|
41
|
+
const [k, v] = inner.split(/:\s+/);
|
|
42
|
+
currentObj[k.trim()] = stripQuotes(v.trim());
|
|
43
|
+
currentList.push(currentObj);
|
|
44
|
+
} else {
|
|
45
|
+
currentList.push(stripQuotes(inner.trim()));
|
|
46
|
+
currentObj = null;
|
|
47
|
+
}
|
|
48
|
+
} else if (line.startsWith(' ')) {
|
|
49
|
+
// Continuation field of the current object
|
|
50
|
+
if (currentObj && line.includes(': ')) {
|
|
51
|
+
const [k, v] = line.trim().split(/:\s+/);
|
|
52
|
+
currentObj[k.trim()] = stripQuotes(v.trim());
|
|
53
|
+
}
|
|
54
|
+
} else if (line.match(/^[a-z_]+:/)) {
|
|
55
|
+
const [key, ...rest] = line.split(':');
|
|
56
|
+
const value = rest.join(':').trim();
|
|
57
|
+
currentKey = key.trim();
|
|
58
|
+
currentObj = null;
|
|
59
|
+
if (value === '' || value === '[]') {
|
|
60
|
+
out[currentKey] = [];
|
|
61
|
+
currentList = out[currentKey];
|
|
62
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
63
|
+
out[currentKey] = value
|
|
64
|
+
.slice(1, -1)
|
|
65
|
+
.split(',')
|
|
66
|
+
.map((s) => stripQuotes(s.trim()))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
currentList = null;
|
|
69
|
+
} else {
|
|
70
|
+
out[currentKey] = stripQuotes(value);
|
|
71
|
+
currentList = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stripQuotes(s) {
|
|
79
|
+
if (!s) return s;
|
|
80
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
81
|
+
return s.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const REQUIRED_FIELDS = ['concept', 'aliases', 'decision', 'rejected_at', 'rejected_by', 'prior_requests'];
|
|
87
|
+
const SLUG_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
88
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
89
|
+
|
|
90
|
+
function validateOutOfScope(filename, content) {
|
|
91
|
+
const issues = [];
|
|
92
|
+
const fm = parseFrontmatter(content);
|
|
93
|
+
if (!fm) return ['frontmatter missing or unparseable'];
|
|
94
|
+
|
|
95
|
+
for (const f of REQUIRED_FIELDS) {
|
|
96
|
+
if (!(f in fm) || fm[f] === undefined) issues.push(`missing field: ${f}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (fm.decision && fm.decision !== 'rejected') {
|
|
100
|
+
issues.push(`decision must be "rejected" (got "${fm.decision}") — deferrals don't belong in .out-of-scope/`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (fm.concept) {
|
|
104
|
+
if (!SLUG_RE.test(fm.concept)) issues.push(`concept slug malformed: ${fm.concept}`);
|
|
105
|
+
if (fm.concept.length > 40) issues.push(`concept slug too long (>40 chars): ${fm.concept}`);
|
|
106
|
+
const expected = path.basename(filename, '.md');
|
|
107
|
+
if (fm.concept !== expected) {
|
|
108
|
+
issues.push(`concept "${fm.concept}" does not match filename "${expected}"`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (fm.rejected_at && !ISO_DATE_RE.test(fm.rejected_at)) {
|
|
113
|
+
issues.push(`rejected_at must be ISO date YYYY-MM-DD (got "${fm.rejected_at}")`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(fm.prior_requests) && fm.prior_requests.length === 0) {
|
|
117
|
+
issues.push('prior_requests must have >=1 entry');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Tests ---
|
|
124
|
+
|
|
125
|
+
describe('out-of-scope/ KB format', () => {
|
|
126
|
+
let tmpDir;
|
|
127
|
+
|
|
128
|
+
test('valid file passes', () => {
|
|
129
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'rune-oos-'));
|
|
130
|
+
const file = path.join(tmpDir, 'dark-mode.md');
|
|
131
|
+
const content = `---
|
|
132
|
+
concept: dark-mode
|
|
133
|
+
aliases: [night-theme, dark-theme]
|
|
134
|
+
decision: rejected
|
|
135
|
+
rejected_at: 2026-04-27
|
|
136
|
+
rejected_by: review-intake
|
|
137
|
+
priority_to_revisit: low
|
|
138
|
+
prior_requests:
|
|
139
|
+
- id: gh-issue-42
|
|
140
|
+
summary: Add dark mode support
|
|
141
|
+
closed_at: 2025-08-01
|
|
142
|
+
revisit_if:
|
|
143
|
+
- "team adds front-end engineer"
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
# Dark Mode
|
|
147
|
+
|
|
148
|
+
Body content.
|
|
149
|
+
`;
|
|
150
|
+
writeFileSync(file, content);
|
|
151
|
+
const issues = validateOutOfScope(file, content);
|
|
152
|
+
assert.deepStrictEqual(issues, []);
|
|
153
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('rejects file with decision=deferred', () => {
|
|
157
|
+
const content = `---
|
|
158
|
+
concept: dark-mode
|
|
159
|
+
aliases: [night-theme]
|
|
160
|
+
decision: deferred
|
|
161
|
+
rejected_at: 2026-04-27
|
|
162
|
+
rejected_by: ba
|
|
163
|
+
prior_requests:
|
|
164
|
+
- id: gh-issue-42
|
|
165
|
+
summary: x
|
|
166
|
+
closed_at: 2025-08-01
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
body
|
|
170
|
+
`;
|
|
171
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
172
|
+
assert.ok(
|
|
173
|
+
issues.some((i) => i.includes('decision must be "rejected"')),
|
|
174
|
+
'should reject decision=deferred',
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('rejects mismatched filename and concept', () => {
|
|
179
|
+
const content = `---
|
|
180
|
+
concept: foo-bar
|
|
181
|
+
aliases: []
|
|
182
|
+
decision: rejected
|
|
183
|
+
rejected_at: 2026-04-27
|
|
184
|
+
rejected_by: ba
|
|
185
|
+
prior_requests:
|
|
186
|
+
- id: i
|
|
187
|
+
summary: s
|
|
188
|
+
closed_at: 2025-08-01
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
body
|
|
192
|
+
`;
|
|
193
|
+
const issues = validateOutOfScope('different-name.md', content);
|
|
194
|
+
assert.ok(
|
|
195
|
+
issues.some((i) => i.includes('does not match filename')),
|
|
196
|
+
'should flag filename/concept mismatch',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('rejects empty prior_requests', () => {
|
|
201
|
+
const content = `---
|
|
202
|
+
concept: dark-mode
|
|
203
|
+
aliases: []
|
|
204
|
+
decision: rejected
|
|
205
|
+
rejected_at: 2026-04-27
|
|
206
|
+
rejected_by: ba
|
|
207
|
+
prior_requests: []
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
body
|
|
211
|
+
`;
|
|
212
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
213
|
+
assert.ok(
|
|
214
|
+
issues.some((i) => i.includes('prior_requests must have >=1 entry')),
|
|
215
|
+
'should require prior_requests',
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('rejects malformed slug', () => {
|
|
220
|
+
const content = `---
|
|
221
|
+
concept: Dark_Mode
|
|
222
|
+
aliases: []
|
|
223
|
+
decision: rejected
|
|
224
|
+
rejected_at: 2026-04-27
|
|
225
|
+
rejected_by: ba
|
|
226
|
+
prior_requests:
|
|
227
|
+
- id: i
|
|
228
|
+
summary: s
|
|
229
|
+
closed_at: 2025-08-01
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
body
|
|
233
|
+
`;
|
|
234
|
+
const issues = validateOutOfScope('Dark_Mode.md', content);
|
|
235
|
+
assert.ok(
|
|
236
|
+
issues.some((i) => i.includes('concept slug malformed')),
|
|
237
|
+
'should reject non-kebab-case slug',
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('rejects slug longer than 40 chars', () => {
|
|
242
|
+
const longSlug = 'a-very-long-concept-name-that-exceeds-the-forty-char-cap';
|
|
243
|
+
const content = `---
|
|
244
|
+
concept: ${longSlug}
|
|
245
|
+
aliases: []
|
|
246
|
+
decision: rejected
|
|
247
|
+
rejected_at: 2026-04-27
|
|
248
|
+
rejected_by: ba
|
|
249
|
+
prior_requests:
|
|
250
|
+
- id: i
|
|
251
|
+
summary: s
|
|
252
|
+
closed_at: 2025-08-01
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
body
|
|
256
|
+
`;
|
|
257
|
+
const issues = validateOutOfScope(`${longSlug}.md`, content);
|
|
258
|
+
assert.ok(
|
|
259
|
+
issues.some((i) => i.includes('too long')),
|
|
260
|
+
'should reject overlong slug',
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('rejects malformed rejected_at date', () => {
|
|
265
|
+
const content = `---
|
|
266
|
+
concept: dark-mode
|
|
267
|
+
aliases: []
|
|
268
|
+
decision: rejected
|
|
269
|
+
rejected_at: yesterday
|
|
270
|
+
rejected_by: ba
|
|
271
|
+
prior_requests:
|
|
272
|
+
- id: i
|
|
273
|
+
summary: s
|
|
274
|
+
closed_at: 2025-08-01
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
body
|
|
278
|
+
`;
|
|
279
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
280
|
+
assert.ok(
|
|
281
|
+
issues.some((i) => i.includes('ISO date')),
|
|
282
|
+
'should reject non-ISO date',
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('rejects file missing required fields', () => {
|
|
287
|
+
const content = `---
|
|
288
|
+
concept: dark-mode
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
body
|
|
292
|
+
`;
|
|
293
|
+
const issues = validateOutOfScope('dark-mode.md', content);
|
|
294
|
+
assert.ok(issues.length >= 4, `expected multiple missing-field errors, got ${issues.length}`);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('signal name validity', () => {
|
|
299
|
+
test('outofscope.match conforms to signal naming pattern', () => {
|
|
300
|
+
const SIGNAL_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
|
|
301
|
+
assert.ok(SIGNAL_PATTERN.test('outofscope.match'));
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill description quality (v2.15+) — lint validation that every SKILL.md
|
|
3
|
+
* description is YAML-safe, well-formed, and ambiguous-name skills carry an
|
|
4
|
+
* explicit "Use when…" routing hint.
|
|
5
|
+
*
|
|
6
|
+
* Validates:
|
|
7
|
+
* - All descriptions are double-quoted (YAML safety)
|
|
8
|
+
* - All descriptions end with `.` or `?`
|
|
9
|
+
* - All descriptions are 30-500 chars
|
|
10
|
+
* - Specific ambiguous-name skills include "Use when…" clause
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert';
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { describe, test } from 'node:test';
|
|
17
|
+
|
|
18
|
+
const SKILLS_DIR = resolve(import.meta.dirname, '../..', 'skills');
|
|
19
|
+
|
|
20
|
+
function listSkills() {
|
|
21
|
+
return readdirSync(SKILLS_DIR).filter((name) => {
|
|
22
|
+
const p = join(SKILLS_DIR, name);
|
|
23
|
+
return statSync(p).isDirectory() && readdirSync(p).includes('SKILL.md');
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readDescriptionLine(name) {
|
|
28
|
+
const text = readFileSync(join(SKILLS_DIR, name, 'SKILL.md'), 'utf8');
|
|
29
|
+
const lines = text.split(/\r?\n/);
|
|
30
|
+
for (const line of lines.slice(0, 20)) {
|
|
31
|
+
if (line.startsWith('description:')) return line;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractDescriptionValue(line) {
|
|
37
|
+
const m = line.match(/^description:\s+"(.+)"\s*$/);
|
|
38
|
+
if (m) return m[1].replace(/\\"/g, '"');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AMBIGUOUS_SKILLS_REQUIRING_USE_WHEN = [
|
|
43
|
+
'ba',
|
|
44
|
+
'completion-gate',
|
|
45
|
+
'constraint-check',
|
|
46
|
+
'doc-processor',
|
|
47
|
+
'integrity-check',
|
|
48
|
+
'logic-guardian',
|
|
49
|
+
'onboard',
|
|
50
|
+
'preflight',
|
|
51
|
+
'sentinel-env',
|
|
52
|
+
'watchdog',
|
|
53
|
+
'worktree',
|
|
54
|
+
'hallucination-guard',
|
|
55
|
+
'mcp-builder',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
describe('Skill description format', () => {
|
|
59
|
+
test('every description is double-quoted (YAML-safe)', () => {
|
|
60
|
+
const skills = listSkills();
|
|
61
|
+
const violations = [];
|
|
62
|
+
for (const name of skills) {
|
|
63
|
+
const line = readDescriptionLine(name);
|
|
64
|
+
if (!line) {
|
|
65
|
+
violations.push(`${name}: no description line found`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!line.match(/^description:\s+"/)) {
|
|
69
|
+
violations.push(`${name}: description is not double-quoted`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
assert.strictEqual(violations.length, 0, `Unquoted descriptions:\n${violations.join('\n')}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every description ends with terminal punctuation', () => {
|
|
76
|
+
const skills = listSkills();
|
|
77
|
+
const violations = [];
|
|
78
|
+
for (const name of skills) {
|
|
79
|
+
const line = readDescriptionLine(name);
|
|
80
|
+
const val = extractDescriptionValue(line);
|
|
81
|
+
if (!val) continue;
|
|
82
|
+
if (!val.match(/[.?]$/)) {
|
|
83
|
+
violations.push(`${name}: description does not end with . or ?`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
assert.strictEqual(violations.length, 0, `Bad endings:\n${violations.join('\n')}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('every description is 30-500 chars', () => {
|
|
90
|
+
const skills = listSkills();
|
|
91
|
+
const violations = [];
|
|
92
|
+
for (const name of skills) {
|
|
93
|
+
const line = readDescriptionLine(name);
|
|
94
|
+
const val = extractDescriptionValue(line);
|
|
95
|
+
if (!val) continue;
|
|
96
|
+
if (val.length < 30) violations.push(`${name}: description too short (${val.length} chars)`);
|
|
97
|
+
if (val.length > 500) violations.push(`${name}: description too long (${val.length} chars)`);
|
|
98
|
+
}
|
|
99
|
+
assert.strictEqual(violations.length, 0, `Length violations:\n${violations.join('\n')}`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('Ambiguous-name skills include "Use when…" routing hint', () => {
|
|
104
|
+
test('all 13 ambiguous-name skills explicitly state "Use when…"', () => {
|
|
105
|
+
const violations = [];
|
|
106
|
+
for (const name of AMBIGUOUS_SKILLS_REQUIRING_USE_WHEN) {
|
|
107
|
+
const line = readDescriptionLine(name);
|
|
108
|
+
const val = extractDescriptionValue(line);
|
|
109
|
+
if (!val) {
|
|
110
|
+
violations.push(`${name}: description not parseable`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!/Use when/i.test(val)) {
|
|
114
|
+
violations.push(`${name}: missing "Use when…" clause`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
assert.strictEqual(violations.length, 0, `Ambiguous-name skills missing "Use when…":\n${violations.join('\n')}`);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('Description integrity', () => {
|
|
122
|
+
test('YAML escape sequences are well-formed (no orphan \\")', () => {
|
|
123
|
+
const skills = listSkills();
|
|
124
|
+
const violations = [];
|
|
125
|
+
for (const name of skills) {
|
|
126
|
+
const line = readDescriptionLine(name);
|
|
127
|
+
if (!line) continue;
|
|
128
|
+
// Parse the quoted value: count unescaped quotes inside the value
|
|
129
|
+
const inner = line.replace(/^description:\s+"/, '').replace(/"\s*$/, '');
|
|
130
|
+
// Every \" should be escaping a quote; bare " inside is invalid
|
|
131
|
+
let i = 0;
|
|
132
|
+
let bareQuotes = 0;
|
|
133
|
+
while (i < inner.length) {
|
|
134
|
+
if (inner[i] === '\\' && inner[i + 1] === '"') {
|
|
135
|
+
i += 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (inner[i] === '"') bareQuotes++;
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
if (bareQuotes > 0) {
|
|
142
|
+
violations.push(`${name}: ${bareQuotes} unescaped " inside description value`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
assert.strictEqual(violations.length, 0, `Bare quotes:\n${violations.join('\n')}`);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zoom-out output validation — scout v0.4 zoom-out mode produces a 3-layer Mermaid map.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - Output is parseable Mermaid (graph LR ... or graph TB)
|
|
6
|
+
* - L0 (target) node is present and styled as "stuck"
|
|
7
|
+
* - L1 cap: max 8 sibling files
|
|
8
|
+
* - L2 cap: max 8 caller modules
|
|
9
|
+
* - Total nodes never exceed L0 + 16
|
|
10
|
+
* - Edge collapse note appears when caps hit
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert';
|
|
14
|
+
import { describe, test } from 'node:test';
|
|
15
|
+
|
|
16
|
+
function parseMermaid(text) {
|
|
17
|
+
// Minimal validator — just need to confirm structure
|
|
18
|
+
const lines = text
|
|
19
|
+
.split('\n')
|
|
20
|
+
.map((l) => l.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
const directive = lines.find((l) => /^graph (LR|TB|RL|BT)/.test(l));
|
|
23
|
+
if (!directive) return { valid: false, error: 'missing graph directive' };
|
|
24
|
+
|
|
25
|
+
const nodes = new Set();
|
|
26
|
+
const edges = [];
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (line.startsWith('graph ') || line.startsWith('classDef ') || line.startsWith('%%')) continue;
|
|
29
|
+
// Extract node names from edges like "A --> B" or "A -.text.- B"
|
|
30
|
+
const edgeMatch = line.match(/^(\S+?)(?:\[[^\]]*\])?\s*(?:-->|-\.[^.]*\.-)\s*(\S+?)(?:\[[^\]]*\])?$/);
|
|
31
|
+
if (edgeMatch) {
|
|
32
|
+
nodes.add(edgeMatch[1]);
|
|
33
|
+
nodes.add(edgeMatch[2]);
|
|
34
|
+
edges.push({ from: edgeMatch[1], to: edgeMatch[2] });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { valid: true, nodes: [...nodes], edges };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildZoomOutFixture({ targetName, l1Files, l2Modules, edgeCollapseNote = false }) {
|
|
41
|
+
const lines = ['graph LR', ` ${targetName}[src/auth/login.ts]:::stuck`];
|
|
42
|
+
for (let i = 0; i < l1Files; i++) {
|
|
43
|
+
lines.push(` sibling${i}[siblingFile${i}] -.same-dir.- ${targetName}`);
|
|
44
|
+
}
|
|
45
|
+
for (let i = 0; i < l2Modules; i++) {
|
|
46
|
+
lines.push(` caller${i}[callerModule${i}] --> ${targetName}`);
|
|
47
|
+
}
|
|
48
|
+
if (edgeCollapseNote) {
|
|
49
|
+
lines.push(' %% showing top 8 by import-frequency (>8 collapsed)');
|
|
50
|
+
}
|
|
51
|
+
lines.push(' classDef stuck fill:#ff6b6b');
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('zoom-out Mermaid output', () => {
|
|
56
|
+
test('valid output parses', () => {
|
|
57
|
+
const fixture = buildZoomOutFixture({ targetName: 'target', l1Files: 3, l2Modules: 4 });
|
|
58
|
+
const parsed = parseMermaid(fixture);
|
|
59
|
+
assert.strictEqual(parsed.valid, true);
|
|
60
|
+
assert.ok(parsed.nodes.includes('target'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('target has classDef stuck', () => {
|
|
64
|
+
const fixture = buildZoomOutFixture({ targetName: 'target', l1Files: 1, l2Modules: 1 });
|
|
65
|
+
assert.ok(fixture.includes(':::stuck'));
|
|
66
|
+
assert.ok(fixture.includes('classDef stuck'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('respects L1 cap (8 max)', () => {
|
|
70
|
+
const fixture = buildZoomOutFixture({ targetName: 'target', l1Files: 8, l2Modules: 0 });
|
|
71
|
+
const parsed = parseMermaid(fixture);
|
|
72
|
+
const siblingCount = parsed.nodes.filter((n) => n.startsWith('sibling')).length;
|
|
73
|
+
assert.ok(siblingCount <= 8, `expected <=8 siblings, got ${siblingCount}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('respects L2 cap (8 max)', () => {
|
|
77
|
+
const fixture = buildZoomOutFixture({ targetName: 'target', l1Files: 0, l2Modules: 8 });
|
|
78
|
+
const parsed = parseMermaid(fixture);
|
|
79
|
+
const callerCount = parsed.nodes.filter((n) => n.startsWith('caller')).length;
|
|
80
|
+
assert.ok(callerCount <= 8, `expected <=8 callers, got ${callerCount}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('total node cap: target + L1 (8) + L2 (8) = 17 max', () => {
|
|
84
|
+
const fixture = buildZoomOutFixture({ targetName: 'target', l1Files: 8, l2Modules: 8 });
|
|
85
|
+
const parsed = parseMermaid(fixture);
|
|
86
|
+
assert.ok(parsed.nodes.length <= 17, `expected <=17 nodes, got ${parsed.nodes.length}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('edge-collapse note included when caps hit', () => {
|
|
90
|
+
const fixture = buildZoomOutFixture({
|
|
91
|
+
targetName: 'target',
|
|
92
|
+
l1Files: 8,
|
|
93
|
+
l2Modules: 8,
|
|
94
|
+
edgeCollapseNote: true,
|
|
95
|
+
});
|
|
96
|
+
assert.ok(fixture.includes('showing top 8'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('rejects output without graph directive', () => {
|
|
100
|
+
const fixture = `target[src/auth/login.ts]
|
|
101
|
+
caller --> target`;
|
|
102
|
+
const parsed = parseMermaid(fixture);
|
|
103
|
+
assert.strictEqual(parsed.valid, false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('agent.stuck signal name validity', () => {
|
|
108
|
+
test('agent.stuck conforms to signal naming pattern', () => {
|
|
109
|
+
const SIGNAL_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
|
|
110
|
+
assert.ok(SIGNAL_PATTERN.test('agent.stuck'));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -8,10 +8,22 @@
|
|
|
8
8
|
* Antigravity project context: AGENTS.md (+ CLAUDE.md fallback)
|
|
9
9
|
* Antigravity skills dir: .agents/skills/
|
|
10
10
|
* Antigravity skill format: .agents/skills/{name}/SKILL.md
|
|
11
|
+
*
|
|
12
|
+
* MODEL TIER MAPPING (v2.15+):
|
|
13
|
+
* Skill frontmatter `model: opus|sonnet|haiku` (Anthropic naming) is
|
|
14
|
+
* translated to Gemini provider-correct model names. Antigravity supports
|
|
15
|
+
* both Gemini and Claude — defaulting to Gemini family since it is the
|
|
16
|
+
* native model. Unknown tier values pass through.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { BRANDING_FOOTER } from '../transforms/branding.js';
|
|
14
20
|
|
|
21
|
+
const MODEL_MAP = {
|
|
22
|
+
opus: 'gemini-3-pro',
|
|
23
|
+
sonnet: 'gemini-3-flash',
|
|
24
|
+
haiku: 'gemini-3-flash-lite',
|
|
25
|
+
};
|
|
26
|
+
|
|
15
27
|
const TOOL_MAP = {
|
|
16
28
|
Read: 'read the file',
|
|
17
29
|
Write: 'write/create the file',
|
|
@@ -46,7 +58,11 @@ export default {
|
|
|
46
58
|
|
|
47
59
|
generateHeader(skill) {
|
|
48
60
|
const desc = (skill.description || '').replace(/"/g, '\\"');
|
|
49
|
-
|
|
61
|
+
const lines = ['---', `name: rune-${skill.name}`, `description: "${desc}"`];
|
|
62
|
+
const translatedModel = skill.model ? MODEL_MAP[skill.model] || skill.model : null;
|
|
63
|
+
if (translatedModel) lines.push(`model: ${translatedModel}`);
|
|
64
|
+
lines.push('---', '', '');
|
|
65
|
+
return lines.join('\n');
|
|
50
66
|
},
|
|
51
67
|
|
|
52
68
|
generateFooter() {
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Claude Code Adapter (Passthrough)
|
|
3
3
|
*
|
|
4
4
|
* No transformation needed — source IS the output.
|
|
5
|
+
*
|
|
6
|
+
* MODEL TIER MAPPING (v2.15+):
|
|
7
|
+
* No-op. Claude Code's Anthropic backend understands `model: opus|sonnet|haiku`
|
|
8
|
+
* natively, so no translation is required (unlike codex/antigravity/opencode).
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
export default {
|
|
@@ -8,10 +8,21 @@
|
|
|
8
8
|
* Codex project context: AGENTS.md (equivalent to CLAUDE.md)
|
|
9
9
|
* Codex skills dir: .codex/skills/
|
|
10
10
|
* Codex skill format: .codex/skills/{name}/SKILL.md
|
|
11
|
+
*
|
|
12
|
+
* MODEL TIER MAPPING (v2.15+):
|
|
13
|
+
* Skill frontmatter `model: opus|sonnet|haiku` (Anthropic naming) is
|
|
14
|
+
* translated to Codex/OpenAI provider-correct model names so the field
|
|
15
|
+
* is meaningful in the compiled output. Unknown tier values pass through.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { BRANDING_FOOTER } from '../transforms/branding.js';
|
|
14
19
|
|
|
20
|
+
const MODEL_MAP = {
|
|
21
|
+
opus: 'gpt-5-pro',
|
|
22
|
+
sonnet: 'gpt-5',
|
|
23
|
+
haiku: 'gpt-5-mini',
|
|
24
|
+
};
|
|
25
|
+
|
|
15
26
|
const TOOL_MAP = {
|
|
16
27
|
Read: 'read the file',
|
|
17
28
|
Write: 'write/create the file',
|
|
@@ -47,7 +58,11 @@ export default {
|
|
|
47
58
|
|
|
48
59
|
generateHeader(skill) {
|
|
49
60
|
const desc = (skill.description || '').replace(/"/g, '\\"');
|
|
50
|
-
|
|
61
|
+
const lines = ['---', `name: rune-${skill.name}`, `description: "${desc}"`];
|
|
62
|
+
const translatedModel = skill.model ? MODEL_MAP[skill.model] || skill.model : null;
|
|
63
|
+
if (translatedModel) lines.push(`model: ${translatedModel}`);
|
|
64
|
+
lines.push('---', '', '');
|
|
65
|
+
return lines.join('\n');
|
|
51
66
|
},
|
|
52
67
|
|
|
53
68
|
generateFooter() {
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Emits .mdc rule files for .cursor/rules/ directory.
|
|
5
5
|
* Uses @file references for cross-skill mesh.
|
|
6
|
+
*
|
|
7
|
+
* MODEL TIER MAPPING (v2.15+):
|
|
8
|
+
* No-op. Cursor's Anthropic API integration understands `model: opus|sonnet|haiku`
|
|
9
|
+
* natively. No translation required.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { BRANDING_FOOTER } from '../transforms/branding.js';
|
|
@@ -3,10 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* For unknown or future platforms. Emits to .ai/rules/ directory.
|
|
5
5
|
* Uses the most portable format possible.
|
|
6
|
+
*
|
|
7
|
+
* MODEL TIER MAPPING (v2.15+):
|
|
8
|
+
* Provider-agnostic — emits semantic tier hints in the markdown header.
|
|
9
|
+
* Skill frontmatter `model: opus|sonnet|haiku` is translated to
|
|
10
|
+
* `tier:heavy|mid|light`. The consuming platform resolves the tier.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { BRANDING_FOOTER } from '../transforms/branding.js';
|
|
9
14
|
|
|
15
|
+
const MODEL_MAP = {
|
|
16
|
+
opus: 'tier:heavy',
|
|
17
|
+
sonnet: 'tier:mid',
|
|
18
|
+
haiku: 'tier:light',
|
|
19
|
+
};
|
|
20
|
+
|
|
10
21
|
const TOOL_MAP = {
|
|
11
22
|
Read: 'read the file',
|
|
12
23
|
Write: 'write/create the file',
|
|
@@ -37,7 +48,9 @@ export default {
|
|
|
37
48
|
},
|
|
38
49
|
|
|
39
50
|
generateHeader(skill) {
|
|
40
|
-
|
|
51
|
+
const translatedModel = skill.model ? MODEL_MAP[skill.model] || skill.model : null;
|
|
52
|
+
const modelSuffix = translatedModel ? ` | model: ${translatedModel}` : '';
|
|
53
|
+
return `# rune-${skill.name}\n\n> Rune ${skill.layer} Skill | ${skill.group}${modelSuffix}\n\n`;
|
|
41
54
|
},
|
|
42
55
|
|
|
43
56
|
generateFooter() {
|