pan-wizard 2.9.1 → 3.5.0
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/README.md +31 -9
- package/agents/pan-conductor.md +189 -0
- package/agents/pan-counterfactual.md +112 -0
- package/agents/pan-debugger.md +15 -1
- package/agents/pan-distiller.md +82 -0
- package/agents/pan-document_code.md +21 -0
- package/agents/pan-executor.md +16 -0
- package/agents/pan-hardener.md +113 -0
- package/agents/pan-integration-checker.md +2 -0
- package/agents/pan-knowledge.md +81 -0
- package/agents/pan-meta-reviewer.md +91 -0
- package/agents/pan-optimizer.md +242 -0
- package/agents/pan-plan-checker.md +2 -0
- package/agents/pan-previewer.md +98 -0
- package/agents/pan-project-researcher.md +4 -4
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +2 -0
- package/bin/install-lib.cjs +197 -0
- package/bin/install.js +2048 -1959
- package/commands/pan/cost.md +132 -0
- package/commands/pan/exec-phase.md +15 -0
- package/commands/pan/focus-auto.md +168 -3
- package/commands/pan/focus-exec.md +21 -1
- package/commands/pan/focus-scan.md +6 -0
- package/commands/pan/git.md +223 -0
- package/commands/pan/knowledge.md +129 -0
- package/commands/pan/learn.md +61 -0
- package/commands/pan/map-codebase.md +15 -0
- package/commands/pan/mcp-bridge.md +145 -0
- package/commands/pan/milestone-done.md +9 -0
- package/commands/pan/optimize.md +86 -0
- package/commands/pan/plan-phase.md +11 -0
- package/commands/pan/preview.md +114 -0
- package/commands/pan/profile.md +37 -0
- package/commands/pan/review-deep.md +128 -0
- package/commands/pan/verify-phase.md +11 -0
- package/commands/pan/what-if.md +146 -0
- package/hooks/dist/pan-cost-logger.js +102 -0
- package/hooks/dist/pan-statusline.js +154 -108
- package/hooks/dist/pan-trace-logger.js +197 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
- package/pan-wizard-core/bin/lib/bus.cjs +251 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1 -0
- package/pan-wizard-core/bin/lib/constants.cjs +44 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
- package/pan-wizard-core/bin/lib/core.cjs +91 -6
- package/pan-wizard-core/bin/lib/cost.cjs +359 -0
- package/pan-wizard-core/bin/lib/distill.cjs +510 -0
- package/pan-wizard-core/bin/lib/focus.cjs +108 -3
- package/pan-wizard-core/bin/lib/git.cjs +407 -0
- package/pan-wizard-core/bin/lib/init.cjs +5 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
- package/pan-wizard-core/bin/lib/memory.cjs +252 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
- package/pan-wizard-core/bin/lib/phase.cjs +40 -13
- package/pan-wizard-core/bin/lib/preview.cjs +480 -0
- package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
- package/pan-wizard-core/bin/lib/state.cjs +2 -2
- package/pan-wizard-core/bin/lib/verify.cjs +34 -1
- package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
- package/pan-wizard-core/bin/pan-tools.cjs +317 -4
- package/pan-wizard-core/templates/playbook.md +53 -0
- package/pan-wizard-core/templates/preview-report.md +93 -0
- package/pan-wizard-core/templates/roadmap.md +24 -24
- package/pan-wizard-core/templates/state.md +12 -9
- package/pan-wizard-core/workflows/exec-phase.md +97 -0
- package/pan-wizard-core/workflows/learn.md +91 -0
- package/pan-wizard-core/workflows/optimize.md +139 -0
- package/pan-wizard-core/workflows/plan-phase.md +28 -1
- package/pan-wizard-core/workflows/quick.md +7 -0
- package/pan-wizard-core/workflows/verify-phase.md +16 -0
- package/scripts/build-hooks.js +3 -1
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { output, error, safeReadFile } = require('./core.cjs');
|
|
6
|
+
|
|
7
|
+
const PLANNING_DIR = '.planning';
|
|
8
|
+
const MEMORY_DIR = path.join(PLANNING_DIR, 'memory');
|
|
9
|
+
const PATTERNS_FILE = 'distill-patterns.md';
|
|
10
|
+
|
|
11
|
+
const SAFETY_TIERS = { SAFE: 'safe', REVIEW: 'review_required', RISKY: 'risky' };
|
|
12
|
+
const DEFAULT_BLOAT_THRESHOLD = 2.0;
|
|
13
|
+
const MAX_FUNCTION_LOC = 50;
|
|
14
|
+
const MAX_PARAM_COUNT = 4;
|
|
15
|
+
const MAX_NESTING_DEPTH = 3;
|
|
16
|
+
const MIN_REPEATED_LINES = 5;
|
|
17
|
+
const PATTERN_TTL_DAYS = 90;
|
|
18
|
+
const MAX_PATTERNS_KEPT = 100;
|
|
19
|
+
|
|
20
|
+
const SCANNABLE_EXTS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py'];
|
|
21
|
+
|
|
22
|
+
// ─── Pass 1: Deterministic static analysis ───────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function findPhantomTryCatch(content, filePath) {
|
|
25
|
+
const findings = [];
|
|
26
|
+
const re = /try\s*\{\s*(?:return\s+)?(JSON\.parse|JSON\.stringify|Number|String|Boolean|parseInt|parseFloat)\([^)]*\)\s*;?\s*\}\s*catch/g;
|
|
27
|
+
let match;
|
|
28
|
+
while ((match = re.exec(content)) !== null) {
|
|
29
|
+
const line = content.slice(0, match.index).split('\n').length;
|
|
30
|
+
findings.push({
|
|
31
|
+
pattern: 'phantom_try_catch',
|
|
32
|
+
file: filePath,
|
|
33
|
+
line,
|
|
34
|
+
span: match[0].slice(0, 80),
|
|
35
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
36
|
+
loc_saved: 4,
|
|
37
|
+
confidence: 0.9,
|
|
38
|
+
message: `Phantom try/catch around ${match[1]}() — does not throw in this form`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findUnusedImports(content, filePath) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
const importRe = /^(?:const|let|var)\s+\{?\s*([\w,\s]+)\s*\}?\s*=\s*require\(['"]([^'"]+)['"]\)/gm;
|
|
47
|
+
const esImportRe = /^import\s+(?:\{?\s*([\w,\s]+)\s*\}?\s*from\s+)?['"]([^'"]+)['"]/gm;
|
|
48
|
+
const collected = [];
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = importRe.exec(content)) !== null) collected.push({ names: m[1], line: content.slice(0, m.index).split('\n').length });
|
|
51
|
+
while ((m = esImportRe.exec(content)) !== null) {
|
|
52
|
+
if (m[1]) collected.push({ names: m[1], line: content.slice(0, m.index).split('\n').length });
|
|
53
|
+
}
|
|
54
|
+
// Strip string literals so 'fs' inside require('fs') doesn't count as a use of fs
|
|
55
|
+
const stripped = content.replace(/(['"])(?:\\.|(?!\1).)*\1/g, '""');
|
|
56
|
+
for (const imp of collected) {
|
|
57
|
+
const names = imp.names.split(',').map(s => s.trim()).filter(Boolean);
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const cleanName = name.replace(/\s+as\s+\w+/, '').trim();
|
|
60
|
+
if (!cleanName) continue;
|
|
61
|
+
const usageRe = new RegExp('\\b' + cleanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
|
|
62
|
+
const uses = (stripped.match(usageRe) || []).length;
|
|
63
|
+
if (uses <= 1) {
|
|
64
|
+
findings.push({
|
|
65
|
+
pattern: 'unused_import',
|
|
66
|
+
file: filePath,
|
|
67
|
+
line: imp.line,
|
|
68
|
+
span: cleanName,
|
|
69
|
+
tier: SAFETY_TIERS.SAFE,
|
|
70
|
+
loc_saved: 1,
|
|
71
|
+
confidence: 0.95,
|
|
72
|
+
message: `Unused import: ${cleanName}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findMagicNumbers(content, filePath) {
|
|
81
|
+
const findings = [];
|
|
82
|
+
const numberRe = /(?<!\w)(\d{3,})(?!\w)/g;
|
|
83
|
+
const occurrences = {};
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = numberRe.exec(content)) !== null) {
|
|
86
|
+
const num = m[1];
|
|
87
|
+
if (num === '100' || num === '1000') continue;
|
|
88
|
+
if (!occurrences[num]) occurrences[num] = [];
|
|
89
|
+
occurrences[num].push(content.slice(0, m.index).split('\n').length);
|
|
90
|
+
}
|
|
91
|
+
for (const [num, lines] of Object.entries(occurrences)) {
|
|
92
|
+
if (lines.length >= 3) {
|
|
93
|
+
findings.push({
|
|
94
|
+
pattern: 'magic_number',
|
|
95
|
+
file: filePath,
|
|
96
|
+
line: lines[0],
|
|
97
|
+
span: num,
|
|
98
|
+
tier: SAFETY_TIERS.SAFE,
|
|
99
|
+
loc_saved: 0,
|
|
100
|
+
confidence: 0.85,
|
|
101
|
+
message: `Magic number ${num} used ${lines.length}x — extract to named constant`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findLongFunctions(content, filePath) {
|
|
109
|
+
const findings = [];
|
|
110
|
+
const fnRe = /(?:^|\n)\s*(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*\{/g;
|
|
111
|
+
let m;
|
|
112
|
+
while ((m = fnRe.exec(content)) !== null) {
|
|
113
|
+
const startLine = content.slice(0, m.index).split('\n').length;
|
|
114
|
+
let depth = 0;
|
|
115
|
+
let i = m.index + m[0].length - 1;
|
|
116
|
+
let lineCount = 0;
|
|
117
|
+
for (; i < content.length; i++) {
|
|
118
|
+
if (content[i] === '{') depth++;
|
|
119
|
+
else if (content[i] === '}') { depth--; if (depth === 0) break; }
|
|
120
|
+
else if (content[i] === '\n') lineCount++;
|
|
121
|
+
}
|
|
122
|
+
if (lineCount > MAX_FUNCTION_LOC) {
|
|
123
|
+
findings.push({
|
|
124
|
+
pattern: 'long_function',
|
|
125
|
+
file: filePath,
|
|
126
|
+
line: startLine,
|
|
127
|
+
span: m[1],
|
|
128
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
129
|
+
loc_saved: Math.floor(lineCount * 0.3),
|
|
130
|
+
confidence: 0.75,
|
|
131
|
+
message: `Function ${m[1]}() is ${lineCount} LOC (limit ${MAX_FUNCTION_LOC}) — decompose`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findWideParamLists(content, filePath) {
|
|
139
|
+
const findings = [];
|
|
140
|
+
const fnRe = /(?:^|\n)\s*(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = fnRe.exec(content)) !== null) {
|
|
143
|
+
const params = m[2].split(',').map(s => s.trim()).filter(Boolean);
|
|
144
|
+
if (params.length > MAX_PARAM_COUNT) {
|
|
145
|
+
findings.push({
|
|
146
|
+
pattern: 'wide_params',
|
|
147
|
+
file: filePath,
|
|
148
|
+
line: content.slice(0, m.index).split('\n').length,
|
|
149
|
+
span: m[1],
|
|
150
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
151
|
+
loc_saved: 0,
|
|
152
|
+
confidence: 0.85,
|
|
153
|
+
message: `Function ${m[1]}() has ${params.length} params (limit ${MAX_PARAM_COUNT}) — use opts object`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return findings;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Pass 2: AST-style structural analysis ───────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function findSingleInstanceFactories(content, filePath) {
|
|
163
|
+
const findings = [];
|
|
164
|
+
const classRe = /class\s+(\w+Factory)\s*\{[^}]*\}/g;
|
|
165
|
+
let m;
|
|
166
|
+
while ((m = classRe.exec(content)) !== null) {
|
|
167
|
+
const className = m[1];
|
|
168
|
+
const usageRe = new RegExp('new\\s+' + className + '\\b', 'g');
|
|
169
|
+
const instances = (content.match(usageRe) || []).length;
|
|
170
|
+
if (instances <= 1) {
|
|
171
|
+
findings.push({
|
|
172
|
+
pattern: 'single_instance_factory',
|
|
173
|
+
file: filePath,
|
|
174
|
+
line: content.slice(0, m.index).split('\n').length,
|
|
175
|
+
span: className,
|
|
176
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
177
|
+
loc_saved: 5,
|
|
178
|
+
confidence: 0.7,
|
|
179
|
+
message: `${className} is instantiated ${instances}x — replace with module function`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findDeepNesting(content, filePath) {
|
|
187
|
+
const findings = [];
|
|
188
|
+
const lines = content.split('\n');
|
|
189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
190
|
+
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
191
|
+
const depth = Math.floor(indent / 2);
|
|
192
|
+
if (depth > MAX_NESTING_DEPTH && lines[i].trim().match(/^(if|for|while|switch)\b/)) {
|
|
193
|
+
findings.push({
|
|
194
|
+
pattern: 'deep_nesting',
|
|
195
|
+
file: filePath,
|
|
196
|
+
line: i + 1,
|
|
197
|
+
span: lines[i].trim().slice(0, 50),
|
|
198
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
199
|
+
loc_saved: 2,
|
|
200
|
+
confidence: 0.7,
|
|
201
|
+
message: `Nesting depth ${depth} (limit ${MAX_NESTING_DEPTH}) — extract to function or use early return`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return findings;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Pass 3: Graph-based cross-file analysis ─────────────────────────────────
|
|
209
|
+
|
|
210
|
+
function findRepeatedBlocks(filesContent) {
|
|
211
|
+
const findings = [];
|
|
212
|
+
const blockMap = {};
|
|
213
|
+
for (const [filePath, content] of Object.entries(filesContent)) {
|
|
214
|
+
const lines = content.split('\n');
|
|
215
|
+
for (let i = 0; i + MIN_REPEATED_LINES <= lines.length; i++) {
|
|
216
|
+
const block = lines.slice(i, i + MIN_REPEATED_LINES).map(l => l.trim()).join('\n');
|
|
217
|
+
if (block.length < 50 || block.includes('//')) continue;
|
|
218
|
+
if (!blockMap[block]) blockMap[block] = [];
|
|
219
|
+
blockMap[block].push({ file: filePath, line: i + 1 });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const [block, locations] of Object.entries(blockMap)) {
|
|
223
|
+
const uniqueFiles = new Set(locations.map(l => l.file));
|
|
224
|
+
if (locations.length >= 2 && uniqueFiles.size >= 2) {
|
|
225
|
+
findings.push({
|
|
226
|
+
pattern: 'repeated_block',
|
|
227
|
+
file: locations[0].file,
|
|
228
|
+
line: locations[0].line,
|
|
229
|
+
span: block.slice(0, 80),
|
|
230
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
231
|
+
loc_saved: MIN_REPEATED_LINES * (locations.length - 1),
|
|
232
|
+
confidence: 0.8,
|
|
233
|
+
locations,
|
|
234
|
+
message: `Block of ${MIN_REPEATED_LINES} lines repeated ${locations.length}x across ${uniqueFiles.size} files — extract helper`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return findings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function findUnreferencedExports(filesContent) {
|
|
242
|
+
const findings = [];
|
|
243
|
+
const exports = {};
|
|
244
|
+
const allContent = Object.values(filesContent).join('\n');
|
|
245
|
+
for (const [filePath, content] of Object.entries(filesContent)) {
|
|
246
|
+
const exportRe = /(?:^|\n)\s*(?:module\.exports\s*=\s*\{[^}]*?(\w+)|exports\.(\w+)\s*=|export\s+(?:const|function|class)\s+(\w+))/g;
|
|
247
|
+
let m;
|
|
248
|
+
while ((m = exportRe.exec(content)) !== null) {
|
|
249
|
+
const name = m[1] || m[2] || m[3];
|
|
250
|
+
if (!name) continue;
|
|
251
|
+
exports[name] = { file: filePath, line: content.slice(0, m.index).split('\n').length };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const [name, loc] of Object.entries(exports)) {
|
|
255
|
+
const usageRe = new RegExp('\\b' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
|
|
256
|
+
const matches = (allContent.match(usageRe) || []).length;
|
|
257
|
+
if (matches <= 1) {
|
|
258
|
+
findings.push({
|
|
259
|
+
pattern: 'unreferenced_export',
|
|
260
|
+
file: loc.file,
|
|
261
|
+
line: loc.line,
|
|
262
|
+
span: name,
|
|
263
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
264
|
+
loc_saved: 3,
|
|
265
|
+
confidence: 0.85,
|
|
266
|
+
message: `Export ${name} is not referenced outside its file`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return findings;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── File scanning ────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
function gatherFiles(cwd, opts) {
|
|
276
|
+
const ignore = (opts && opts.ignore) || ['node_modules', '.git', 'dist', 'build', '.planning', '.claude', '.codex', '.gemini', '.opencode', '.github'];
|
|
277
|
+
const files = [];
|
|
278
|
+
function walk(dir) {
|
|
279
|
+
let entries;
|
|
280
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
if (ignore.includes(entry.name)) continue;
|
|
283
|
+
const full = path.join(dir, entry.name);
|
|
284
|
+
if (entry.isDirectory()) walk(full);
|
|
285
|
+
else if (entry.isFile() && SCANNABLE_EXTS.includes(path.extname(entry.name))) {
|
|
286
|
+
files.push(full);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
walk(cwd);
|
|
291
|
+
return files;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function loadFiles(filePaths, cwd) {
|
|
295
|
+
const out = {};
|
|
296
|
+
for (const f of filePaths) {
|
|
297
|
+
const content = safeReadFile(f);
|
|
298
|
+
if (content) out[path.relative(cwd, f).replace(/\\/g, '/')] = content;
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Main scan: runs all 5 passes ─────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function distillScan(cwd, opts) {
|
|
306
|
+
const filePaths = gatherFiles(cwd, opts);
|
|
307
|
+
const filesContent = loadFiles(filePaths, cwd);
|
|
308
|
+
const allFindings = [];
|
|
309
|
+
|
|
310
|
+
for (const [filePath, content] of Object.entries(filesContent)) {
|
|
311
|
+
allFindings.push(...findPhantomTryCatch(content, filePath));
|
|
312
|
+
allFindings.push(...findUnusedImports(content, filePath));
|
|
313
|
+
allFindings.push(...findMagicNumbers(content, filePath));
|
|
314
|
+
allFindings.push(...findLongFunctions(content, filePath));
|
|
315
|
+
allFindings.push(...findWideParamLists(content, filePath));
|
|
316
|
+
allFindings.push(...findSingleInstanceFactories(content, filePath));
|
|
317
|
+
allFindings.push(...findDeepNesting(content, filePath));
|
|
318
|
+
}
|
|
319
|
+
allFindings.push(...findRepeatedBlocks(filesContent));
|
|
320
|
+
allFindings.push(...findUnreferencedExports(filesContent));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
findings: allFindings,
|
|
324
|
+
files_scanned: filePaths.length,
|
|
325
|
+
by_pattern: groupByPattern(allFindings),
|
|
326
|
+
by_tier: groupByTier(allFindings),
|
|
327
|
+
total_loc_saved: allFindings.reduce((s, f) => s + (f.loc_saved || 0), 0),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function groupByPattern(findings) {
|
|
332
|
+
const map = {};
|
|
333
|
+
for (const f of findings) map[f.pattern] = (map[f.pattern] || 0) + 1;
|
|
334
|
+
return map;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function groupByTier(findings) {
|
|
338
|
+
const map = { safe: 0, review_required: 0, risky: 0 };
|
|
339
|
+
for (const f of findings) map[f.tier] = (map[f.tier] || 0) + 1;
|
|
340
|
+
return map;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Bloat budget ────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function computeBloatBudget(touchedLoc, findings, threshold) {
|
|
346
|
+
const t = threshold || DEFAULT_BLOAT_THRESHOLD;
|
|
347
|
+
const removableLoc = findings.reduce((s, f) => s + (f.loc_saved || 0), 0);
|
|
348
|
+
const essentialLoc = Math.max(1, touchedLoc - removableLoc);
|
|
349
|
+
const ratio = touchedLoc / essentialLoc;
|
|
350
|
+
return {
|
|
351
|
+
touched_loc: touchedLoc,
|
|
352
|
+
essential_loc: essentialLoc,
|
|
353
|
+
removable_loc: removableLoc,
|
|
354
|
+
ratio: Number(ratio.toFixed(2)),
|
|
355
|
+
threshold: t,
|
|
356
|
+
over_budget: ratio > t,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Pass 5: Cross-session memory ────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
function readPatternsMemory(cwd) {
|
|
363
|
+
const filePath = path.join(cwd, MEMORY_DIR, PATTERNS_FILE);
|
|
364
|
+
const content = safeReadFile(filePath);
|
|
365
|
+
if (!content) return { patterns: [], file: filePath };
|
|
366
|
+
const patterns = [];
|
|
367
|
+
const re = /^- \[(\d{4}-\d{2}-\d{2})\] `([^`]+)` in `([^`]+)`(?:\s*—\s*(.*))?$/gm;
|
|
368
|
+
let m;
|
|
369
|
+
while ((m = re.exec(content)) !== null) {
|
|
370
|
+
patterns.push({ date: m[1], pattern: m[2], file: m[3], note: m[4] || '' });
|
|
371
|
+
}
|
|
372
|
+
return { patterns, file: filePath };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function detectRegressedPatterns(currentFindings, memory) {
|
|
376
|
+
const regressed = [];
|
|
377
|
+
for (const f of currentFindings) {
|
|
378
|
+
const prior = memory.patterns.find(p => p.pattern === f.pattern && p.file === f.file);
|
|
379
|
+
if (prior) {
|
|
380
|
+
regressed.push({
|
|
381
|
+
...f,
|
|
382
|
+
regressed: true,
|
|
383
|
+
previously_resolved: prior.date,
|
|
384
|
+
message: f.message + ` (REGRESSED — last resolved ${prior.date})`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return regressed;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function writePatternsMemory(cwd, findings, opts) {
|
|
392
|
+
const dir = path.join(cwd, MEMORY_DIR);
|
|
393
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
394
|
+
const filePath = path.join(dir, PATTERNS_FILE);
|
|
395
|
+
const existing = readPatternsMemory(cwd).patterns;
|
|
396
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
397
|
+
const cutoff = new Date(Date.now() - PATTERN_TTL_DAYS * 86400000).toISOString().slice(0, 10);
|
|
398
|
+
|
|
399
|
+
const fresh = existing.filter(p => p.date >= cutoff);
|
|
400
|
+
const newEntries = findings.map(f => ({
|
|
401
|
+
date: today,
|
|
402
|
+
pattern: f.pattern,
|
|
403
|
+
file: f.file,
|
|
404
|
+
note: f.message ? f.message.slice(0, 60) : '',
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
const dedup = {};
|
|
408
|
+
for (const p of [...fresh, ...newEntries]) {
|
|
409
|
+
dedup[`${p.pattern}:${p.file}`] = p;
|
|
410
|
+
}
|
|
411
|
+
const all = Object.values(dedup).slice(-MAX_PATTERNS_KEPT);
|
|
412
|
+
|
|
413
|
+
const lines = [
|
|
414
|
+
'---',
|
|
415
|
+
'name: distill-patterns',
|
|
416
|
+
'description: Cross-session memory of AI-bloat patterns detected and resolved',
|
|
417
|
+
'type: project',
|
|
418
|
+
'---',
|
|
419
|
+
'',
|
|
420
|
+
'## Pattern History',
|
|
421
|
+
'',
|
|
422
|
+
...all.map(p => `- [${p.date}] \`${p.pattern}\` in \`${p.file}\`${p.note ? ' — ' + p.note : ''}`),
|
|
423
|
+
'',
|
|
424
|
+
];
|
|
425
|
+
try {
|
|
426
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
|
|
427
|
+
return { written: true, file: filePath, count: all.length };
|
|
428
|
+
} catch (e) {
|
|
429
|
+
return { written: false, error: e.message };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── CLI dispatchers ──────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
function cmdDistillScan(cwd, opts, raw) {
|
|
436
|
+
const result = distillScan(cwd, opts);
|
|
437
|
+
output(result, raw, `${result.findings.length} finding(s) across ${result.files_scanned} file(s)`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function cmdDistillAnalyze(cwd, opts, raw) {
|
|
441
|
+
const scan = distillScan(cwd, opts);
|
|
442
|
+
const memory = readPatternsMemory(cwd);
|
|
443
|
+
const regressed = detectRegressedPatterns(scan.findings, memory);
|
|
444
|
+
const touched = (opts && opts.touchedLoc) ? parseInt(opts.touchedLoc, 10) : scan.findings.length * 10;
|
|
445
|
+
const budget = computeBloatBudget(touched, scan.findings, opts && opts.bloatThreshold);
|
|
446
|
+
output({
|
|
447
|
+
...scan,
|
|
448
|
+
regressed_count: regressed.length,
|
|
449
|
+
regressed,
|
|
450
|
+
bloat_budget: budget,
|
|
451
|
+
memory_file: memory.file,
|
|
452
|
+
prior_patterns: memory.patterns.length,
|
|
453
|
+
}, raw, `${scan.findings.length} findings | bloat ratio: ${budget.ratio}x | regressed: ${regressed.length}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function cmdDistillReport(cwd, opts, raw) {
|
|
457
|
+
const scan = distillScan(cwd, opts);
|
|
458
|
+
const writeResult = writePatternsMemory(cwd, scan.findings, opts);
|
|
459
|
+
output({
|
|
460
|
+
findings_count: scan.findings.length,
|
|
461
|
+
by_tier: scan.by_tier,
|
|
462
|
+
by_pattern: scan.by_pattern,
|
|
463
|
+
memory: writeResult,
|
|
464
|
+
}, raw, writeResult.written ? `Wrote ${writeResult.count} patterns to ${writeResult.file}` : 'Memory write failed');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function cmdDistill(cwd, subcommand, args, raw) {
|
|
468
|
+
const getOpt = (flag, def) => {
|
|
469
|
+
const i = args.indexOf(flag);
|
|
470
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : def;
|
|
471
|
+
};
|
|
472
|
+
const opts = {
|
|
473
|
+
bloatThreshold: getOpt('--bloat-threshold', null),
|
|
474
|
+
touchedLoc: getOpt('--touched-loc', null),
|
|
475
|
+
};
|
|
476
|
+
switch (subcommand) {
|
|
477
|
+
case 'scan': return cmdDistillScan(cwd, opts, raw);
|
|
478
|
+
case 'analyze': return cmdDistillAnalyze(cwd, opts, raw);
|
|
479
|
+
case 'report': return cmdDistillReport(cwd, opts, raw);
|
|
480
|
+
default: error('Unknown distill subcommand: ' + subcommand + '. Available: scan, analyze, report');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = {
|
|
485
|
+
cmdDistill,
|
|
486
|
+
cmdDistillScan,
|
|
487
|
+
cmdDistillAnalyze,
|
|
488
|
+
cmdDistillReport,
|
|
489
|
+
distillScan,
|
|
490
|
+
computeBloatBudget,
|
|
491
|
+
readPatternsMemory,
|
|
492
|
+
writePatternsMemory,
|
|
493
|
+
detectRegressedPatterns,
|
|
494
|
+
findPhantomTryCatch,
|
|
495
|
+
findUnusedImports,
|
|
496
|
+
findMagicNumbers,
|
|
497
|
+
findLongFunctions,
|
|
498
|
+
findWideParamLists,
|
|
499
|
+
findSingleInstanceFactories,
|
|
500
|
+
findDeepNesting,
|
|
501
|
+
findRepeatedBlocks,
|
|
502
|
+
findUnreferencedExports,
|
|
503
|
+
SAFETY_TIERS,
|
|
504
|
+
DEFAULT_BLOAT_THRESHOLD,
|
|
505
|
+
MAX_FUNCTION_LOC,
|
|
506
|
+
MAX_PARAM_COUNT,
|
|
507
|
+
MAX_NESTING_DEPTH,
|
|
508
|
+
MIN_REPEATED_LINES,
|
|
509
|
+
PATTERN_TTL_DAYS,
|
|
510
|
+
};
|
|
@@ -539,7 +539,9 @@ function cmdFocusSync(cwd, raw, ...args) {
|
|
|
539
539
|
// ─── Exec helpers ───────────────────────────────────────────────────────────
|
|
540
540
|
|
|
541
541
|
/**
|
|
542
|
-
* Read the
|
|
542
|
+
* Read the oldest open batch file from .planning/focus/.
|
|
543
|
+
* Batches are named batch-YYYY-MM-DD.json; lexical sort == chronological.
|
|
544
|
+
* Oldest-first ensures older unfinished batches get executed before newer ones.
|
|
543
545
|
* @param {string} cwd - Project root
|
|
544
546
|
* @returns {Object|null} Parsed batch data or null
|
|
545
547
|
*/
|
|
@@ -552,7 +554,7 @@ function readLatestBatch(cwd) {
|
|
|
552
554
|
return null;
|
|
553
555
|
}
|
|
554
556
|
if (files.length === 0) return null;
|
|
555
|
-
files.sort()
|
|
557
|
+
files.sort();
|
|
556
558
|
const content = safeReadFile(path.join(focusDir, files[0]));
|
|
557
559
|
if (!content) return null;
|
|
558
560
|
try {
|
|
@@ -781,7 +783,13 @@ function determineStopReason(cycle, run) {
|
|
|
781
783
|
if (cycle.tests_after < cycle.tests_before) return 'regression';
|
|
782
784
|
if (run.totals.points_used >= run.total_budget) return 'budget_cap';
|
|
783
785
|
if (run.totals.cycles_completed >= run.max_cycles) return 'max_cycles';
|
|
784
|
-
if (cycle.items_completed === 0)
|
|
786
|
+
if (cycle.items_completed === 0) {
|
|
787
|
+
// Security category gets a descriptive stop reason rather than generic zero_completed
|
|
788
|
+
if (run.category === 'security') return 'security_complete';
|
|
789
|
+
// Distill category gets a descriptive stop reason — codebase fully distilled
|
|
790
|
+
if (run.category === 'distill') return 'distill_complete';
|
|
791
|
+
return 'zero_completed';
|
|
792
|
+
}
|
|
785
793
|
|
|
786
794
|
// Prompts category: stop when all prompts are completed
|
|
787
795
|
if (run.category === 'prompts' && cycle.prompts_remaining === 0) {
|
|
@@ -846,6 +854,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
846
854
|
max_cycles: maxCycles,
|
|
847
855
|
total_budget: totalBudget,
|
|
848
856
|
priority_range: category ? CATEGORY_PRIORITY_RANGE[category] : { min: 0, max: 6 },
|
|
857
|
+
deep_review_enabled: hasFlag('--deep-review'),
|
|
849
858
|
tests_baseline: null,
|
|
850
859
|
cycles: [],
|
|
851
860
|
totals: { cycles_completed: 0, items_completed: 0, items_failed: 0, points_used: 0, tests_current: 0 },
|
|
@@ -874,6 +883,99 @@ function cmdFocusAuto(cwd, raw, ...args) {
|
|
|
874
883
|
return focusAutoInit(cwd, raw, getVal, hasFlag);
|
|
875
884
|
}
|
|
876
885
|
|
|
886
|
+
// ─── Opus 4.7: Reflection gate for focus-auto ───────────────────────────────
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Emit a reflection prompt the orchestrator shows to a thinking-capable model
|
|
890
|
+
* before committing to the next auto cycle. Returns null when reflection is
|
|
891
|
+
* disabled (non-reasoning tier, or explicitly turned off).
|
|
892
|
+
*
|
|
893
|
+
* @param {Object} run - Auto-run state (reads totals, config, category)
|
|
894
|
+
* @param {Object} cycle - The just-completed cycle's telemetry
|
|
895
|
+
* @param {Array} proposedNextBatch - Items focus-scan would select for cycle N+1
|
|
896
|
+
* @param {Object} [opts]
|
|
897
|
+
* @param {string} [opts.tier] - Resolved model tier ('reasoning'|'mid'|'fast')
|
|
898
|
+
* @returns {{reflect: boolean, prompt: string|null, reason: string}}
|
|
899
|
+
*/
|
|
900
|
+
function determineContinuation(run, cycle, proposedNextBatch, opts) {
|
|
901
|
+
const { REFLECTION_THRESHOLD } = require('./constants.cjs');
|
|
902
|
+
const tier = opts?.tier || 'mid';
|
|
903
|
+
const configFlag = run?.reflection_enabled;
|
|
904
|
+
|
|
905
|
+
const enabled = configFlag !== undefined
|
|
906
|
+
? Boolean(configFlag)
|
|
907
|
+
: REFLECTION_THRESHOLD.enabled_default ||
|
|
908
|
+
REFLECTION_THRESHOLD.enable_on_tiers.includes(tier);
|
|
909
|
+
|
|
910
|
+
if (!enabled) {
|
|
911
|
+
return { reflect: false, prompt: null, reason: 'reflection_disabled' };
|
|
912
|
+
}
|
|
913
|
+
if (!Array.isArray(proposedNextBatch) || proposedNextBatch.length === 0) {
|
|
914
|
+
return { reflect: false, prompt: null, reason: 'no_next_batch' };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const completed = cycle?.items_completed ?? 0;
|
|
918
|
+
const pointsUsed = cycle?.points_used ?? 0;
|
|
919
|
+
const efficiency = pointsUsed > 0 ? (completed / pointsUsed).toFixed(3) : 'n/a';
|
|
920
|
+
const category = run?.category || 'mixed';
|
|
921
|
+
const cyclesDone = run?.totals?.cycles_completed ?? 0;
|
|
922
|
+
const maxCycles = run?.max_cycles ?? 10;
|
|
923
|
+
|
|
924
|
+
const firstThree = proposedNextBatch.slice(0, 3)
|
|
925
|
+
.map(i => `- ${i.id || '(no id)'}: ${i.description || i.title || '(no description)'}`)
|
|
926
|
+
.join('\n');
|
|
927
|
+
|
|
928
|
+
const prompt = [
|
|
929
|
+
`Reflect before committing to cycle ${cyclesDone + 1} of ${maxCycles} in category "${category}".`,
|
|
930
|
+
'',
|
|
931
|
+
'Just-completed cycle telemetry:',
|
|
932
|
+
` items_completed: ${completed}`,
|
|
933
|
+
` points_used: ${pointsUsed}`,
|
|
934
|
+
` efficiency: ${efficiency} items/point`,
|
|
935
|
+
'',
|
|
936
|
+
`Next batch candidates (top ${Math.min(3, proposedNextBatch.length)} of ${proposedNextBatch.length}):`,
|
|
937
|
+
firstThree,
|
|
938
|
+
'',
|
|
939
|
+
'Think step-by-step: Is running another cycle worthwhile given the telemetry? Would the remaining items cluster better under a different category? Is there a stop signal this data is showing that the automatic rules missed? Answer in JSON: {"continue": true|false, "rationale": "..."}',
|
|
940
|
+
].join('\n');
|
|
941
|
+
|
|
942
|
+
return { reflect: true, prompt, reason: 'ok' };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ─── Opus 4.7: Parallel-tool stage dependency DAG for focus-exec ────────────
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Classify focus-exec items into a simple dependency DAG suitable for
|
|
949
|
+
* emitting parallel-tool-use instructions.
|
|
950
|
+
*
|
|
951
|
+
* Today every item is independent (batches come from focus-plan which
|
|
952
|
+
* already resolves dependencies). But items of tier MICRO can run fully
|
|
953
|
+
* parallel, STANDARD can run within-stage parallel, FULL must serialize.
|
|
954
|
+
* This helper expresses that as independent waves the command template
|
|
955
|
+
* can reference when instructing Opus to emit parallel tool calls.
|
|
956
|
+
*
|
|
957
|
+
* @param {Array<{id?: string, tier?: string}>} items - From readLatestBatch(...).batch
|
|
958
|
+
* @returns {{waves: Array<Array<Object>>, parallelism_hint: string}}
|
|
959
|
+
*/
|
|
960
|
+
function classifyStageDependencies(items) {
|
|
961
|
+
const { FOCUS_TIERS } = require('./constants.cjs');
|
|
962
|
+
const safe = Array.isArray(items) ? items : [];
|
|
963
|
+
const micro = safe.filter(i => i && i.tier === FOCUS_TIERS.MICRO);
|
|
964
|
+
const standard = safe.filter(i => i && i.tier === FOCUS_TIERS.STANDARD);
|
|
965
|
+
const full = safe.filter(i => i && i.tier === FOCUS_TIERS.FULL);
|
|
966
|
+
|
|
967
|
+
const waves = [];
|
|
968
|
+
if (micro.length) waves.push(micro);
|
|
969
|
+
if (standard.length) waves.push(standard);
|
|
970
|
+
for (const f of full) waves.push([f]);
|
|
971
|
+
|
|
972
|
+
let hint = 'sequential';
|
|
973
|
+
if (micro.length >= 2) hint = 'emit-micro-in-parallel';
|
|
974
|
+
else if (standard.length >= 2) hint = 'emit-standard-in-parallel';
|
|
975
|
+
|
|
976
|
+
return { waves, parallelism_hint: hint };
|
|
977
|
+
}
|
|
978
|
+
|
|
877
979
|
// ─── Module exports ─────────────────────────────────────────────────────────
|
|
878
980
|
|
|
879
981
|
module.exports = {
|
|
@@ -902,4 +1004,7 @@ module.exports = {
|
|
|
902
1004
|
writeAutoRun,
|
|
903
1005
|
cmdFocusAuto,
|
|
904
1006
|
determineStopReason,
|
|
1007
|
+
// Opus 4.7
|
|
1008
|
+
determineContinuation,
|
|
1009
|
+
classifyStageDependencies,
|
|
905
1010
|
};
|