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 +6 -1
- 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/package.json +4 -1
- package/src/commands/df/execute.md +61 -2
- package/src/commands/df/verify.md +1 -1
- package/templates/config-template.yaml +1 -0
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(`(^.*- \\[ \\].*\\*\\*${
|
|
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
|
-
|
|
172
|
+
try {
|
|
173
|
+
const { tool_name, tool_input, cwd } = payload;
|
|
40
174
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
199
|
+
// --- Phase 1: inline regex symbol extraction (AC-1, AC-7, AC-9) ---
|
|
200
|
+
const { symbols, hit: phase1Hit } = runPhase1(originalPrompt, effectiveCwd);
|
|
58
201
|
|
|
59
|
-
|
|
60
|
-
const updatedPrompt = `${originalPrompt}\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\n${protocol}`;
|
|
202
|
+
let updatedPrompt;
|
|
61
203
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepflow",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
|
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:
|
|
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}"
|