deepflow 0.1.107 → 0.1.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.js +25 -7
- package/bin/install.test.js +113 -0
- package/bin/plan-consolidator.js +19 -1
- package/bin/plan-consolidator.test.js +150 -0
- package/bin/ratchet.js +11 -6
- package/bin/ratchet.test.js +172 -0
- package/bin/worktree-deps.js +127 -0
- package/hooks/ac-coverage.js +213 -0
- package/hooks/df-explore-protocol.js +227 -28
- package/hooks/df-explore-protocol.test.js +460 -81
- package/hooks/df-spec-lint.js +13 -2
- package/hooks/df-spec-lint.test.js +133 -0
- package/package.json +4 -1
- package/src/commands/df/execute.md +112 -2
- package/src/commands/df/plan.md +244 -16
- package/src/commands/df/verify.md +46 -8
- package/templates/config-template.yaml +1 -0
- package/templates/explore-protocol.md.bak +69 -0
- package/templates/plan-template.md +11 -0
- package/templates/spec-template.md +15 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deepflow worktree-deps
|
|
4
|
+
* Symlinks node_modules from the main repo into a worktree so that
|
|
5
|
+
* TypeScript / LSP / builds resolve dependencies without a full install.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node bin/worktree-deps.js --source /path/to/repo --worktree /path/to/worktree
|
|
8
|
+
*
|
|
9
|
+
* Walks the source repo looking for node_modules directories (max depth 2)
|
|
10
|
+
* and creates corresponding symlinks in the worktree.
|
|
11
|
+
*
|
|
12
|
+
* Exit codes: 0=OK, 1=ERROR
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Args
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function parseArgs() {
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const opts = {};
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
if (args[i] === '--source' && args[i + 1]) opts.source = args[++i];
|
|
29
|
+
else if (args[i] === '--worktree' && args[i + 1]) opts.worktree = args[++i];
|
|
30
|
+
}
|
|
31
|
+
if (!opts.source || !opts.worktree) {
|
|
32
|
+
console.error('Usage: node bin/worktree-deps.js --source <repo> --worktree <worktree>');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Find node_modules directories (depth 0 and 1 level of nesting)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function findNodeModules(root) {
|
|
43
|
+
const results = [];
|
|
44
|
+
|
|
45
|
+
// Root node_modules
|
|
46
|
+
const rootNM = path.join(root, 'node_modules');
|
|
47
|
+
if (fs.existsSync(rootNM)) {
|
|
48
|
+
results.push('node_modules');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Scan common monorepo directory patterns for nested node_modules
|
|
52
|
+
const monorepoPatterns = ['packages', 'apps', 'libs', 'services', 'modules', 'plugins'];
|
|
53
|
+
|
|
54
|
+
for (const dir of monorepoPatterns) {
|
|
55
|
+
const dirPath = path.join(root, dir);
|
|
56
|
+
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) continue;
|
|
57
|
+
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(dirPath);
|
|
61
|
+
} catch (_) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const entryPath = path.join(dirPath, entry);
|
|
67
|
+
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
68
|
+
|
|
69
|
+
const nm = path.join(entryPath, 'node_modules');
|
|
70
|
+
if (fs.existsSync(nm)) {
|
|
71
|
+
results.push(path.join(dir, entry, 'node_modules'));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Create symlinks
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
function symlinkDeps(source, worktree) {
|
|
84
|
+
const nodeModulesPaths = findNodeModules(source);
|
|
85
|
+
|
|
86
|
+
if (nodeModulesPaths.length === 0) {
|
|
87
|
+
console.log('{"linked":0,"message":"no node_modules found in source"}');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let linked = 0;
|
|
92
|
+
const errors = [];
|
|
93
|
+
|
|
94
|
+
for (const relPath of nodeModulesPaths) {
|
|
95
|
+
const srcAbs = path.join(source, relPath);
|
|
96
|
+
const dstAbs = path.join(worktree, relPath);
|
|
97
|
+
|
|
98
|
+
// Skip if already exists (symlink or directory)
|
|
99
|
+
if (fs.existsSync(dstAbs)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Ensure parent directory exists in worktree
|
|
104
|
+
const parent = path.dirname(dstAbs);
|
|
105
|
+
if (!fs.existsSync(parent)) {
|
|
106
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
fs.symlinkSync(srcAbs, dstAbs, 'dir');
|
|
111
|
+
linked++;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
errors.push({ path: relPath, error: err.message });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = { linked, total: nodeModulesPaths.length };
|
|
118
|
+
if (errors.length > 0) result.errors = errors;
|
|
119
|
+
console.log(JSON.stringify(result));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Main
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const opts = parseArgs();
|
|
127
|
+
symlinkDeps(opts.source, opts.worktree);
|
|
@@ -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
|
});
|