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.
Files changed (75) hide show
  1. package/README.md +31 -9
  2. package/agents/pan-conductor.md +189 -0
  3. package/agents/pan-counterfactual.md +112 -0
  4. package/agents/pan-debugger.md +15 -1
  5. package/agents/pan-distiller.md +82 -0
  6. package/agents/pan-document_code.md +21 -0
  7. package/agents/pan-executor.md +16 -0
  8. package/agents/pan-hardener.md +113 -0
  9. package/agents/pan-integration-checker.md +2 -0
  10. package/agents/pan-knowledge.md +81 -0
  11. package/agents/pan-meta-reviewer.md +91 -0
  12. package/agents/pan-optimizer.md +242 -0
  13. package/agents/pan-plan-checker.md +2 -0
  14. package/agents/pan-previewer.md +98 -0
  15. package/agents/pan-project-researcher.md +4 -4
  16. package/agents/pan-reviewer.md +2 -0
  17. package/agents/pan-verifier.md +2 -0
  18. package/bin/install-lib.cjs +197 -0
  19. package/bin/install.js +2048 -1959
  20. package/commands/pan/cost.md +132 -0
  21. package/commands/pan/exec-phase.md +15 -0
  22. package/commands/pan/focus-auto.md +168 -3
  23. package/commands/pan/focus-exec.md +21 -1
  24. package/commands/pan/focus-scan.md +6 -0
  25. package/commands/pan/git.md +223 -0
  26. package/commands/pan/knowledge.md +129 -0
  27. package/commands/pan/learn.md +61 -0
  28. package/commands/pan/map-codebase.md +15 -0
  29. package/commands/pan/mcp-bridge.md +145 -0
  30. package/commands/pan/milestone-done.md +9 -0
  31. package/commands/pan/optimize.md +86 -0
  32. package/commands/pan/plan-phase.md +11 -0
  33. package/commands/pan/preview.md +114 -0
  34. package/commands/pan/profile.md +37 -0
  35. package/commands/pan/review-deep.md +128 -0
  36. package/commands/pan/verify-phase.md +11 -0
  37. package/commands/pan/what-if.md +146 -0
  38. package/hooks/dist/pan-cost-logger.js +102 -0
  39. package/hooks/dist/pan-statusline.js +154 -108
  40. package/hooks/dist/pan-trace-logger.js +197 -0
  41. package/package.json +1 -1
  42. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  43. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  44. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  45. package/pan-wizard-core/bin/lib/commands.cjs +1 -0
  46. package/pan-wizard-core/bin/lib/constants.cjs +44 -1
  47. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  48. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  49. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  50. package/pan-wizard-core/bin/lib/distill.cjs +510 -0
  51. package/pan-wizard-core/bin/lib/focus.cjs +108 -3
  52. package/pan-wizard-core/bin/lib/git.cjs +407 -0
  53. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  54. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  55. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  56. package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
  57. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  58. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  59. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  60. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  61. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  62. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  63. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  64. package/pan-wizard-core/bin/pan-tools.cjs +317 -4
  65. package/pan-wizard-core/templates/playbook.md +53 -0
  66. package/pan-wizard-core/templates/preview-report.md +93 -0
  67. package/pan-wizard-core/templates/roadmap.md +24 -24
  68. package/pan-wizard-core/templates/state.md +12 -9
  69. package/pan-wizard-core/workflows/exec-phase.md +97 -0
  70. package/pan-wizard-core/workflows/learn.md +91 -0
  71. package/pan-wizard-core/workflows/optimize.md +139 -0
  72. package/pan-wizard-core/workflows/plan-phase.md +28 -1
  73. package/pan-wizard-core/workflows/quick.md +7 -0
  74. package/pan-wizard-core/workflows/verify-phase.md +16 -0
  75. 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 most recent batch file from .planning/focus/.
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().reverse();
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) return 'zero_completed';
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
  };