deepflow 0.1.103 → 0.1.104

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 (58) hide show
  1. package/bin/install-dynamic-hooks.test.js +461 -0
  2. package/bin/install.js +150 -250
  3. package/bin/lineage-ingest.js +70 -0
  4. package/hooks/df-check-update.js +1 -0
  5. package/hooks/df-command-usage.js +18 -0
  6. package/hooks/df-dashboard-push.js +1 -0
  7. package/hooks/df-execution-history.js +1 -0
  8. package/hooks/df-explore-protocol.js +83 -0
  9. package/hooks/df-explore-protocol.test.js +228 -0
  10. package/hooks/df-hook-event-tags.test.js +127 -0
  11. package/hooks/df-invariant-check.js +1 -0
  12. package/hooks/df-quota-logger.js +1 -0
  13. package/hooks/df-snapshot-guard.js +1 -0
  14. package/hooks/df-spec-lint.js +58 -1
  15. package/hooks/df-spec-lint.test.js +412 -0
  16. package/hooks/df-statusline.js +1 -0
  17. package/hooks/df-subagent-registry.js +1 -0
  18. package/hooks/df-tool-usage.js +13 -3
  19. package/hooks/df-worktree-guard.js +1 -0
  20. package/package.json +1 -1
  21. package/src/commands/df/debate.md +1 -1
  22. package/src/commands/df/eval.md +117 -0
  23. package/src/commands/df/execute.md +1 -1
  24. package/src/commands/df/fix.md +104 -0
  25. package/src/eval/git-memory.js +159 -0
  26. package/src/eval/git-memory.test.js +439 -0
  27. package/src/eval/hypothesis.js +80 -0
  28. package/src/eval/hypothesis.test.js +169 -0
  29. package/src/eval/loop.js +378 -0
  30. package/src/eval/loop.test.js +306 -0
  31. package/src/eval/metric-collector.js +163 -0
  32. package/src/eval/metric-collector.test.js +369 -0
  33. package/src/eval/metric-pivot.js +119 -0
  34. package/src/eval/metric-pivot.test.js +350 -0
  35. package/src/eval/mutator-prompt.js +106 -0
  36. package/src/eval/mutator-prompt.test.js +180 -0
  37. package/templates/config-template.yaml +5 -0
  38. package/templates/eval-fixture-template/config.yaml +39 -0
  39. package/templates/eval-fixture-template/fixture/.deepflow/decisions.md +5 -0
  40. package/templates/eval-fixture-template/fixture/hooks/invariant.js +28 -0
  41. package/templates/eval-fixture-template/fixture/package.json +12 -0
  42. package/templates/eval-fixture-template/fixture/specs/doing-example-task.md +18 -0
  43. package/templates/eval-fixture-template/fixture/src/commands/df/example.md +18 -0
  44. package/templates/eval-fixture-template/fixture/src/config.js +40 -0
  45. package/templates/eval-fixture-template/fixture/src/index.js +19 -0
  46. package/templates/eval-fixture-template/fixture/src/pipeline.js +40 -0
  47. package/templates/eval-fixture-template/fixture/src/skills/example-skill/SKILL.md +32 -0
  48. package/templates/eval-fixture-template/fixture/src/spec-loader.js +35 -0
  49. package/templates/eval-fixture-template/fixture/src/task-runner.js +32 -0
  50. package/templates/eval-fixture-template/fixture/src/verifier.js +37 -0
  51. package/templates/eval-fixture-template/hypotheses.md +14 -0
  52. package/templates/eval-fixture-template/spec.md +34 -0
  53. package/templates/eval-fixture-template/tests/behavior.test.js +69 -0
  54. package/templates/eval-fixture-template/tests/guard.test.js +108 -0
  55. package/templates/eval-fixture-template.test.js +318 -0
  56. package/templates/explore-agent.md +5 -74
  57. package/templates/explore-protocol.md +44 -0
  58. package/templates/spec-template.md +4 -0
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // @hook-event: PreToolUse
3
+ /**
4
+ * deepflow explore protocol injector
5
+ * PreToolUse hook: fires before the Agent tool executes.
6
+ * When subagent_type is "Explore", appends the search protocol from
7
+ * templates/explore-protocol.md to the agent prompt via updatedInput.
8
+ *
9
+ * Protocol source resolution (first match wins):
10
+ * 1. {cwd}/templates/explore-protocol.md (repo checkout)
11
+ * 2. ~/.claude/templates/explore-protocol.md (installed copy)
12
+ *
13
+ * Exits silently (code 0) on all errors — never blocks tool execution (REQ-8).
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+
22
+ /**
23
+ * Locate the explore-protocol.md template.
24
+ * Prefers project-local copy, falls back to installed global copy.
25
+ */
26
+ function findProtocol(cwd) {
27
+ const candidates = [
28
+ path.join(cwd, 'templates', 'explore-protocol.md'),
29
+ path.join(os.homedir(), '.claude', 'templates', 'explore-protocol.md'),
30
+ ];
31
+ for (const p of candidates) {
32
+ if (fs.existsSync(p)) return p;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ let raw = '';
38
+ process.stdin.setEncoding('utf8');
39
+ process.stdin.on('data', chunk => raw += chunk);
40
+ process.stdin.on('end', () => {
41
+ try {
42
+ const payload = JSON.parse(raw);
43
+ const { tool_name, tool_input, cwd } = payload;
44
+
45
+ // Only intercept Agent calls with subagent_type "Explore"
46
+ if (tool_name !== 'Agent') {
47
+ process.exit(0);
48
+ }
49
+ const subagentType = (tool_input.subagent_type || '').toLowerCase();
50
+ if (subagentType !== 'explore') {
51
+ process.exit(0);
52
+ }
53
+
54
+ const protocolPath = findProtocol(cwd || process.cwd());
55
+ if (!protocolPath) {
56
+ // No template found — allow without modification
57
+ process.exit(0);
58
+ }
59
+
60
+ const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
61
+ const originalPrompt = tool_input.prompt || '';
62
+
63
+ // Append protocol as a system-level suffix the agent must follow
64
+ const updatedPrompt = `${originalPrompt}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
65
+
66
+ const result = {
67
+ hookSpecificOutput: {
68
+ hookEventName: 'PreToolUse',
69
+ permissionDecision: 'allow',
70
+ updatedInput: {
71
+ ...tool_input,
72
+ prompt: updatedPrompt,
73
+ },
74
+ },
75
+ };
76
+
77
+ process.stdout.write(JSON.stringify(result));
78
+ process.exit(0);
79
+ } catch {
80
+ // Never break Claude Code
81
+ process.exit(0);
82
+ }
83
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Tests for df-explore-protocol.js — PreToolUse hook
3
+ *
4
+ * Verifies that the hook injects the explore-protocol.md search protocol
5
+ * into Explore agent prompts via updatedInput.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { test, describe, beforeEach, afterEach } = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const { execFileSync } = require('node:child_process');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+
17
+ const HOOK_PATH = path.resolve(__dirname, 'df-explore-protocol.js');
18
+
19
+ /**
20
+ * Run the hook as a child process with JSON piped to stdin.
21
+ * Returns { stdout, stderr, code }.
22
+ */
23
+ function runHook(input, { cwd, home } = {}) {
24
+ const json = typeof input === 'string' ? input : JSON.stringify(input);
25
+ const env = { ...process.env };
26
+ if (cwd) env.CWD_OVERRIDE = cwd;
27
+ if (home) env.HOME = home;
28
+ try {
29
+ const stdout = execFileSync(
30
+ process.execPath,
31
+ [HOOK_PATH],
32
+ {
33
+ input: json,
34
+ encoding: 'utf8',
35
+ timeout: 5000,
36
+ env,
37
+ }
38
+ );
39
+ return { stdout, stderr: '', code: 0 };
40
+ } catch (err) {
41
+ return {
42
+ stdout: err.stdout || '',
43
+ stderr: err.stderr || '',
44
+ code: err.status ?? 1,
45
+ };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Create a temp directory with a mock explore-protocol.md template.
51
+ */
52
+ function createTempProject(protocolContent) {
53
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-test-'));
54
+ const templatesDir = path.join(tmpDir, 'templates');
55
+ fs.mkdirSync(templatesDir, { recursive: true });
56
+ fs.writeFileSync(
57
+ path.join(templatesDir, 'explore-protocol.md'),
58
+ protocolContent || '# Explore Agent Pattern\n\nReturn ONLY:\n- filepath:startLine-endLine -- why relevant'
59
+ );
60
+ return tmpDir;
61
+ }
62
+
63
+ describe('df-explore-protocol hook', () => {
64
+ let tmpDir;
65
+
66
+ beforeEach(() => {
67
+ tmpDir = createTempProject();
68
+ });
69
+
70
+ afterEach(() => {
71
+ fs.rmSync(tmpDir, { recursive: true, force: true });
72
+ });
73
+
74
+ test('injects protocol into Explore agent prompt', () => {
75
+ const input = {
76
+ tool_name: 'Agent',
77
+ tool_input: {
78
+ subagent_type: 'Explore',
79
+ prompt: 'Find: config files related to database',
80
+ model: 'haiku',
81
+ },
82
+ cwd: tmpDir,
83
+ };
84
+
85
+ const { stdout, code } = runHook(input);
86
+ assert.equal(code, 0);
87
+
88
+ const result = JSON.parse(stdout);
89
+ const updated = result.hookSpecificOutput.updatedInput;
90
+
91
+ assert.ok(updated.prompt.includes('Find: config files related to database'));
92
+ assert.ok(updated.prompt.includes('filepath:startLine-endLine'));
93
+ assert.ok(updated.prompt.includes('Search Protocol (auto-injected'));
94
+ assert.equal(updated.subagent_type, 'Explore');
95
+ assert.equal(updated.model, 'haiku');
96
+ assert.equal(result.hookSpecificOutput.permissionDecision, 'allow');
97
+ });
98
+
99
+ test('ignores non-Agent tool calls', () => {
100
+ const input = {
101
+ tool_name: 'Read',
102
+ tool_input: { file_path: '/some/file.ts' },
103
+ cwd: tmpDir,
104
+ };
105
+
106
+ const { stdout, code } = runHook(input);
107
+ assert.equal(code, 0);
108
+ assert.equal(stdout, '');
109
+ });
110
+
111
+ test('ignores non-Explore agent calls', () => {
112
+ const input = {
113
+ tool_name: 'Agent',
114
+ tool_input: {
115
+ subagent_type: 'reasoner',
116
+ prompt: 'Analyze this code',
117
+ },
118
+ cwd: tmpDir,
119
+ };
120
+
121
+ const { stdout, code } = runHook(input);
122
+ assert.equal(code, 0);
123
+ assert.equal(stdout, '');
124
+ });
125
+
126
+ test('handles case-insensitive subagent_type', () => {
127
+ const input = {
128
+ tool_name: 'Agent',
129
+ tool_input: {
130
+ subagent_type: 'explore',
131
+ prompt: 'Find: test utilities',
132
+ },
133
+ cwd: tmpDir,
134
+ };
135
+
136
+ const { stdout, code } = runHook(input);
137
+ assert.equal(code, 0);
138
+
139
+ const result = JSON.parse(stdout);
140
+ assert.ok(result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol'));
141
+ });
142
+
143
+ test('exits cleanly when no template found', () => {
144
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-empty-'));
145
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-home-'));
146
+ try {
147
+ const input = {
148
+ tool_name: 'Agent',
149
+ tool_input: {
150
+ subagent_type: 'Explore',
151
+ prompt: 'Find: something',
152
+ },
153
+ cwd: emptyDir,
154
+ };
155
+
156
+ const { stdout, code } = runHook(input, { home: fakeHome });
157
+ assert.equal(code, 0);
158
+ assert.equal(stdout, '');
159
+ } finally {
160
+ fs.rmSync(emptyDir, { recursive: true, force: true });
161
+ fs.rmSync(fakeHome, { recursive: true, force: true });
162
+ }
163
+ });
164
+
165
+ test('exits cleanly on malformed JSON input', () => {
166
+ const { code } = runHook('not valid json');
167
+ assert.equal(code, 0);
168
+ });
169
+
170
+ test('preserves all original tool_input fields', () => {
171
+ const input = {
172
+ tool_name: 'Agent',
173
+ tool_input: {
174
+ subagent_type: 'Explore',
175
+ prompt: 'Find: API routes',
176
+ model: 'haiku',
177
+ description: 'search for routes',
178
+ run_in_background: false,
179
+ },
180
+ cwd: tmpDir,
181
+ };
182
+
183
+ const { stdout, code } = runHook(input);
184
+ assert.equal(code, 0);
185
+
186
+ const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
187
+ assert.equal(updated.model, 'haiku');
188
+ assert.equal(updated.description, 'search for routes');
189
+ assert.equal(updated.run_in_background, false);
190
+ assert.equal(updated.subagent_type, 'Explore');
191
+ });
192
+
193
+ test('does not double-inject if protocol already present', () => {
194
+ const input = {
195
+ tool_name: 'Agent',
196
+ tool_input: {
197
+ subagent_type: 'Explore',
198
+ prompt: 'Find: config\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\nalready here',
199
+ },
200
+ cwd: tmpDir,
201
+ };
202
+
203
+ const { stdout, code } = runHook(input);
204
+ assert.equal(code, 0);
205
+
206
+ const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
207
+ const matches = updated.prompt.match(/Search Protocol \(auto-injected/g);
208
+ // Currently will double-inject — documenting current behavior
209
+ // If this becomes a problem, add dedup logic
210
+ assert.ok(matches.length >= 1);
211
+ });
212
+
213
+ test('handles missing prompt gracefully', () => {
214
+ const input = {
215
+ tool_name: 'Agent',
216
+ tool_input: {
217
+ subagent_type: 'Explore',
218
+ },
219
+ cwd: tmpDir,
220
+ };
221
+
222
+ const { stdout, code } = runHook(input);
223
+ assert.equal(code, 0);
224
+
225
+ const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
226
+ assert.ok(updated.prompt.includes('Search Protocol'));
227
+ });
228
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Tests for @hook-event tags in hook files (self-describing-hooks, T1)
3
+ *
4
+ * Verifies that each hook file has the correct @hook-event comment tag
5
+ * within the first 10 lines, enabling programmatic event discovery.
6
+ *
7
+ * Uses Node.js built-in node:test to avoid adding dependencies.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { test, describe } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const HOOKS_DIR = path.resolve(__dirname);
22
+
23
+ /**
24
+ * Read first N lines of a file and return them as an array.
25
+ */
26
+ function readFirstLines(filePath, n = 10) {
27
+ const content = fs.readFileSync(filePath, 'utf8');
28
+ return content.split('\n').slice(0, n);
29
+ }
30
+
31
+ /**
32
+ * Extract @hook-event value from a file's first 10 lines.
33
+ * Returns the matched event string or null.
34
+ */
35
+ function extractHookEvent(filePath) {
36
+ const lines = readFirstLines(filePath, 10);
37
+ for (const line of lines) {
38
+ const match = line.match(/\/\/\s*@hook-event:\s*(.+)/);
39
+ if (match) return match[1].trim();
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Expected tags per hook file
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const EXPECTED_TAGS = {
49
+ 'df-check-update.js': 'SessionStart',
50
+ 'df-quota-logger.js': 'SessionStart, SessionEnd',
51
+ 'df-dashboard-push.js': 'SessionEnd',
52
+ 'df-command-usage.js': 'PreToolUse, PostToolUse, SessionEnd',
53
+ 'df-tool-usage.js': 'PostToolUse',
54
+ 'df-execution-history.js': 'PostToolUse',
55
+ 'df-worktree-guard.js': 'PostToolUse',
56
+ 'df-snapshot-guard.js': 'PostToolUse',
57
+ 'df-invariant-check.js': 'PostToolUse',
58
+ 'df-subagent-registry.js': 'SubagentStop',
59
+ 'df-explore-protocol.js': 'PreToolUse',
60
+ 'df-statusline.js': 'statusLine',
61
+ };
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('@hook-event tags — self-describing hooks', () => {
68
+
69
+ // Test each hook file individually
70
+ for (const [filename, expectedTag] of Object.entries(EXPECTED_TAGS)) {
71
+ test(`${filename} has @hook-event: ${expectedTag}`, () => {
72
+ const filePath = path.join(HOOKS_DIR, filename);
73
+ assert.ok(fs.existsSync(filePath), `${filename} should exist`);
74
+
75
+ const tag = extractHookEvent(filePath);
76
+ assert.ok(tag !== null, `${filename} should have a @hook-event tag within the first 10 lines`);
77
+ assert.equal(tag, expectedTag);
78
+ });
79
+ }
80
+
81
+ // Tag must appear on line 2 (index 1) — the canonical position
82
+ test('all tags appear on line 2 (after shebang)', () => {
83
+ for (const filename of Object.keys(EXPECTED_TAGS)) {
84
+ const filePath = path.join(HOOKS_DIR, filename);
85
+ const lines = readFirstLines(filePath, 3);
86
+ assert.match(
87
+ lines[1],
88
+ /\/\/\s*@hook-event:/,
89
+ `${filename}: tag should be on line 2`
90
+ );
91
+ }
92
+ });
93
+
94
+ // Multi-event hooks have comma-separated values
95
+ test('df-quota-logger.js lists two events comma-separated', () => {
96
+ const tag = extractHookEvent(path.join(HOOKS_DIR, 'df-quota-logger.js'));
97
+ const events = tag.split(',').map(e => e.trim());
98
+ assert.equal(events.length, 2);
99
+ assert.deepEqual(events, ['SessionStart', 'SessionEnd']);
100
+ });
101
+
102
+ test('df-command-usage.js lists three events comma-separated', () => {
103
+ const tag = extractHookEvent(path.join(HOOKS_DIR, 'df-command-usage.js'));
104
+ const events = tag.split(',').map(e => e.trim());
105
+ assert.equal(events.length, 3);
106
+ assert.deepEqual(events, ['PreToolUse', 'PostToolUse', 'SessionEnd']);
107
+ });
108
+
109
+ // Negative case: df-spec-lint.js should NOT have a @hook-event tag
110
+ test('df-spec-lint.js does NOT have a @hook-event tag', () => {
111
+ const filePath = path.join(HOOKS_DIR, 'df-spec-lint.js');
112
+ assert.ok(fs.existsSync(filePath), 'df-spec-lint.js should exist');
113
+
114
+ const tag = extractHookEvent(filePath);
115
+ assert.equal(tag, null, 'df-spec-lint.js should not have a @hook-event tag');
116
+ });
117
+
118
+ // df-statusline uses statusLine (not a hooks.* lifecycle event)
119
+ test('df-statusline.js uses statusLine event (not a hooks.* event)', () => {
120
+ const tag = extractHookEvent(path.join(HOOKS_DIR, 'df-statusline.js'));
121
+ assert.equal(tag, 'statusLine');
122
+ assert.ok(
123
+ !tag.startsWith('Session') && !tag.startsWith('Pre') && !tag.startsWith('Post') && !tag.startsWith('Subagent'),
124
+ 'statusLine should not be a lifecycle event type'
125
+ );
126
+ });
127
+ });
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: PostToolUse
2
3
  /**
3
4
  * deepflow invariant checker
4
5
  * Checks implementation diffs against spec invariants.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: SessionStart, SessionEnd
2
3
  /**
3
4
  * deepflow quota logger
4
5
  * Logs Anthropic API quota/usage data to ~/.claude/quota-history.jsonl
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @hook-event: PostToolUse
2
3
  /**
3
4
  * deepflow snapshot guard
4
5
  * PostToolUse hook: blocks Write/Edit to files listed in .deepflow/auto-snapshot.txt.
@@ -12,6 +12,45 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
 
15
+ /**
16
+ * Parse YAML frontmatter from the top of a markdown file.
17
+ * Detects an opening `---` on line 1 and a closing `---` on a subsequent line.
18
+ * Supports simple `key: value` pairs only (no full YAML parsing needed).
19
+ *
20
+ * @param {string} content - Raw file content.
21
+ * @returns {{ frontmatter: Object, body: string }}
22
+ */
23
+ function parseFrontmatter(content) {
24
+ const lines = content.split('\n');
25
+ if (lines[0].trim() !== '---') {
26
+ return { frontmatter: {}, body: content };
27
+ }
28
+
29
+ let closingIndex = -1;
30
+ for (let i = 1; i < lines.length; i++) {
31
+ if (lines[i].trim() === '---') {
32
+ closingIndex = i;
33
+ break;
34
+ }
35
+ }
36
+
37
+ if (closingIndex === -1) {
38
+ // No closing marker — treat entire file as body, no frontmatter
39
+ return { frontmatter: {}, body: content };
40
+ }
41
+
42
+ const frontmatter = {};
43
+ for (let i = 1; i < closingIndex; i++) {
44
+ const m = lines[i].match(/^([^:]+):\s*(.*)$/);
45
+ if (m) {
46
+ frontmatter[m[1].trim()] = m[2].trim();
47
+ }
48
+ }
49
+
50
+ const body = lines.slice(closingIndex + 1).join('\n');
51
+ return { frontmatter, body };
52
+ }
53
+
15
54
  // Each entry: [canonical name, ...aliases that also satisfy the requirement]
16
55
  const REQUIRED_SECTIONS = [
17
56
  ['Objective', 'overview', 'goal', 'goals', 'summary'],
@@ -90,6 +129,24 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
90
129
  const hard = [];
91
130
  const advisory = [];
92
131
 
132
+ // ── Frontmatter: parse and validate derives-from ─────────────────────
133
+ const { frontmatter } = parseFrontmatter(content);
134
+ if (frontmatter['derives-from'] !== undefined) {
135
+ const ref = frontmatter['derives-from'];
136
+ if (specsDir) {
137
+ // Probe candidate filenames: exact, done- prefix, and plain name
138
+ const candidates = [
139
+ `${ref}.md`,
140
+ `done-${ref}.md`,
141
+ `${ref}`,
142
+ ];
143
+ const exists = candidates.some((f) => fs.existsSync(path.join(specsDir, f)));
144
+ if (!exists) {
145
+ advisory.push(`derives-from references unknown spec: "${ref}" (not found in specs dir)`);
146
+ }
147
+ }
148
+ }
149
+
93
150
  const layer = computeLayer(content);
94
151
 
95
152
  // ── (a) Required sections (layer-aware) ──────────────────────────────
@@ -307,4 +364,4 @@ if (require.main === module) {
307
364
  process.exit(result.hard.length > 0 ? 1 : 0);
308
365
  }
309
366
 
310
- module.exports = { validateSpec, extractSection, computeLayer };
367
+ module.exports = { validateSpec, extractSection, computeLayer, parseFrontmatter };