deepflow 0.1.108 → 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/ratchet.js CHANGED
@@ -282,6 +282,10 @@ const SALVAGEABLE_STAGES = new Set(['lint']);
282
282
  // CLI argument parser
283
283
  // ---------------------------------------------------------------------------
284
284
 
285
+ function escapeRegExp(value) {
286
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
287
+ }
288
+
285
289
  function parseArgs(argv) {
286
290
  const args = { task: null, worktree: null, snapshot: null };
287
291
  for (let i = 0; i < argv.length; i++) {
@@ -316,8 +320,9 @@ function updatePlanMd(repoRoot, taskId, cwd) {
316
320
  }
317
321
 
318
322
  const text = fs.readFileSync(planPath, 'utf8');
323
+ const safeTaskId = escapeRegExp(taskId);
319
324
  // Match lines like: - [ ] **T54** ...
320
- const re = new RegExp(`(^.*- \\[ \\].*\\*\\*${taskId}\\*\\*.*)`, 'm');
325
+ const re = new RegExp(`(^.*- \\[ \\].*\\*\\*${safeTaskId}\\*\\*.*)`, 'm');
321
326
  const updated = text.replace(re, (line) => {
322
327
  let result = line.replace('- [ ]', '- [x]');
323
328
  if (hash) result += ` (${hash})`;
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow AC coverage checker
4
+ * Standalone script called by the orchestrator after ratchet checks.
5
+ *
6
+ * Usage:
7
+ * node ac-coverage.js --spec <path> --output <text> --status <pass|fail|revert>
8
+ * node ac-coverage.js --spec <path> --output-file <path> --status <pass|fail|revert>
9
+ *
10
+ * Exit codes:
11
+ * 0 — all ACs covered, no ACs in spec, or input status was fail/revert
12
+ * 2 — SALVAGEABLE: missed ACs detected and input status was pass
13
+ * 1 — script error only
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+
20
+ // ── Argument parsing ────────────────────────────────────────────────────────
21
+
22
+ function parseArgs(argv) {
23
+ const args = {};
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === '--spec' && argv[i + 1]) { args.spec = argv[++i]; }
27
+ else if (a === '--output' && argv[i + 1]) { args.output = argv[++i]; }
28
+ else if (a === '--output-file' && argv[i + 1]) { args.outputFile = argv[++i]; }
29
+ else if (a === '--status' && argv[i + 1]) { args.status = argv[++i]; }
30
+ }
31
+ return args;
32
+ }
33
+
34
+ // ── Section extraction ──────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Extract content of `## Acceptance Criteria` section (up to next ## or EOF).
38
+ * Returns null if the section is absent.
39
+ */
40
+ function extractACSection(content) {
41
+ const lines = content.split('\n');
42
+ let capturing = false;
43
+ const captured = [];
44
+
45
+ for (const line of lines) {
46
+ const headerMatch = line.match(/^##\s+(.+)/);
47
+ if (headerMatch) {
48
+ if (capturing) break; // next section — stop
49
+ if (/^acceptance criteria$/i.test(headerMatch[1].trim())) {
50
+ capturing = true;
51
+ }
52
+ continue;
53
+ }
54
+ if (capturing) {
55
+ captured.push(line);
56
+ }
57
+ }
58
+
59
+ return capturing ? captured.join('\n') : null;
60
+ }
61
+
62
+ // ── AC extraction from spec ─────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Extract canonical AC-N identifiers from the spec's Acceptance Criteria section.
66
+ * Returns null if no section found, empty array if section exists but has no AC-\d+ patterns.
67
+ */
68
+ function extractSpecACs(specContent) {
69
+ const section = extractACSection(specContent);
70
+ if (section === null) return null;
71
+
72
+ const ids = new Set();
73
+ const pattern = /\bAC-(\d+)\b/g;
74
+ let m;
75
+ while ((m = pattern.exec(section)) !== null) {
76
+ ids.add(`AC-${m[1]}`);
77
+ }
78
+ return [...ids].sort((a, b) => {
79
+ const na = parseInt(a.replace('AC-', ''), 10);
80
+ const nb = parseInt(b.replace('AC-', ''), 10);
81
+ return na - nb;
82
+ });
83
+ }
84
+
85
+ // ── AC parsing from agent output ────────────────────────────────────────────
86
+
87
+ /**
88
+ * Parse AC_COVERAGE block from agent output.
89
+ * Returns a Map of AC-N → { status: 'done'|'skip', reason: string|null }
90
+ */
91
+ function parseACCoverage(outputText) {
92
+ const map = new Map();
93
+
94
+ const blockMatch = outputText.match(/AC_COVERAGE:([\s\S]*?)AC_COVERAGE_END/);
95
+ if (!blockMatch) return map;
96
+
97
+ const block = blockMatch[1];
98
+ const linePattern = /^(AC-\d+):(done|skip)(?::(.+))?$/gm;
99
+ let m;
100
+ while ((m = linePattern.exec(block)) !== null) {
101
+ map.set(m[1], {
102
+ status: m[2],
103
+ reason: m[3] ? m[3].trim() : null,
104
+ });
105
+ }
106
+ return map;
107
+ }
108
+
109
+ // ── Main logic ──────────────────────────────────────────────────────────────
110
+
111
+ function run(args) {
112
+ if (!args.spec) {
113
+ console.error('[ac-coverage] Error: --spec is required');
114
+ process.exit(1);
115
+ }
116
+ if (!args.status) {
117
+ console.error('[ac-coverage] Error: --status is required');
118
+ process.exit(1);
119
+ }
120
+
121
+ // Read spec
122
+ let specContent;
123
+ try {
124
+ specContent = fs.readFileSync(args.spec, 'utf8');
125
+ } catch (e) {
126
+ console.error(`[ac-coverage] Error reading spec: ${e.message}`);
127
+ process.exit(1);
128
+ }
129
+
130
+ // Extract canonical ACs (AC-2, AC-7, AC-8)
131
+ const specACs = extractSpecACs(specContent);
132
+
133
+ // AC-7: no Acceptance Criteria section → silent exit
134
+ if (specACs === null) {
135
+ process.exit(0);
136
+ }
137
+
138
+ // AC-8: section exists but no AC-\d+ patterns → silent exit
139
+ if (specACs.length === 0) {
140
+ process.exit(0);
141
+ }
142
+
143
+ // Read agent output
144
+ let outputText = '';
145
+ if (args.outputFile) {
146
+ try {
147
+ outputText = fs.readFileSync(args.outputFile, 'utf8');
148
+ } catch (e) {
149
+ console.error(`[ac-coverage] Error reading output file: ${e.message}`);
150
+ process.exit(1);
151
+ }
152
+ } else if (args.output !== undefined) {
153
+ outputText = args.output;
154
+ }
155
+
156
+ // AC-3: parse AC_COVERAGE block from agent output
157
+ const reported = parseACCoverage(outputText);
158
+
159
+ // AC-3: diff — identify missed ACs (not reported as done)
160
+ const missed = [];
161
+ const skipped = [];
162
+ let coveredCount = 0;
163
+
164
+ for (const id of specACs) {
165
+ const entry = reported.get(id);
166
+ if (entry && entry.status === 'done') {
167
+ coveredCount++;
168
+ } else if (entry && entry.status === 'skip') {
169
+ skipped.push({ id, reason: entry.reason });
170
+ } else {
171
+ missed.push(id);
172
+ }
173
+ }
174
+
175
+ const totalACs = specACs.length;
176
+ const reportedDoneCount = coveredCount;
177
+
178
+ // AC-6: summary line
179
+ const summaryParts = [];
180
+ if (missed.length > 0) {
181
+ summaryParts.push(`missed: ${missed.join(', ')}`);
182
+ }
183
+ if (skipped.length > 0) {
184
+ const skipDesc = skipped.map(s => s.reason ? `${s.id} (${s.reason})` : s.id).join(', ');
185
+ summaryParts.push(`skipped: ${skipDesc}`);
186
+ }
187
+
188
+ const summaryDetail = summaryParts.length > 0 ? ` — ${summaryParts.join('; ')}` : '';
189
+ console.log(`[ac-coverage] ${reportedDoneCount}/${totalACs} ACs covered${summaryDetail}`);
190
+
191
+ // AC-4 / AC-5: status override
192
+ const inputStatus = args.status;
193
+ const hasMissed = missed.length > 0;
194
+
195
+ if (hasMissed && inputStatus === 'pass') {
196
+ // AC-4: override to SALVAGEABLE
197
+ console.log('OVERRIDE:SALVAGEABLE');
198
+ process.exit(2);
199
+ } else {
200
+ // AC-5: all done, or status was already fail/revert — no override
201
+ console.log('OVERRIDE:none');
202
+ process.exit(0);
203
+ }
204
+ }
205
+
206
+ // ── Entry point ─────────────────────────────────────────────────────────────
207
+
208
+ if (require.main === module) {
209
+ const args = parseArgs(process.argv.slice(2));
210
+ run(args);
211
+ }
212
+
213
+ module.exports = { extractSpecACs, parseACCoverage, extractACSection };
@@ -10,6 +10,10 @@
10
10
  * 1. {cwd}/templates/explore-protocol.md (repo checkout)
11
11
  * 2. ~/.claude/templates/explore-protocol.md (installed copy)
12
12
  *
13
+ * Phase 1: globs source files and extracts symbols via inline regex — no subprocess,
14
+ * no model calls. Results are filtered to strip noise paths and injected as
15
+ * structured context for Phase 2.
16
+ *
13
17
  * Exits silently (code 0) on all errors — never blocks tool execution (REQ-8).
14
18
  */
15
19
 
@@ -20,6 +24,135 @@ const path = require('path');
20
24
  const os = require('os');
21
25
  const { readStdinIfMain } = require('./lib/hook-stdin');
22
26
 
27
+ /**
28
+ * Regex patterns per language extension for symbol extraction.
29
+ * Spike-validated against JS/TS/Python/Go/Rust codebases.
30
+ */
31
+ const PATTERNS = {
32
+ '.js': /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum)\s+(\w+)/gm,
33
+ '.ts': /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum)\s+(\w+)/gm,
34
+ '.py': /^(?:async\s+)?(?:def|class)\s+(\w+)/gm,
35
+ '.go': /^(?:func(?:\s*\([^)]*\))?\s+(\w+)|type\s+(\w+)\s+(?:struct|interface|func))/gm,
36
+ '.rs': /^(?:pub\s+)?(?:async\s+)?(?:fn|struct|enum|trait|impl|type|mod)\s+(\w+)/gm,
37
+ };
38
+
39
+ /**
40
+ * Generic fallback pattern for unknown extensions.
41
+ * Covers the most common declaration keywords across languages.
42
+ */
43
+ const PATTERN_GENERIC = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|def|fn|struct|enum|trait|type|interface|mod)\s+(\w+)/gm;
44
+
45
+ /**
46
+ * Map extensions that share a pattern with a canonical key.
47
+ */
48
+ const EXTENSION_MAP = {
49
+ '.jsx': '.js',
50
+ '.tsx': '.ts',
51
+ '.mjs': '.js',
52
+ '.cjs': '.js',
53
+ };
54
+
55
+ /**
56
+ * Path filter — returns true if the filepath should be excluded.
57
+ * Strips node_modules, .claude/worktrees, dist, .git, vendor,
58
+ * __pycache__, .next, and build directories.
59
+ */
60
+ function isNoisePath(filepath) {
61
+ return /(node_modules|\.claude\/worktrees|\/dist\/|\.git\/|\/vendor\/|__pycache__|\/\.next\/|\/build\/)/.test(filepath);
62
+ }
63
+
64
+ /**
65
+ * Recursively walk a directory, yielding absolute file paths.
66
+ * Silently skips unreadable directories.
67
+ */
68
+ function* walkDir(dir) {
69
+ let entries;
70
+ try {
71
+ entries = fs.readdirSync(dir, { withFileTypes: true });
72
+ } catch (_) {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ const full = path.join(dir, entry.name);
77
+ if (entry.isDirectory()) {
78
+ if (!isNoisePath(full + '/')) yield* walkDir(full);
79
+ } else if (entry.isFile()) {
80
+ yield full;
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Run Phase 1: glob source files and extract symbols via inline regex.
87
+ * Returns { symbols: [{name, kind, line, filepath}], hit: boolean }.
88
+ * No subprocess, no model calls — pure Node.js (AC-1).
89
+ *
90
+ * @param {string} query - The explore prompt used for substring filtering (AC-15).
91
+ * @param {string} cwd - Project root to walk.
92
+ */
93
+ function runPhase1(query, cwd) {
94
+ const queryLower = query.toLowerCase();
95
+ const symbols = [];
96
+
97
+ try {
98
+ for (const filepath of walkDir(cwd)) {
99
+ if (isNoisePath(filepath)) continue;
100
+
101
+ const ext = path.extname(filepath).toLowerCase();
102
+ const canonExt = EXTENSION_MAP[ext] || ext;
103
+ const pattern = PATTERNS[canonExt] || PATTERN_GENERIC;
104
+
105
+ // AC-15: filter by substring match on file path
106
+ const filepathLower = filepath.toLowerCase();
107
+ const pathMatches = filepathLower.includes(queryLower);
108
+
109
+ let content;
110
+ try {
111
+ content = fs.readFileSync(filepath, 'utf8');
112
+ } catch (_) {
113
+ continue;
114
+ }
115
+
116
+ const lines = content.split('\n');
117
+ // Reset lastIndex before each use of the shared regex
118
+ const re = new RegExp(pattern.source, pattern.flags);
119
+ let m;
120
+ while ((m = re.exec(content)) !== null) {
121
+ // Capture group 1 is always the symbol name (Go uses group 2 for type decls)
122
+ const name = m[1] || m[2];
123
+ if (!name) continue;
124
+
125
+ // AC-15: filter by substring match on symbol name or file path
126
+ const nameLower = name.toLowerCase();
127
+ if (!nameLower.includes(queryLower) && !pathMatches) continue;
128
+
129
+ // Compute 1-based line number from the match offset
130
+ const before = content.slice(0, m.index);
131
+ const line = before.split('\n').length;
132
+
133
+ // Derive kind from the matched keyword
134
+ const matchedText = m[0];
135
+ let kind = 'symbol';
136
+ if (/\bclass\b/.test(matchedText)) kind = 'class';
137
+ else if (/\bfunction\b|\bfn\b|\bdef\b|\bfunc\b/.test(matchedText)) kind = 'function';
138
+ else if (/\binterface\b/.test(matchedText)) kind = 'interface';
139
+ else if (/\btype\b/.test(matchedText)) kind = 'type';
140
+ else if (/\benum\b/.test(matchedText)) kind = 'enum';
141
+ else if (/\bstruct\b/.test(matchedText)) kind = 'struct';
142
+ else if (/\btrait\b/.test(matchedText)) kind = 'trait';
143
+ else if (/\bmod\b/.test(matchedText)) kind = 'module';
144
+ else if (/\bimpl\b/.test(matchedText)) kind = 'impl';
145
+
146
+ symbols.push({ name, kind, line, filepath });
147
+ }
148
+ }
149
+ } catch (_) {
150
+ // Fail-open: return whatever was gathered so far
151
+ }
152
+
153
+ return { symbols, hit: symbols.length > 0 };
154
+ }
155
+
23
156
  /**
24
157
  * Locate the explore-protocol.md template.
25
158
  * Prefers project-local copy, falls back to installed global copy.
@@ -36,39 +169,105 @@ function findProtocol(cwd) {
36
169
  }
37
170
 
38
171
  readStdinIfMain(module, (payload) => {
39
- const { tool_name, tool_input, cwd } = payload;
172
+ try {
173
+ const { tool_name, tool_input, cwd } = payload;
40
174
 
41
- // Only intercept Agent calls with subagent_type "Explore"
42
- if (tool_name !== 'Agent') {
43
- return;
44
- }
45
- const subagentType = (tool_input.subagent_type || '').toLowerCase();
46
- if (subagentType !== 'explore') {
47
- return;
48
- }
175
+ // Only intercept Agent calls with subagent_type "Explore"
176
+ if (tool_name !== 'Agent') {
177
+ return;
178
+ }
179
+ const subagentType = (tool_input.subagent_type || '').toLowerCase();
180
+ if (subagentType !== 'explore') {
181
+ return;
182
+ }
49
183
 
50
- const protocolPath = findProtocol(cwd || process.cwd());
51
- if (!protocolPath) {
52
- // No template found allow without modification
53
- return;
54
- }
184
+ const effectiveCwd = cwd || process.cwd();
185
+
186
+ // --- Deduplication guard (AC-8) ---
187
+ // If the prompt already carries injected markers, skip re-injection entirely.
188
+ const existingPrompt = tool_input.prompt || '';
189
+ if (
190
+ existingPrompt.includes('Search Protocol (auto-injected') ||
191
+ existingPrompt.includes('LSP Phase')
192
+ ) {
193
+ return;
194
+ }
195
+
196
+ const protocolPath = findProtocol(effectiveCwd);
197
+ const originalPrompt = existingPrompt;
55
198
 
56
- const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
57
- const originalPrompt = tool_input.prompt || '';
199
+ // --- Phase 1: inline regex symbol extraction (AC-1, AC-7, AC-9) ---
200
+ const { symbols, hit: phase1Hit } = runPhase1(originalPrompt, effectiveCwd);
58
201
 
59
- // Append protocol as a system-level suffix the agent must follow
60
- const updatedPrompt = `${originalPrompt}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
202
+ let updatedPrompt;
61
203
 
62
- const result = {
63
- hookSpecificOutput: {
64
- hookEventName: 'PreToolUse',
65
- permissionDecision: 'allow',
66
- updatedInput: {
67
- ...tool_input,
68
- prompt: updatedPrompt,
204
+ if (phase1Hit) {
205
+ // Phase 1 succeeded — inject symbol locations + protocol (requires template)
206
+ if (!protocolPath) {
207
+ // No template found and Phase 1 succeeded — allow without modification
208
+ return;
209
+ }
210
+ const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
211
+
212
+ // AC-3: Format each symbol as `filepath:line -- name (kind)`
213
+ const locationLines = symbols
214
+ .map((s) => `${s.filepath}:${s.line} -- ${s.name} (${s.kind})`)
215
+ .join('\n');
216
+ const phase1Block =
217
+ '\n\n---\n## [LSP Phase -- locations found]\n\n' +
218
+ locationLines +
219
+ '\n\nRead ONLY these ranges. Do not use Grep, Glob, or Bash.';
220
+
221
+ updatedPrompt =
222
+ `${originalPrompt}${phase1Block}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
223
+ } else {
224
+ // Phase 1 empty — fall back to static template injection (AC-5)
225
+ if (!protocolPath) {
226
+ // AC-6: no template and regex found nothing — exit silently with no modification
227
+ return;
228
+ }
229
+ const protocol = fs.readFileSync(protocolPath, 'utf8').trim();
230
+
231
+ // Inject static template only, with auto-injected marker so dedup guard fires next time
232
+ updatedPrompt =
233
+ `${originalPrompt}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
234
+ }
235
+
236
+ const result = {
237
+ hookSpecificOutput: {
238
+ hookEventName: 'PreToolUse',
239
+ permissionDecision: 'allow',
240
+ updatedInput: {
241
+ ...tool_input,
242
+ prompt: updatedPrompt,
243
+ },
69
244
  },
70
- },
71
- };
245
+ };
246
+
247
+ // --- Metrics logging (fail-open) ---
248
+ // Log phase 1 hit rate to explore-metrics.jsonl.
249
+ // Wraps in try/catch so metrics failures never block hook execution.
250
+ try {
251
+ const metricsDir = path.join(effectiveCwd, '.deepflow');
252
+ const metricsPath = path.join(metricsDir, 'explore-metrics.jsonl');
253
+ if (!fs.existsSync(metricsDir)) {
254
+ fs.mkdirSync(metricsDir, { recursive: true });
255
+ }
256
+ const metricsEntry = {
257
+ timestamp: new Date().toISOString(),
258
+ query: originalPrompt,
259
+ phase1_hit: phase1Hit,
260
+ // tool_calls intentionally omitted: PreToolUse hooks fire before tool execution,
261
+ // so actual tool call counts are not observable here without a PostToolUse hook.
262
+ };
263
+ fs.appendFileSync(metricsPath, JSON.stringify(metricsEntry) + '\n', 'utf8');
264
+ } catch (_) {
265
+ // Metrics logging failure is silent — never blocks execution (REQ-8).
266
+ }
72
267
 
73
- process.stdout.write(JSON.stringify(result));
268
+ process.stdout.write(JSON.stringify(result));
269
+ } catch (_) {
270
+ // AC-10: catch ALL errors — malformed JSON, missing tool_input, filesystem errors, etc.
271
+ // Always exit 0; never block tool execution (REQ-8).
272
+ }
74
273
  });
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Tests for df-explore-protocol.js — PreToolUse hook
3
3
  *
4
- * Verifies that the hook injects the explore-protocol.md search protocol
5
- * into Explore agent prompts via updatedInput.
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
- process.execPath,
31
- [HOOK_PATH],
32
- {
33
- input: json,
34
- encoding: 'utf8',
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
- * 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
- }
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
- test('injects protocol into Explore agent prompt', () => {
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
- const { stdout, code } = runHook(input);
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 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');
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
- test('ignores non-Agent tool calls', () => {
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: 'Read',
102
- tool_input: { file_path: '/some/file.ts' },
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
- assert.equal(stdout, '');
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
- test('ignores non-Explore agent calls', () => {
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
- assert.equal(stdout, '');
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
- test('handles case-insensitive subagent_type', () => {
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
- assert.ok(result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol'));
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
- test('exits cleanly when no template found', () => {
144
- const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-empty-'));
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
- test('exits cleanly on malformed JSON input', () => {
166
- const { code } = runHook('not valid json');
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
- test('preserves all original tool_input fields', () => {
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: 'Find: API routes',
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.subagent_type, 'Explore');
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('does not double-inject if protocol already present', () => {
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: config\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\nalready here',
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
- 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);
477
+ assert.equal(updated.model, 'sonnet');
478
+ assert.equal(updated.extra, 'value');
479
+ assert.equal(updated.subagent_type, 'Explore');
211
480
  });
212
481
 
213
- test('handles missing prompt gracefully', () => {
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
- subagent_type: 'Explore',
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepflow",
3
- "version": "0.1.108",
3
+ "version": "0.1.109",
4
4
  "description": "Doing reveals what thinking can't predict — spec-driven iterative development for Claude Code",
5
5
  "keywords": [
6
6
  "claude",
@@ -42,5 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "playwright": "^1.58.2"
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^6.0.2"
45
48
  }
46
49
  }
@@ -167,7 +167,7 @@ The script handles all health checks internally and outputs structured JSON:
167
167
  **Broken-tests policy:** Updating pre-existing tests requires a separate dedicated task in PLAN.md with explicit justification — never inline during execution.
168
168
 
169
169
  **Orchestrator response by exit code:**
170
- - **Exit 0 (PASS):** Commit stands. TaskUpdate(status: "completed"), update PLAN.md [x] + commit hash. **Extract decisions** (see §5.5.1).
170
+ - **Exit 0 (PASS):** Commit stands. **AC coverage check** (see §5.5.1). TaskUpdate(status: "completed"), update PLAN.md [x] + commit hash. **Extract decisions** (see §5.5.2).
171
171
  - **Exit 1 (FAIL):** Script already reverted. Set `TaskUpdate(status: "pending")`. Recompute remaining waves:
172
172
  ```
173
173
  WAVE_JSON=!`node "${HOME}/.claude/bin/wave-runner.js" --json --plan PLAN.md --recalc --failed T{N} 2>/dev/null || echo 'WAVE_ERROR'`
@@ -176,7 +176,21 @@ The script handles all health checks internally and outputs structured JSON:
176
176
  Report: `"✗ T{n}: reverted"`.
177
177
  - **Exit 2 (SALVAGEABLE):** Spawn `Agent(model="sonnet")` to fix lint/typecheck issues. Re-run `node "${HOME}/.claude/bin/ratchet.js"`. If still non-zero → revert both commits, set status pending.
178
178
 
179
- #### 5.5.1. DECISION EXTRACTION (on ratchet pass)
179
+ #### 5.5.1. AC COVERAGE CHECK (after ratchet pass)
180
+
181
+ After ratchet PASS (exit 0), run AC coverage check to verify agent reported all acceptance criteria:
182
+ ```bash
183
+ node "${HOME}/.claude/bin/hooks/ac-coverage.js" --spec {spec_path} --output-file {agent_output_file} --status pass
184
+ ```
185
+
186
+ where `{spec_path}` is the path to `specs/doing-{spec_name}.md` and `{agent_output_file}` is the task agent's full output transcript (from TaskOutput or notification context).
187
+
188
+ **Exit codes from ac-coverage.js:**
189
+ - **Exit 0:** All ACs covered or no ACs in spec. Status remains PASS. Proceed to decision extraction (§5.5.2).
190
+ - **Exit 2 (SALVAGEABLE):** Missed ACs detected despite agent reporting TASK_STATUS:pass. Script outputs summary: `[ac-coverage] N/M ACs covered — missed: AC-X, AC-Y; ...`. Override final status to SALVAGEABLE. Commit stands. TaskUpdate(status: "completed") with note that ACs are incomplete.
191
+ - **Exit 1 (script error):** Log error, do not change status. Proceed as if ratchet PASS (exit 0 from ac-coverage).
192
+
193
+ #### 5.5.2. DECISION EXTRACTION (on ratchet pass)
180
194
 
181
195
  Parse the agent's response for `DECISIONS:` line. If present:
182
196
  1. Split by ` | ` to get individual decisions
@@ -324,6 +338,25 @@ TASK_DETAIL=!`cat .deepflow/plans/doing-{task_id}.md 2>/dev/null || echo 'NOT_FO
324
338
  ```
325
339
  If `TASK_DETAIL` is not `NOT_FOUND`, use it as the full Middle section (Steps, ACs, Impact) in the agent prompt, overriding the inline PLAN.md block. If `NOT_FOUND`, fall back to the inline PLAN.md task block.
326
340
 
341
+ **Pre-prompt type context extraction (before building agent prompt):**
342
+
343
+ Run LSP `documentSymbol` on the task's `files` list to collect existing type definitions. This runs BEFORE prompt construction so the result can be injected as `EXISTING_TYPES`.
344
+
345
+ <!-- AC-7: No new tool calls or latency added when context sources are empty -->
346
+ **Early exit (AC-7):** If the task's `Files:` list is empty, skip all `documentSymbol` calls entirely. Set `EXISTING_TYPES` to empty string immediately and proceed to prompt construction.
347
+
348
+ Steps (only when `Files:` list is non-empty):
349
+ 1. Cap the file list at 10 files (take the first 10 from the task's `Files:` list).
350
+ 2. For each file (up to the cap), call `documentSymbol` via LSP.
351
+ 3. Filter results: keep only symbols with kind ∈ {Class, Interface, Enum, TypeAlias} (LSP SymbolKind values 5, 11, 10, 26 respectively).
352
+ 4. For each matching symbol, extract the source range (`range.start.line` to `range.end.line`) — read those lines from the file.
353
+ 5. Accumulate extracted lines with a **120-line total budget** — stop adding symbols once the budget is reached.
354
+ 6. Join all extracted ranges into a single string: `EXISTING_TYPES`.
355
+
356
+ **AC-8 — graceful no-op:** If no matching symbols are found across all processed files (either `documentSymbol` returns nothing or no Class/Interface/Enum/TypeAlias symbols exist), set `EXISTING_TYPES` to empty string. No context block is added to the prompt.
357
+
358
+ <!-- AC-6: Backward-compatible no-op — when neither Domain Model section exists in the spec nor Existing Types extraction yields content (EXISTING_TYPES is empty string), the Standard Task prompt contains no extra context blocks and is identical to the pre-injection baseline. Zero prompt overhead, zero tool calls for tasks that lack these context sources. -->
359
+
327
360
  **Standard Task** (`Agent(model="{Model}", ...)`):
328
361
  ```
329
362
  --- START ---
@@ -337,6 +370,16 @@ spike_results:
337
370
  insight: {insight from probe_learnings}
338
371
  }
339
372
  Success criteria: {ACs from spec relevant to this task}
373
+ {If spec contains ## Domain Model section:
374
+ --- CONTEXT: Domain Model ---
375
+ {Domain Model section content from doing-*.md, extracted via shell injection:
376
+ DOMAIN_MODEL=!`sed -n '/^## Domain Model$/,/^## [^D]/p' specs/doing-{spec_name}.md | head -n -1 2>/dev/null || echo 'NOT_FOUND'`
377
+ }
378
+ }
379
+ {If EXISTING_TYPES is non-empty:
380
+ --- CONTEXT: Existing Types ---
381
+ {EXISTING_TYPES}
382
+ }
340
383
  --- MIDDLE (omit for low effort; omit deps for medium) ---
341
384
  {TASK_DETAIL if available, else inline block:}
342
385
  Impact: Callers: {file} ({why}) | Duplicates: [active→consolidate] [dead→DELETE] | Data flow: {consumers}
@@ -344,6 +387,14 @@ Prior tasks: {dep_id}: {summary}
344
387
  Steps: 1. chub search/get for APIs 2. LSP findReferences, add unlisted callers 3. LSP documentSymbol on Impact files → Read with offset/limit on relevant ranges only (never read full files) 4. Implement 5. Commit
345
388
  --- END ---
346
389
  Duplicates: [active]→consolidate [dead]→DELETE. ONLY job: code+commit. No merge/rename/checkout.
390
+ **Acceptance Criteria Coverage:** If the spec has acceptance criteria (AC-N), emit this block:
391
+ ```
392
+ AC_COVERAGE:
393
+ AC-1:done
394
+ AC-2:skip:reason here (if applicable)
395
+ AC_COVERAGE_END
396
+ ```
397
+ Format: one line per AC with either `AC-N:done` or `AC-N:skip:reason`. Omit this block if the spec has no acceptance criteria.
347
398
  DECISIONS: If you made non-obvious choices, append to the LAST LINE BEFORE TASK_STATUS:
348
399
  DECISIONS: [TAG] {decision} — {rationale} | [TAG] {decision2} — {rationale2}
349
400
  Tags:
@@ -373,6 +424,14 @@ RULES:
373
424
  - Do NOT create new variables or intermediate adapters to paper over mismatches. Fix the actual call site.
374
425
  - Do NOT modify acceptance criteria or spec definitions.
375
426
  - Commit as fix({spec}): {contract description}. One commit per contract fix.
427
+ **Acceptance Criteria Coverage:** If the spec has acceptance criteria (AC-N), emit this block:
428
+ ```
429
+ AC_COVERAGE:
430
+ AC-1:done
431
+ AC-2:skip:reason here (if applicable)
432
+ AC_COVERAGE_END
433
+ ```
434
+ Format: one line per AC with either `AC-N:done` or `AC-N:skip:reason`. Omit this block if the spec has no acceptance criteria.
376
435
  DECISIONS: Report each contract fix as: [TAG] {what was mismatched} — {which side changed and why}. Use [APPROACH] for definitive fixes, [PROVISIONAL] if the fix is a workaround, [UPDATE] if changing a prior decision.
377
436
  Last line: TASK_STATUS:pass or TASK_STATUS:fail
378
437
  ```
@@ -86,7 +86,7 @@ Nothing found → `⚠ No build/test commands detected. L0/L4 skipped. Set quali
86
86
 
87
87
  No tool → pass with warning. When available: stash changes → run coverage on baseline → stash pop → run coverage on current → compare. Drop → FAIL. Same/improved → pass.
88
88
 
89
- **L3: Integration** — Subsumed by L0 + L4. No separate check.
89
+ **L3: AC coverage verification** — Verify that agent-reported acceptance criteria coverage matches the spec's acceptance criteria section. Parse spec file for `## Acceptance Criteria` section, extract all ACs. For each AC, verify that agent execution explicitly claimed coverage (via agent output or PLAN.md task completion notes). Missing or uncovered ACs → FAIL with list of uncovered ACs. All ACs claimed → pass.
90
90
 
91
91
  **L4: Tests** — Run AFTER L0 passes. Run even if L1-L2 had issues. Exit 0 → pass. Non-zero → FAIL with last 50 lines + fix task. If `quality.test_retry_on_fail: true`: re-run once; second pass → warn (flaky); second fail → genuine failure.
92
92
 
@@ -38,6 +38,7 @@ models:
38
38
 
39
39
  explore:
40
40
  max_tokens: 500 # Controls Explore agent response length
41
+ explore_lsp_timeout_ms: 15000 # Timeout (ms) for the Phase 1 LSP subprocess; on timeout the static template is injected as fallback
41
42
 
42
43
  commits:
43
44
  format: "feat({spec}): {description}"