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.
- package/bin/install-dynamic-hooks.test.js +461 -0
- package/bin/install.js +150 -250
- package/bin/lineage-ingest.js +70 -0
- package/hooks/df-check-update.js +1 -0
- package/hooks/df-command-usage.js +18 -0
- package/hooks/df-dashboard-push.js +1 -0
- package/hooks/df-execution-history.js +1 -0
- package/hooks/df-explore-protocol.js +83 -0
- package/hooks/df-explore-protocol.test.js +228 -0
- package/hooks/df-hook-event-tags.test.js +127 -0
- package/hooks/df-invariant-check.js +1 -0
- package/hooks/df-quota-logger.js +1 -0
- package/hooks/df-snapshot-guard.js +1 -0
- package/hooks/df-spec-lint.js +58 -1
- package/hooks/df-spec-lint.test.js +412 -0
- package/hooks/df-statusline.js +1 -0
- package/hooks/df-subagent-registry.js +1 -0
- package/hooks/df-tool-usage.js +13 -3
- package/hooks/df-worktree-guard.js +1 -0
- package/package.json +1 -1
- package/src/commands/df/debate.md +1 -1
- package/src/commands/df/eval.md +117 -0
- package/src/commands/df/execute.md +1 -1
- package/src/commands/df/fix.md +104 -0
- package/src/eval/git-memory.js +159 -0
- package/src/eval/git-memory.test.js +439 -0
- package/src/eval/hypothesis.js +80 -0
- package/src/eval/hypothesis.test.js +169 -0
- package/src/eval/loop.js +378 -0
- package/src/eval/loop.test.js +306 -0
- package/src/eval/metric-collector.js +163 -0
- package/src/eval/metric-collector.test.js +369 -0
- package/src/eval/metric-pivot.js +119 -0
- package/src/eval/metric-pivot.test.js +350 -0
- package/src/eval/mutator-prompt.js +106 -0
- package/src/eval/mutator-prompt.test.js +180 -0
- package/templates/config-template.yaml +5 -0
- package/templates/eval-fixture-template/config.yaml +39 -0
- package/templates/eval-fixture-template/fixture/.deepflow/decisions.md +5 -0
- package/templates/eval-fixture-template/fixture/hooks/invariant.js +28 -0
- package/templates/eval-fixture-template/fixture/package.json +12 -0
- package/templates/eval-fixture-template/fixture/specs/doing-example-task.md +18 -0
- package/templates/eval-fixture-template/fixture/src/commands/df/example.md +18 -0
- package/templates/eval-fixture-template/fixture/src/config.js +40 -0
- package/templates/eval-fixture-template/fixture/src/index.js +19 -0
- package/templates/eval-fixture-template/fixture/src/pipeline.js +40 -0
- package/templates/eval-fixture-template/fixture/src/skills/example-skill/SKILL.md +32 -0
- package/templates/eval-fixture-template/fixture/src/spec-loader.js +35 -0
- package/templates/eval-fixture-template/fixture/src/task-runner.js +32 -0
- package/templates/eval-fixture-template/fixture/src/verifier.js +37 -0
- package/templates/eval-fixture-template/hypotheses.md +14 -0
- package/templates/eval-fixture-template/spec.md +34 -0
- package/templates/eval-fixture-template/tests/behavior.test.js +69 -0
- package/templates/eval-fixture-template/tests/guard.test.js +108 -0
- package/templates/eval-fixture-template.test.js +318 -0
- package/templates/explore-agent.md +5 -74
- package/templates/explore-protocol.md +44 -0
- 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
|
+
});
|
package/hooks/df-quota-logger.js
CHANGED
package/hooks/df-spec-lint.js
CHANGED
|
@@ -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 };
|