deepflow 0.1.107 → 0.1.109
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.js +25 -7
- package/bin/install.test.js +113 -0
- package/bin/plan-consolidator.js +19 -1
- package/bin/plan-consolidator.test.js +150 -0
- package/bin/ratchet.js +11 -6
- package/bin/ratchet.test.js +172 -0
- package/bin/worktree-deps.js +127 -0
- package/hooks/ac-coverage.js +213 -0
- package/hooks/df-explore-protocol.js +227 -28
- package/hooks/df-explore-protocol.test.js +460 -81
- package/hooks/df-spec-lint.js +13 -2
- package/hooks/df-spec-lint.test.js +133 -0
- package/package.json +4 -1
- package/src/commands/df/execute.md +112 -2
- package/src/commands/df/plan.md +244 -16
- package/src/commands/df/verify.md +46 -8
- package/templates/config-template.yaml +1 -0
- package/templates/explore-protocol.md.bak +69 -0
- package/templates/plan-template.md +11 -0
- package/templates/spec-template.md +15 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for df-explore-protocol.js — PreToolUse hook
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Two-phase behavior coverage:
|
|
5
|
+
* Phase 1: inline regex extraction from source files in cwd
|
|
6
|
+
* Phase 2: inject symbol locations + static template into prompt
|
|
7
|
+
*
|
|
8
|
+
* All tests control Phase 1 by writing fixture source files to a tmpDir,
|
|
9
|
+
* then passing that tmpDir as cwd. No subprocess, no fake `claude` binary.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
'use strict';
|
|
@@ -15,6 +19,43 @@ const path = require('node:path');
|
|
|
15
19
|
const os = require('node:os');
|
|
16
20
|
|
|
17
21
|
const HOOK_PATH = path.resolve(__dirname, 'df-explore-protocol.js');
|
|
22
|
+
const PROTOCOL_CONTENT =
|
|
23
|
+
'# Explore Agent Pattern\n\nReturn ONLY:\n- filepath:startLine-endLine -- why relevant';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a temp project directory with an optional explore-protocol.md template
|
|
31
|
+
* and optional .deepflow/config.yaml.
|
|
32
|
+
*/
|
|
33
|
+
function createTempProject({ withTemplate = true, configYaml = null } = {}) {
|
|
34
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-test-'));
|
|
35
|
+
if (withTemplate) {
|
|
36
|
+
const templatesDir = path.join(tmpDir, 'templates');
|
|
37
|
+
fs.mkdirSync(templatesDir, { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(templatesDir, 'explore-protocol.md'), PROTOCOL_CONTENT);
|
|
39
|
+
}
|
|
40
|
+
if (configYaml) {
|
|
41
|
+
const dfDir = path.join(tmpDir, '.deepflow');
|
|
42
|
+
fs.mkdirSync(dfDir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(dfDir, 'config.yaml'), configYaml);
|
|
44
|
+
}
|
|
45
|
+
return tmpDir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write a fixture source file into tmpDir/src/ with the given content.
|
|
50
|
+
* Returns the absolute path of the written file.
|
|
51
|
+
*/
|
|
52
|
+
function writeFixtureFile(tmpDir, filename, content) {
|
|
53
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
54
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
55
|
+
const filepath = path.join(srcDir, filename);
|
|
56
|
+
fs.writeFileSync(filepath, content);
|
|
57
|
+
return filepath;
|
|
58
|
+
}
|
|
18
59
|
|
|
19
60
|
/**
|
|
20
61
|
* Run the hook as a child process with JSON piped to stdin.
|
|
@@ -23,19 +64,14 @@ const HOOK_PATH = path.resolve(__dirname, 'df-explore-protocol.js');
|
|
|
23
64
|
function runHook(input, { cwd, home } = {}) {
|
|
24
65
|
const json = typeof input === 'string' ? input : JSON.stringify(input);
|
|
25
66
|
const env = { ...process.env };
|
|
26
|
-
if (cwd) env.CWD_OVERRIDE = cwd;
|
|
27
67
|
if (home) env.HOME = home;
|
|
28
68
|
try {
|
|
29
|
-
const stdout = execFileSync(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
timeout: 5000,
|
|
36
|
-
env,
|
|
37
|
-
}
|
|
38
|
-
);
|
|
69
|
+
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
|
|
70
|
+
input: json,
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
timeout: 10000,
|
|
73
|
+
env,
|
|
74
|
+
});
|
|
39
75
|
return { stdout, stderr: '', code: 0 };
|
|
40
76
|
} catch (err) {
|
|
41
77
|
return {
|
|
@@ -46,19 +82,9 @@ function runHook(input, { cwd, home } = {}) {
|
|
|
46
82
|
}
|
|
47
83
|
}
|
|
48
84
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Test suite
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
62
88
|
|
|
63
89
|
describe('df-explore-protocol hook', () => {
|
|
64
90
|
let tmpDir;
|
|
@@ -71,65 +97,103 @@ describe('df-explore-protocol hook', () => {
|
|
|
71
97
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
72
98
|
});
|
|
73
99
|
|
|
74
|
-
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
// AC-1: Phase 1 uses inline regex extraction — no subprocess required
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
test('AC-1: Phase 1 uses inline regex extraction without requiring a claude binary', () => {
|
|
104
|
+
// Write a fixture file with a function whose name matches the query substring
|
|
105
|
+
writeFixtureFile(tmpDir, 'index.js', 'function databaseConfig() { return {}; }\n');
|
|
106
|
+
|
|
107
|
+
// Query is just the symbol name so it substring-matches the symbol "databaseConfig"
|
|
75
108
|
const input = {
|
|
76
109
|
tool_name: 'Agent',
|
|
77
|
-
tool_input: {
|
|
78
|
-
subagent_type: 'Explore',
|
|
79
|
-
prompt: 'Find: config files related to database',
|
|
80
|
-
model: 'haiku',
|
|
81
|
-
},
|
|
110
|
+
tool_input: { subagent_type: 'Explore', prompt: 'databaseConfig' },
|
|
82
111
|
cwd: tmpDir,
|
|
83
112
|
};
|
|
84
113
|
|
|
85
|
-
|
|
114
|
+
// Run without any PATH manipulation — no fake claude binary needed
|
|
115
|
+
const { code, stdout } = runHook(input);
|
|
86
116
|
assert.equal(code, 0);
|
|
87
117
|
|
|
118
|
+
// Phase 1 should have found the symbol; LSP block injected
|
|
88
119
|
const result = JSON.parse(stdout);
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
assert.equal(updated.subagent_type, 'Explore');
|
|
95
|
-
assert.equal(updated.model, 'haiku');
|
|
96
|
-
assert.equal(result.hookSpecificOutput.permissionDecision, 'allow');
|
|
120
|
+
const prompt = result.hookSpecificOutput.updatedInput.prompt;
|
|
121
|
+
assert.ok(
|
|
122
|
+
prompt.includes('[LSP Phase -- locations found]'),
|
|
123
|
+
'Expected [LSP Phase -- locations found] section from regex extraction'
|
|
124
|
+
);
|
|
97
125
|
});
|
|
98
126
|
|
|
99
|
-
|
|
127
|
+
// -------------------------------------------------------------------------
|
|
128
|
+
// AC-2: Phase 1 hit injects [LSP Phase -- locations found] section
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
test('AC-2: Phase 1 hit injects [LSP Phase -- locations found] section into prompt', () => {
|
|
131
|
+
// Write fixture files whose symbol names contain the query substring "connect"
|
|
132
|
+
writeFixtureFile(tmpDir, 'config.ts', 'export function dbConnect() { return {}; }\n');
|
|
133
|
+
writeFixtureFile(tmpDir, 'db.ts', 'export async function connectDB() {}\n');
|
|
134
|
+
|
|
135
|
+
// Query "connect" matches both symbols by substring
|
|
100
136
|
const input = {
|
|
101
|
-
tool_name: '
|
|
102
|
-
tool_input: {
|
|
137
|
+
tool_name: 'Agent',
|
|
138
|
+
tool_input: { subagent_type: 'Explore', prompt: 'connect' },
|
|
103
139
|
cwd: tmpDir,
|
|
104
140
|
};
|
|
105
141
|
|
|
106
142
|
const { stdout, code } = runHook(input);
|
|
107
143
|
assert.equal(code, 0);
|
|
108
|
-
|
|
144
|
+
|
|
145
|
+
const result = JSON.parse(stdout);
|
|
146
|
+
const prompt = result.hookSpecificOutput.updatedInput.prompt;
|
|
147
|
+
|
|
148
|
+
assert.ok(
|
|
149
|
+
prompt.includes('[LSP Phase -- locations found]'),
|
|
150
|
+
'Expected [LSP Phase -- locations found] section'
|
|
151
|
+
);
|
|
152
|
+
assert.ok(
|
|
153
|
+
prompt.includes('Search Protocol (auto-injected'),
|
|
154
|
+
'Expected Search Protocol section'
|
|
155
|
+
);
|
|
156
|
+
assert.ok(prompt.includes(PROTOCOL_CONTENT.slice(0, 30)), 'Expected protocol content');
|
|
109
157
|
});
|
|
110
158
|
|
|
111
|
-
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
// AC-3: Reader-phase entries in filepath:line format
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
test('AC-3: LSP locations are formatted as filepath:line -- symbolName (symbolKind)', () => {
|
|
163
|
+
// Write a fixture file whose symbol name contains "myFunc" (matches query substring)
|
|
164
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
165
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
166
|
+
const filepath = path.join(srcDir, 'utils.ts');
|
|
167
|
+
// Place the function at line 1
|
|
168
|
+
fs.writeFileSync(filepath, 'export function myFunc() {}\n');
|
|
169
|
+
|
|
170
|
+
// Query "myFunc" matches the symbol name by substring
|
|
112
171
|
const input = {
|
|
113
172
|
tool_name: 'Agent',
|
|
114
|
-
tool_input: {
|
|
115
|
-
subagent_type: 'reasoner',
|
|
116
|
-
prompt: 'Analyze this code',
|
|
117
|
-
},
|
|
173
|
+
tool_input: { subagent_type: 'Explore', prompt: 'myFunc' },
|
|
118
174
|
cwd: tmpDir,
|
|
119
175
|
};
|
|
120
176
|
|
|
121
177
|
const { stdout, code } = runHook(input);
|
|
122
178
|
assert.equal(code, 0);
|
|
123
|
-
|
|
179
|
+
|
|
180
|
+
const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
|
|
181
|
+
assert.ok(
|
|
182
|
+
prompt.includes(`${filepath}:1 -- myFunc (function)`),
|
|
183
|
+
`Expected filepath:line format, prompt: ${prompt}`
|
|
184
|
+
);
|
|
124
185
|
});
|
|
125
186
|
|
|
126
|
-
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// AC-5: No matching symbols → fallback to static template injection
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
test('AC-5: no matching symbols falls back to static template injection', () => {
|
|
191
|
+
// Write a fixture file whose symbol/path do NOT match the query "xyzRouteHandler"
|
|
192
|
+
writeFixtureFile(tmpDir, 'unrelated.js', 'function completelyDifferent() {}\n');
|
|
193
|
+
|
|
127
194
|
const input = {
|
|
128
195
|
tool_name: 'Agent',
|
|
129
|
-
tool_input: {
|
|
130
|
-
subagent_type: 'explore',
|
|
131
|
-
prompt: 'Find: test utilities',
|
|
132
|
-
},
|
|
196
|
+
tool_input: { subagent_type: 'Explore', prompt: 'xyzRouteHandler' },
|
|
133
197
|
cwd: tmpDir,
|
|
134
198
|
};
|
|
135
199
|
|
|
@@ -137,45 +201,245 @@ describe('df-explore-protocol hook', () => {
|
|
|
137
201
|
assert.equal(code, 0);
|
|
138
202
|
|
|
139
203
|
const result = JSON.parse(stdout);
|
|
140
|
-
|
|
204
|
+
const prompt = result.hookSpecificOutput.updatedInput.prompt;
|
|
205
|
+
|
|
206
|
+
// Should inject protocol but NOT the LSP phase block
|
|
207
|
+
assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Expected protocol injection');
|
|
208
|
+
assert.ok(
|
|
209
|
+
!prompt.includes('[LSP Phase -- locations found]'),
|
|
210
|
+
'Must NOT include LSP phase block when no symbols match'
|
|
211
|
+
);
|
|
212
|
+
assert.ok(prompt.includes('xyzRouteHandler'), 'Original prompt preserved');
|
|
141
213
|
});
|
|
142
214
|
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// AC-6: No template + no matching symbols → exit 0 with no output
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
test('AC-6: no template and no matching symbols exits 0 with no output', () => {
|
|
219
|
+
const emptyDir = createTempProject({ withTemplate: false });
|
|
145
220
|
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-home-'));
|
|
221
|
+
|
|
146
222
|
try {
|
|
147
223
|
const input = {
|
|
148
224
|
tool_name: 'Agent',
|
|
149
|
-
tool_input: {
|
|
150
|
-
subagent_type: 'Explore',
|
|
151
|
-
prompt: 'Find: something',
|
|
152
|
-
},
|
|
225
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
|
|
153
226
|
cwd: emptyDir,
|
|
154
227
|
};
|
|
155
228
|
|
|
156
229
|
const { stdout, code } = runHook(input, { home: fakeHome });
|
|
157
230
|
assert.equal(code, 0);
|
|
158
|
-
assert.equal(stdout, '');
|
|
231
|
+
assert.equal(stdout, '', 'Expected empty stdout when no template and no symbols match');
|
|
159
232
|
} finally {
|
|
160
233
|
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
161
234
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
162
235
|
}
|
|
163
236
|
});
|
|
164
237
|
|
|
165
|
-
|
|
166
|
-
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
// AC-7: Path filtering removes node_modules, .claude/worktrees, dist paths
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
test('AC-7: noise paths filtered out — node_modules, .claude/worktrees, dist, .git', () => {
|
|
242
|
+
// Write a good source file with a symbol name that IS the query
|
|
243
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
244
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
245
|
+
const goodFile = path.join(srcDir, 'main.ts');
|
|
246
|
+
fs.writeFileSync(goodFile, 'export function targetSymbol() {}\n');
|
|
247
|
+
|
|
248
|
+
// Write noise files that also declare targetSymbol but live in noise paths
|
|
249
|
+
const nodeModDir = path.join(tmpDir, 'node_modules', 'lib');
|
|
250
|
+
fs.mkdirSync(nodeModDir, { recursive: true });
|
|
251
|
+
fs.writeFileSync(path.join(nodeModDir, 'index.js'), 'function targetSymbol() {}\n');
|
|
252
|
+
|
|
253
|
+
const worktreeDir = path.join(tmpDir, '.claude', 'worktrees', 'branch');
|
|
254
|
+
fs.mkdirSync(worktreeDir, { recursive: true });
|
|
255
|
+
fs.writeFileSync(path.join(worktreeDir, 'file.ts'), 'function targetSymbol() {}\n');
|
|
256
|
+
|
|
257
|
+
const distDir = path.join(tmpDir, 'dist');
|
|
258
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
259
|
+
fs.writeFileSync(path.join(distDir, 'bundle.js'), 'function targetSymbol() {}\n');
|
|
260
|
+
|
|
261
|
+
const gitDir = path.join(tmpDir, '.git', 'hooks');
|
|
262
|
+
fs.mkdirSync(gitDir, { recursive: true });
|
|
263
|
+
fs.writeFileSync(path.join(gitDir, 'pre-commit'), 'function targetSymbol() {}\n');
|
|
264
|
+
|
|
265
|
+
const input = {
|
|
266
|
+
// Query "targetSymbol" substring-matches the symbol name in all files,
|
|
267
|
+
// but only the good file should survive the noise-path filter
|
|
268
|
+
tool_input: { subagent_type: 'Explore', prompt: 'targetSymbol' },
|
|
269
|
+
tool_name: 'Agent',
|
|
270
|
+
cwd: tmpDir,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const { stdout, code } = runHook(input);
|
|
274
|
+
assert.equal(code, 0);
|
|
275
|
+
|
|
276
|
+
const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
|
|
277
|
+
assert.ok(prompt.includes(goodFile), 'Good path should be present');
|
|
278
|
+
assert.ok(!prompt.includes('node_modules'), 'node_modules should be filtered');
|
|
279
|
+
assert.ok(!prompt.includes('.claude/worktrees'), '.claude/worktrees should be filtered');
|
|
280
|
+
assert.ok(!prompt.includes('/dist/'), 'dist should be filtered');
|
|
281
|
+
assert.ok(!prompt.includes('.git/hooks'), '.git should be filtered');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------------------
|
|
285
|
+
// AC-7 edge: all symbols in noise paths → falls back to static template (no LSP block)
|
|
286
|
+
// -------------------------------------------------------------------------
|
|
287
|
+
test('AC-7: all symbols filtered → falls back to static template (no LSP block)', () => {
|
|
288
|
+
// Write only a noise file that would match the query but lives in node_modules
|
|
289
|
+
const nodeModDir = path.join(tmpDir, 'node_modules', 'lib');
|
|
290
|
+
fs.mkdirSync(nodeModDir, { recursive: true });
|
|
291
|
+
fs.writeFileSync(path.join(nodeModDir, 'index.js'), 'function badNodeModules() {}\n');
|
|
292
|
+
|
|
293
|
+
const input = {
|
|
294
|
+
tool_name: 'Agent',
|
|
295
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: functions' },
|
|
296
|
+
cwd: tmpDir,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const { stdout, code } = runHook(input);
|
|
167
300
|
assert.equal(code, 0);
|
|
301
|
+
|
|
302
|
+
const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
|
|
303
|
+
assert.ok(
|
|
304
|
+
!prompt.includes('[LSP Phase -- locations found]'),
|
|
305
|
+
'Should not inject LSP block when all paths filtered'
|
|
306
|
+
);
|
|
307
|
+
assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Should still inject protocol');
|
|
168
308
|
});
|
|
169
309
|
|
|
170
|
-
|
|
310
|
+
// -------------------------------------------------------------------------
|
|
311
|
+
// AC-8: Deduplication guard prevents double-injection
|
|
312
|
+
// -------------------------------------------------------------------------
|
|
313
|
+
test('AC-8: dedup guard — skips injection if Search Protocol already present', () => {
|
|
171
314
|
const input = {
|
|
172
315
|
tool_name: 'Agent',
|
|
173
316
|
tool_input: {
|
|
174
317
|
subagent_type: 'Explore',
|
|
175
|
-
prompt:
|
|
318
|
+
prompt:
|
|
319
|
+
'Find: config\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\nalready here',
|
|
320
|
+
},
|
|
321
|
+
cwd: tmpDir,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const { stdout, code } = runHook(input);
|
|
325
|
+
assert.equal(code, 0);
|
|
326
|
+
// Dedup guard should fire — no output (hook returns without modification)
|
|
327
|
+
assert.equal(stdout, '', 'Expected no output when dedup guard fires');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('AC-8: dedup guard — skips injection if LSP Phase already present', () => {
|
|
331
|
+
const input = {
|
|
332
|
+
tool_name: 'Agent',
|
|
333
|
+
tool_input: {
|
|
334
|
+
subagent_type: 'Explore',
|
|
335
|
+
prompt: 'Find: config\n\n## [LSP Phase -- locations found]\n\n/some/file.ts:10 -- foo (Fn)',
|
|
336
|
+
},
|
|
337
|
+
cwd: tmpDir,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const { stdout, code } = runHook(input);
|
|
341
|
+
assert.equal(code, 0);
|
|
342
|
+
assert.equal(stdout, '', 'Expected no output when LSP Phase marker already present');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// -------------------------------------------------------------------------
|
|
346
|
+
// AC-9: No subprocess means timeout config has no effect — static fallback still works
|
|
347
|
+
// -------------------------------------------------------------------------
|
|
348
|
+
test('AC-9: config with explore_lsp_timeout_ms is ignored — regex extraction always runs inline', () => {
|
|
349
|
+
// Create project with a very short timeout config (no longer relevant, but must not crash)
|
|
350
|
+
const projectWithConfig = createTempProject({
|
|
351
|
+
configYaml: 'explore_lsp_timeout_ms: 100\n',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// No matching symbols in the project directory
|
|
355
|
+
const input = {
|
|
356
|
+
tool_name: 'Agent',
|
|
357
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: symbols' },
|
|
358
|
+
cwd: projectWithConfig,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const { stdout, code } = runHook(input);
|
|
362
|
+
assert.equal(code, 0);
|
|
363
|
+
|
|
364
|
+
// Should fall back to static template since no matching symbols
|
|
365
|
+
const result = JSON.parse(stdout);
|
|
366
|
+
const prompt = result.hookSpecificOutput.updatedInput.prompt;
|
|
367
|
+
assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Should inject static protocol');
|
|
368
|
+
assert.ok(
|
|
369
|
+
!prompt.includes('[LSP Phase -- locations found]'),
|
|
370
|
+
'Should NOT have LSP block (no matching symbols)'
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
fs.rmSync(projectWithConfig, { recursive: true, force: true });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// -------------------------------------------------------------------------
|
|
377
|
+
// AC-10: Exit 0 on malformed JSON input
|
|
378
|
+
// -------------------------------------------------------------------------
|
|
379
|
+
test('AC-10: exits 0 on malformed JSON stdin', () => {
|
|
380
|
+
const { code, stdout } = runHook('not valid json {{ }}');
|
|
381
|
+
assert.equal(code, 0);
|
|
382
|
+
assert.equal(stdout, '');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// AC-10: missing tool_input field
|
|
386
|
+
test('AC-10: exits 0 when tool_input is missing', () => {
|
|
387
|
+
const input = {
|
|
388
|
+
tool_name: 'Agent',
|
|
389
|
+
// tool_input deliberately omitted
|
|
390
|
+
cwd: tmpDir,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const { code } = runHook(input);
|
|
394
|
+
assert.equal(code, 0);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// AC-10: filesystem error (cwd that does not exist)
|
|
398
|
+
test('AC-10: exits 0 when cwd does not exist (filesystem error)', () => {
|
|
399
|
+
const input = {
|
|
400
|
+
tool_name: 'Agent',
|
|
401
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
|
|
402
|
+
cwd: '/nonexistent/path/that/does/not/exist',
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const { code } = runHook(input);
|
|
406
|
+
assert.equal(code, 0);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// AC-10: no source files in cwd → fallback to static protocol
|
|
410
|
+
test('AC-10: exits 0 and falls back to static protocol when cwd has no matching source files', () => {
|
|
411
|
+
const input = {
|
|
412
|
+
tool_name: 'Agent',
|
|
413
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
|
|
414
|
+
cwd: tmpDir,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const { code, stdout } = runHook(input);
|
|
418
|
+
assert.equal(code, 0);
|
|
419
|
+
// Should fall back to static protocol
|
|
420
|
+
const result = JSON.parse(stdout);
|
|
421
|
+
assert.ok(
|
|
422
|
+
result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol (auto-injected')
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// -------------------------------------------------------------------------
|
|
427
|
+
// AC-12: All original tool_input fields preserved in updatedInput
|
|
428
|
+
// -------------------------------------------------------------------------
|
|
429
|
+
test('AC-12: all original tool_input fields preserved after Phase 1 hit', () => {
|
|
430
|
+
// Write a fixture whose symbol name contains "ApiRoutes" — matches query by substring
|
|
431
|
+
writeFixtureFile(tmpDir, 'api.ts', 'export class ApiRoutes {}\n');
|
|
432
|
+
|
|
433
|
+
const input = {
|
|
434
|
+
tool_name: 'Agent',
|
|
435
|
+
tool_input: {
|
|
436
|
+
subagent_type: 'Explore',
|
|
437
|
+
// Query "ApiRoutes" substring-matches the class name
|
|
438
|
+
prompt: 'ApiRoutes',
|
|
176
439
|
model: 'haiku',
|
|
177
|
-
description: 'search for routes',
|
|
440
|
+
description: 'search for API routes',
|
|
178
441
|
run_in_background: false,
|
|
442
|
+
custom_field: 'custom_value',
|
|
179
443
|
},
|
|
180
444
|
cwd: tmpDir,
|
|
181
445
|
};
|
|
@@ -184,18 +448,24 @@ describe('df-explore-protocol hook', () => {
|
|
|
184
448
|
assert.equal(code, 0);
|
|
185
449
|
|
|
186
450
|
const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
|
|
451
|
+
assert.equal(updated.subagent_type, 'Explore');
|
|
187
452
|
assert.equal(updated.model, 'haiku');
|
|
188
|
-
assert.equal(updated.description, 'search for routes');
|
|
453
|
+
assert.equal(updated.description, 'search for API routes');
|
|
189
454
|
assert.equal(updated.run_in_background, false);
|
|
190
|
-
assert.equal(updated.
|
|
455
|
+
assert.equal(updated.custom_field, 'custom_value');
|
|
456
|
+
// Prompt is modified (has injection) but still contains original text
|
|
457
|
+
assert.ok(updated.prompt.includes('ApiRoutes'));
|
|
191
458
|
});
|
|
192
459
|
|
|
193
|
-
test('
|
|
460
|
+
test('AC-12: all original tool_input fields preserved after Phase 1 fallback', () => {
|
|
461
|
+
// No matching files → fallback path
|
|
194
462
|
const input = {
|
|
195
463
|
tool_name: 'Agent',
|
|
196
464
|
tool_input: {
|
|
197
465
|
subagent_type: 'Explore',
|
|
198
|
-
prompt: 'Find:
|
|
466
|
+
prompt: 'Find: something',
|
|
467
|
+
model: 'sonnet',
|
|
468
|
+
extra: 'value',
|
|
199
469
|
},
|
|
200
470
|
cwd: tmpDir,
|
|
201
471
|
};
|
|
@@ -204,18 +474,127 @@ describe('df-explore-protocol hook', () => {
|
|
|
204
474
|
assert.equal(code, 0);
|
|
205
475
|
|
|
206
476
|
const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
assert.ok(matches.length >= 1);
|
|
477
|
+
assert.equal(updated.model, 'sonnet');
|
|
478
|
+
assert.equal(updated.extra, 'value');
|
|
479
|
+
assert.equal(updated.subagent_type, 'Explore');
|
|
211
480
|
});
|
|
212
481
|
|
|
213
|
-
|
|
482
|
+
// -------------------------------------------------------------------------
|
|
483
|
+
// Existing behavior: non-Explore/non-Agent pass-through
|
|
484
|
+
// -------------------------------------------------------------------------
|
|
485
|
+
test('ignores non-Agent tool calls', () => {
|
|
486
|
+
const input = {
|
|
487
|
+
tool_name: 'Read',
|
|
488
|
+
tool_input: { file_path: '/some/file.ts' },
|
|
489
|
+
cwd: tmpDir,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const { stdout, code } = runHook(input);
|
|
493
|
+
assert.equal(code, 0);
|
|
494
|
+
assert.equal(stdout, '');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('ignores non-Explore agent calls', () => {
|
|
214
498
|
const input = {
|
|
215
499
|
tool_name: 'Agent',
|
|
216
|
-
tool_input: {
|
|
217
|
-
|
|
218
|
-
|
|
500
|
+
tool_input: { subagent_type: 'reasoner', prompt: 'Analyze this code' },
|
|
501
|
+
cwd: tmpDir,
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const { stdout, code } = runHook(input);
|
|
505
|
+
assert.equal(code, 0);
|
|
506
|
+
assert.equal(stdout, '');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('handles case-insensitive subagent_type (EXPLORE)', () => {
|
|
510
|
+
const input = {
|
|
511
|
+
tool_name: 'Agent',
|
|
512
|
+
tool_input: { subagent_type: 'EXPLORE', prompt: 'Find: test utilities' },
|
|
513
|
+
cwd: tmpDir,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const { stdout, code } = runHook(input);
|
|
517
|
+
assert.equal(code, 0);
|
|
518
|
+
|
|
519
|
+
const result = JSON.parse(stdout);
|
|
520
|
+
assert.ok(result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol'));
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// -------------------------------------------------------------------------
|
|
524
|
+
// AC-15: Phase 1 filters symbols by substring match on symbol name OR file path
|
|
525
|
+
// -------------------------------------------------------------------------
|
|
526
|
+
test('AC-15: Phase 1 includes symbols matching query in name or filepath', () => {
|
|
527
|
+
// File path contains "database" — all functions in it should be included
|
|
528
|
+
writeFixtureFile(tmpDir, 'database.ts', 'export function connect() {}\nexport function close() {}\n');
|
|
529
|
+
// File name does not match, but symbol name "databaseHelper" contains "database"
|
|
530
|
+
writeFixtureFile(tmpDir, 'utils.ts', 'export function databaseHelper() {}\nexport function unrelated() {}\n');
|
|
531
|
+
|
|
532
|
+
// Query "database" matches filepath of database.ts and symbol name databaseHelper
|
|
533
|
+
const input = {
|
|
534
|
+
tool_name: 'Agent',
|
|
535
|
+
tool_input: { subagent_type: 'Explore', prompt: 'database' },
|
|
536
|
+
cwd: tmpDir,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const { stdout, code } = runHook(input);
|
|
540
|
+
assert.equal(code, 0);
|
|
541
|
+
|
|
542
|
+
const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
|
|
543
|
+
assert.ok(prompt.includes('[LSP Phase -- locations found]'), 'Should have LSP block');
|
|
544
|
+
// databaseHelper matches by symbol name substring
|
|
545
|
+
assert.ok(prompt.includes('databaseHelper'), 'Symbol matching query by name should be included');
|
|
546
|
+
// connect/close match because their file path includes "database"
|
|
547
|
+
assert.ok(prompt.includes('connect'), 'Symbol in matching filepath should be included');
|
|
548
|
+
// unrelated in utils.ts should not be included (path has "utils", not "database"; name "unrelated" doesn't match)
|
|
549
|
+
assert.ok(!prompt.includes('unrelated'), 'Unrelated symbol in non-matching file excluded');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// -------------------------------------------------------------------------
|
|
553
|
+
// Phase 1 no matching symbols → static fallback
|
|
554
|
+
// -------------------------------------------------------------------------
|
|
555
|
+
test('Phase 1 no matching symbols falls back to static template injection', () => {
|
|
556
|
+
// Write a file with a symbol that does NOT match the query
|
|
557
|
+
writeFixtureFile(tmpDir, 'unrelated.js', 'function completelyDifferent() {}\n');
|
|
558
|
+
|
|
559
|
+
const input = {
|
|
560
|
+
tool_name: 'Agent',
|
|
561
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: nothing here' },
|
|
562
|
+
cwd: tmpDir,
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const { stdout, code } = runHook(input);
|
|
566
|
+
assert.equal(code, 0);
|
|
567
|
+
|
|
568
|
+
const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
|
|
569
|
+
assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Should inject static protocol');
|
|
570
|
+
assert.ok(!prompt.includes('[LSP Phase -- locations found]'), 'No LSP block for empty results');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// -------------------------------------------------------------------------
|
|
574
|
+
// permissionDecision is always 'allow'
|
|
575
|
+
// -------------------------------------------------------------------------
|
|
576
|
+
test('hookSpecificOutput has permissionDecision allow', () => {
|
|
577
|
+
const input = {
|
|
578
|
+
tool_name: 'Agent',
|
|
579
|
+
tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
|
|
580
|
+
cwd: tmpDir,
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const { stdout, code } = runHook(input);
|
|
584
|
+
assert.equal(code, 0);
|
|
585
|
+
|
|
586
|
+
const out = JSON.parse(stdout).hookSpecificOutput;
|
|
587
|
+
assert.equal(out.permissionDecision, 'allow');
|
|
588
|
+
assert.equal(out.hookEventName, 'PreToolUse');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// -------------------------------------------------------------------------
|
|
592
|
+
// Missing prompt field — should inject into empty string base
|
|
593
|
+
// -------------------------------------------------------------------------
|
|
594
|
+
test('handles missing prompt field gracefully', () => {
|
|
595
|
+
const input = {
|
|
596
|
+
tool_name: 'Agent',
|
|
597
|
+
tool_input: { subagent_type: 'Explore' },
|
|
219
598
|
cwd: tmpDir,
|
|
220
599
|
};
|
|
221
600
|
|