@rune-kit/rune 2.13.0 → 2.14.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.
Files changed (41) hide show
  1. package/README.md +23 -12
  2. package/compiler/__tests__/adr-scoring.test.js +91 -0
  3. package/compiler/__tests__/context-md-format.test.js +180 -0
  4. package/compiler/__tests__/context-pack-smell-tests.test.js +215 -0
  5. package/compiler/__tests__/diversity-score.test.js +117 -0
  6. package/compiler/__tests__/improve-architecture.test.js +171 -0
  7. package/compiler/__tests__/openclaw-adapter.test.js +11 -6
  8. package/compiler/__tests__/out-of-scope-format.test.js +303 -0
  9. package/compiler/__tests__/zoom-out-output.test.js +112 -0
  10. package/package.json +2 -2
  11. package/skills/audit/SKILL.md +1 -0
  12. package/skills/ba/SKILL.md +241 -82
  13. package/skills/ba/references/context-md-format.md +136 -0
  14. package/skills/ba/references/explore-first.md +107 -0
  15. package/skills/ba/references/out-of-scope-format.md +152 -0
  16. package/skills/brainstorm/SKILL.md +77 -1
  17. package/skills/brainstorm/references/design-it-twice.md +155 -0
  18. package/skills/context-pack/SKILL.md +68 -25
  19. package/skills/context-pack/evals.md +122 -0
  20. package/skills/context-pack/references/brief-template.md +121 -0
  21. package/skills/context-pack/references/durability-rules.md +109 -0
  22. package/skills/cook/SKILL.md +4 -4
  23. package/skills/debug/SKILL.md +2 -1
  24. package/skills/fix/SKILL.md +2 -1
  25. package/skills/improve-architecture/SKILL.md +275 -0
  26. package/skills/improve-architecture/evals.md +145 -0
  27. package/skills/improve-architecture/references/deepening.md +87 -0
  28. package/skills/improve-architecture/references/interface-design.md +68 -0
  29. package/skills/improve-architecture/references/language.md +81 -0
  30. package/skills/improve-architecture/references/scoring.md +122 -0
  31. package/skills/journal/SKILL.md +33 -6
  32. package/skills/journal/references/adr-criteria.md +109 -0
  33. package/skills/review/SKILL.md +1 -0
  34. package/skills/review-intake/SKILL.md +25 -2
  35. package/skills/scout/SKILL.md +33 -1
  36. package/skills/surgeon/SKILL.md +16 -1
  37. package/skills/test/SKILL.md +48 -1
  38. package/skills/test/evals.md +137 -0
  39. package/skills/test/references/mocking-policy.md +121 -0
  40. package/skills/test/references/test-quality.md +124 -0
  41. package/skills/test/references/vertical-tdd.md +110 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Diversity score for Design-It-Twice mode.
3
+ *
4
+ * Computes pairwise Jaccard similarity over feature vectors of N designs.
5
+ * Returns 1 - mean(similarity).
6
+ *
7
+ * Used by brainstorm v0.6 Step 3.5 to gate re-spawning when designs are too similar.
8
+ */
9
+
10
+ import assert from 'node:assert';
11
+ import { describe, test } from 'node:test';
12
+
13
+ function jaccard(a, b) {
14
+ const setA = new Set(a);
15
+ const setB = new Set(b);
16
+ const intersect = new Set([...setA].filter((x) => setB.has(x)));
17
+ const union = new Set([...setA, ...setB]);
18
+ if (union.size === 0) return 1;
19
+ return intersect.size / union.size;
20
+ }
21
+
22
+ function diversityScore(designs) {
23
+ if (designs.length < 2) return 1;
24
+ const pairs = [];
25
+ for (let i = 0; i < designs.length; i++) {
26
+ for (let j = i + 1; j < designs.length; j++) {
27
+ pairs.push(jaccard(designs[i], designs[j]));
28
+ }
29
+ }
30
+ const mean = pairs.reduce((a, b) => a + b, 0) / pairs.length;
31
+ return 1 - mean;
32
+ }
33
+
34
+ // Encoding: a feature vector is a list of feature tokens.
35
+ // Examples:
36
+ // ["methods:1", "returns:1", "adapters:0", "deps:1", "paradigm:minimal", "async:no", "stream:no"]
37
+ // ["methods:8", "returns:5", "adapters:3", "deps:5", "paradigm:extensible", "async:yes", "stream:yes"]
38
+
39
+ describe('diversity score', () => {
40
+ test('identical designs return diversity 0', () => {
41
+ const d1 = ['methods:3', 'paradigm:minimal'];
42
+ const d2 = ['methods:3', 'paradigm:minimal'];
43
+ assert.strictEqual(diversityScore([d1, d2]), 0);
44
+ });
45
+
46
+ test('disjoint designs return diversity 1', () => {
47
+ const d1 = ['methods:3', 'paradigm:minimal'];
48
+ const d2 = ['methods:8', 'paradigm:extensible'];
49
+ assert.strictEqual(diversityScore([d1, d2]), 1);
50
+ });
51
+
52
+ test('half-overlap returns diversity 0.5', () => {
53
+ const d1 = ['methods:3', 'paradigm:minimal'];
54
+ const d2 = ['methods:3', 'paradigm:extensible'];
55
+ // jaccard = 1/3 (intersection {methods:3}, union {methods:3, paradigm:minimal, paradigm:extensible})
56
+ // diversity = 1 - 1/3 = 0.6667
57
+ const diversity = diversityScore([d1, d2]);
58
+ assert.ok(Math.abs(diversity - 0.6667) < 0.001, `expected ~0.6667, got ${diversity}`);
59
+ });
60
+
61
+ test('three radically different designs pass 0.6 floor', () => {
62
+ const c1 = ['methods:2', 'returns:1', 'paradigm:minimal', 'adapters:0', 'async:no'];
63
+ const c2 = ['methods:8', 'returns:5', 'paradigm:extensible', 'adapters:0', 'async:yes'];
64
+ const c4 = ['methods:3', 'returns:2', 'paradigm:ports-adapters', 'adapters:2', 'async:no'];
65
+ const diversity = diversityScore([c1, c2, c4]);
66
+ assert.ok(diversity >= 0.6, `expected >=0.6, got ${diversity}`);
67
+ });
68
+
69
+ test('three near-identical designs fail 0.4 floor', () => {
70
+ const a = ['methods:3', 'returns:1', 'paradigm:minimal'];
71
+ const b = ['methods:3', 'returns:1', 'paradigm:minimal'];
72
+ const c = ['methods:3', 'returns:1', 'paradigm:minimal'];
73
+ const diversity = diversityScore([a, b, c]);
74
+ assert.ok(diversity < 0.4, `expected <0.4, got ${diversity}`);
75
+ });
76
+
77
+ test('marginal designs land in 0.4-0.59 zone (warn tier)', () => {
78
+ // 6/9 features shared between each pair → jaccard ≈ 0.50, diversity ≈ 0.50
79
+ const a = ['m:3', 'r:1', 'p:m', 'a:0', 'q:x', 'e:y'];
80
+ const b = ['m:3', 'r:1', 'p:m', 'a:0', 'q:x', 'e:z'];
81
+ const c = ['m:3', 'r:1', 'p:m', 'a:0', 'q:y', 'e:z'];
82
+ const diversity = diversityScore([a, b, c]);
83
+ assert.ok(diversity >= 0.3 && diversity < 0.6, `expected 0.3-0.59, got ${diversity}`);
84
+ });
85
+
86
+ test('single design returns diversity 1 (degenerate)', () => {
87
+ assert.strictEqual(diversityScore([['x']]), 1);
88
+ });
89
+
90
+ test('four designs (when C4 included) — diverse set passes', () => {
91
+ const c1 = ['methods:2', 'paradigm:minimal'];
92
+ const c2 = ['methods:8', 'paradigm:extensible'];
93
+ const c3 = ['methods:1', 'paradigm:default-light'];
94
+ const c4 = ['methods:3', 'paradigm:ports-adapters'];
95
+ const diversity = diversityScore([c1, c2, c3, c4]);
96
+ assert.ok(diversity >= 0.6, `expected >=0.6, got ${diversity}`);
97
+ });
98
+ });
99
+
100
+ describe('threshold gate', () => {
101
+ test('floor 0.4 — below triggers re-spawn', () => {
102
+ const FLOOR = 0.4;
103
+ const lowDiversity = 0.3;
104
+ assert.strictEqual(lowDiversity < FLOOR, true);
105
+ });
106
+
107
+ test('threshold 0.6 — proceed without warning', () => {
108
+ const THRESHOLD = 0.6;
109
+ const goodDiversity = 0.7;
110
+ assert.strictEqual(goodDiversity >= THRESHOLD, true);
111
+ });
112
+
113
+ test('warn zone 0.4-0.59 — surface to user', () => {
114
+ const value = 0.5;
115
+ assert.ok(value >= 0.4 && value < 0.6);
116
+ });
117
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * improve-architecture skill — structural and vocabulary tests.
3
+ *
4
+ * Validates:
5
+ * - SKILL.md parses, version 0.1.0, layer L2, model opus
6
+ * - Required reference files exist (language, deepening, interface-design, scoring)
7
+ * - evals.md exists with >=4 evals across categories
8
+ * - Vocabulary discipline: skill body + references do NOT use banned aliases
9
+ * - Proposal payload schema shape (sample YAML in SKILL.md parses)
10
+ */
11
+
12
+ import assert from 'node:assert';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { describe, test } from 'node:test';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { parseSkill } from '../parser.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const SKILL_DIR = path.resolve(__dirname, '../../skills/improve-architecture');
21
+
22
+ const REQUIRED_FILES = [
23
+ 'SKILL.md',
24
+ 'references/language.md',
25
+ 'references/deepening.md',
26
+ 'references/interface-design.md',
27
+ 'references/scoring.md',
28
+ 'evals.md',
29
+ ];
30
+
31
+ // --- Files exist ---
32
+
33
+ describe('improve-architecture file presence', () => {
34
+ for (const f of REQUIRED_FILES) {
35
+ test(`${f} exists`, () => {
36
+ const p = path.join(SKILL_DIR, f);
37
+ assert.ok(existsSync(p), `missing: ${f}`);
38
+ });
39
+ }
40
+ });
41
+
42
+ // --- SKILL.md parses correctly ---
43
+
44
+ test('SKILL.md frontmatter — name, version, layer, model', () => {
45
+ const skillPath = path.join(SKILL_DIR, 'SKILL.md');
46
+ const parsed = parseSkill(readFileSync(skillPath, 'utf-8'));
47
+ assert.strictEqual(parsed.frontmatter.name, 'improve-architecture');
48
+ assert.strictEqual(parsed.frontmatter.metadata.version, '0.1.0');
49
+ assert.strictEqual(parsed.frontmatter.metadata.layer, 'L2');
50
+ assert.strictEqual(parsed.frontmatter.metadata.model, 'opus');
51
+ });
52
+
53
+ test('SKILL.md emit includes architecture.shallow.flagged + architecture.deletion.passed', () => {
54
+ const skillPath = path.join(SKILL_DIR, 'SKILL.md');
55
+ const content = readFileSync(skillPath, 'utf-8');
56
+ assert.ok(/emit:.*architecture\.shallow\.flagged/.test(content), 'emits architecture.shallow.flagged');
57
+ assert.ok(/emit:.*architecture\.deletion\.passed/.test(content), 'emits architecture.deletion.passed');
58
+ });
59
+
60
+ // --- Required reference content ---
61
+
62
+ test('language.md defines all 8 controlled terms', () => {
63
+ const content = readFileSync(path.join(SKILL_DIR, 'references/language.md'), 'utf-8');
64
+ const required = ['Module', 'Interface', 'Implementation', 'Depth', 'Seam', 'Adapter', 'Leverage', 'Locality'];
65
+ for (const term of required) {
66
+ // Each term should appear as a heading line `### Term`
67
+ assert.ok(new RegExp(`^### ${term}\\b`, 'm').test(content), `language.md missing heading for "${term}"`);
68
+ }
69
+ });
70
+
71
+ test('deepening.md lists all 4 dependency categories', () => {
72
+ const content = readFileSync(path.join(SKILL_DIR, 'references/deepening.md'), 'utf-8');
73
+ for (const cat of ['In-process', 'Local-substitutable', 'Remote-owned', 'True-external']) {
74
+ assert.ok(content.includes(cat), `deepening.md missing dependency category "${cat}"`);
75
+ }
76
+ });
77
+
78
+ test('scoring.md provides numeric rubric for depth/leverage/locality', () => {
79
+ const content = readFileSync(path.join(SKILL_DIR, 'references/scoring.md'), 'utf-8');
80
+ for (const metric of ['## Depth (1–5)', '## Leverage (1–5)', '## Locality (1–5)']) {
81
+ assert.ok(content.includes(metric), `scoring.md missing section "${metric}"`);
82
+ }
83
+ // Deletion test verdict enum
84
+ for (const verdict of ['vanish', 'concentrate', 'redistribute']) {
85
+ assert.ok(content.includes(verdict), `scoring.md missing deletion-test verdict "${verdict}"`);
86
+ }
87
+ });
88
+
89
+ // --- Eval coverage ---
90
+
91
+ test('evals.md has >=4 evals across categories', () => {
92
+ const content = readFileSync(path.join(SKILL_DIR, 'evals.md'), 'utf-8');
93
+ const evalMatches = content.match(/^## Eval: E\d+/gm) || [];
94
+ assert.ok(evalMatches.length >= 4, `expected >=4 evals, found ${evalMatches.length}`);
95
+
96
+ for (const cat of ['happy-path', 'edge-case', 'adversarial', 'jailbreak']) {
97
+ assert.ok(content.includes(cat), `evals.md missing category "${cat}"`);
98
+ }
99
+ });
100
+
101
+ // --- Vocabulary discipline: banned aliases in narrative prose ---
102
+
103
+ describe('vocabulary discipline', () => {
104
+ // Skip narrative scan inside reference files that explicitly enumerate banned terms
105
+ // (language.md lists "Avoid:" rules — those mentions are intentional)
106
+ const SKIPPED_FILES = ['references/language.md'];
107
+
108
+ // Banned alias terms in skill narrative.
109
+ // We allow them inside fenced code blocks AND inside lines starting with "Avoid:" or "Banned"
110
+ // to permit legitimate doctrine prose ("don't say boundary").
111
+ const BANNED_ALIASES = ['boundary', 'component', 'service'];
112
+
113
+ for (const f of REQUIRED_FILES) {
114
+ if (SKIPPED_FILES.includes(f) || f === 'evals.md') continue;
115
+ test(`${f} narrative has no banned aliases`, () => {
116
+ const content = readFileSync(path.join(SKILL_DIR, f), 'utf-8');
117
+ const lines = content.split('\n');
118
+ let inFence = false;
119
+ const violations = [];
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const line = lines[i];
122
+ if (line.trim().startsWith('```')) {
123
+ inFence = !inFence;
124
+ continue;
125
+ }
126
+ if (inFence) continue;
127
+ // Skip lines that explicitly call out banned terms
128
+ const trimmed = line.trim();
129
+ if (
130
+ trimmed.startsWith('*Avoid*') ||
131
+ trimmed.startsWith('Avoid:') ||
132
+ trimmed.startsWith('Banned') ||
133
+ trimmed.startsWith('- **') || // banned-framings list items
134
+ /["“”'].*("|”|')\s*$/.test(trimmed) || // quoted phrases like "service"
135
+ line.includes('banned ') // doctrine sentences mentioning banned X
136
+ ) {
137
+ continue;
138
+ }
139
+ for (const bad of BANNED_ALIASES) {
140
+ // Match whole word, not substring (so "microservice" wouldn't trigger "service" only when it's a standalone word)
141
+ if (new RegExp(`\\b${bad}\\b`, 'i').test(line) && !line.includes(`"${bad}"`)) {
142
+ violations.push(`${f}:${i + 1}: ${line.trim()}`);
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ assert.deepStrictEqual(violations, [], `vocabulary violations in ${f}:\n${violations.join('\n')}`);
148
+ });
149
+ }
150
+ });
151
+
152
+ // --- Proposal payload sample is parseable ---
153
+
154
+ test('SKILL.md sample proposal payload is well-formed YAML-ish', () => {
155
+ const content = readFileSync(path.join(SKILL_DIR, 'SKILL.md'), 'utf-8');
156
+ // Find the architecture.proposal block
157
+ const match = content.match(/architecture\.proposal:[\s\S]*?(?=\n```|\n\n##)/);
158
+ assert.ok(match, 'SKILL.md missing architecture.proposal sample');
159
+ const block = match[0];
160
+ // Must include required fields
161
+ for (const field of [
162
+ 'module_path:',
163
+ 'current:',
164
+ 'target:',
165
+ 'dependency_category:',
166
+ 'suggested_seam:',
167
+ 'adapters_planned:',
168
+ ]) {
169
+ assert.ok(block.includes(field), `proposal payload missing field "${field}"`);
170
+ }
171
+ });
@@ -115,10 +115,9 @@ test('generateManifest defaults version when missing', () => {
115
115
  });
116
116
 
117
117
  test('generateManifest declares artifact convention for OpenClaw skills', () => {
118
- const manifest = openclaw.generateManifest(
119
- [{ name: 'cook', layer: 'L1', group: 'orchestrator' }],
120
- { version: '1.0.0' },
121
- );
118
+ const manifest = openclaw.generateManifest([{ name: 'cook', layer: 'L1', group: 'orchestrator' }], {
119
+ version: '1.0.0',
120
+ });
122
121
  assert.ok(manifest.artifactConvention, 'artifactConvention field exists');
123
122
  assert.ok(Array.isArray(manifest.artifactConvention.outputDirPriority), 'outputDirPriority is array');
124
123
  assert.ok(manifest.artifactConvention.outputDirPriority.length >= 4, 'at least 4 fallback tiers');
@@ -129,12 +128,18 @@ test('generateManifest declares artifact convention for OpenClaw skills', () =>
129
128
  assert.ok(manifest.artifactConvention.outputContract, 'outputContract documented');
130
129
  assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[0], 'success');
131
130
  assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[4], 'timeout with partial results (accept)');
132
- assert.strictEqual(manifest.artifactConvention.outputContract.exitCodes[124], 'timeout with zero results (retry or abort)');
131
+ assert.strictEqual(
132
+ manifest.artifactConvention.outputContract.exitCodes[124],
133
+ 'timeout with zero results (retry or abort)',
134
+ );
133
135
  });
134
136
 
135
137
  test('generateManifest description scales with skill count', () => {
136
138
  const fewSkills = openclaw.generateManifest(
137
- [{ name: 'cook', layer: 'L1' }, { name: 'plan', layer: 'L2' }],
139
+ [
140
+ { name: 'cook', layer: 'L1' },
141
+ { name: 'plan', layer: 'L2' },
142
+ ],
138
143
  { version: '1.0.0' },
139
144
  );
140
145
  assert.ok(fewSkills.description.includes('2-skill'), 'description reflects actual skill count');
@@ -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
+ });