pan-wizard 2.9.1 → 3.4.1

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 (58) hide show
  1. package/README.md +8 -8
  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-document_code.md +21 -0
  6. package/agents/pan-executor.md +16 -0
  7. package/agents/pan-hardener.md +113 -0
  8. package/agents/pan-integration-checker.md +2 -0
  9. package/agents/pan-knowledge.md +81 -0
  10. package/agents/pan-meta-reviewer.md +91 -0
  11. package/agents/pan-plan-checker.md +2 -0
  12. package/agents/pan-previewer.md +98 -0
  13. package/agents/pan-project-researcher.md +4 -4
  14. package/agents/pan-reviewer.md +2 -0
  15. package/agents/pan-verifier.md +2 -0
  16. package/bin/install-lib.cjs +197 -0
  17. package/bin/install.js +1999 -1959
  18. package/commands/pan/cost.md +132 -0
  19. package/commands/pan/exec-phase.md +15 -0
  20. package/commands/pan/focus-auto.md +18 -0
  21. package/commands/pan/focus-exec.md +10 -1
  22. package/commands/pan/knowledge.md +129 -0
  23. package/commands/pan/map-codebase.md +15 -0
  24. package/commands/pan/mcp-bridge.md +145 -0
  25. package/commands/pan/plan-phase.md +11 -0
  26. package/commands/pan/preview.md +114 -0
  27. package/commands/pan/profile.md +37 -0
  28. package/commands/pan/review-deep.md +128 -0
  29. package/commands/pan/verify-phase.md +11 -0
  30. package/commands/pan/what-if.md +146 -0
  31. package/hooks/dist/pan-cost-logger.js +102 -0
  32. package/hooks/dist/pan-statusline.js +154 -108
  33. package/package.json +1 -1
  34. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  35. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  36. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  37. package/pan-wizard-core/bin/lib/constants.cjs +39 -0
  38. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  39. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  40. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  41. package/pan-wizard-core/bin/lib/focus.cjs +100 -2
  42. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  43. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  44. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  45. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  46. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  47. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  48. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  49. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  50. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  51. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  52. package/pan-wizard-core/bin/pan-tools.cjs +239 -4
  53. package/pan-wizard-core/templates/playbook.md +53 -0
  54. package/pan-wizard-core/templates/preview-report.md +93 -0
  55. package/pan-wizard-core/templates/roadmap.md +24 -24
  56. package/pan-wizard-core/templates/state.md +12 -9
  57. package/pan-wizard-core/workflows/plan-phase.md +1 -1
  58. package/scripts/build-hooks.js +2 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Cost — per-call cost aggregation and dashboard (Spec B v2 Y-6, v3.0).
3
+ *
4
+ * Storage: `.planning/metrics/tokens.jsonl` — append-only JSON Lines.
5
+ *
6
+ * Each line is a cost record:
7
+ * {
8
+ * ts: "2026-04-18T12:34:56.789Z",
9
+ * agent: "pan-planner" | null, // agent name, if spawned as agent
10
+ * command: "exec-phase" | null, // command name, if invoked directly
11
+ * model: "claude-opus-4-7" | null, // model id when known
12
+ * tier: "reasoning" | "mid" | "fast" | null,
13
+ * input_tokens: 12345,
14
+ * output_tokens: 678,
15
+ * cache_read_tokens: 0,
16
+ * cache_write_tokens: 0,
17
+ * cost_usd: 0.123, // computed if model+tokens known, else null
18
+ * phase: "07" | null,
19
+ * session: "abc123" | null
20
+ * }
21
+ *
22
+ * The appender is deliberately tolerant: if fields are missing the record
23
+ * is still written; aggregation skips null fields gracefully. Non-blocking
24
+ * — failure to write never breaks the caller (cost is observability, not
25
+ * critical path).
26
+ *
27
+ * Aggregation produces:
28
+ * - by agent, by command, by tier, by day
29
+ * - totals: input/output/cache tokens, cost
30
+ * - hit rate: cache_read / (cache_read + input - cache_write) if any cache activity
31
+ *
32
+ * Rate table is approximate — real pricing comes from the provider's API.
33
+ * Rates are US dollars per million tokens, indicative as of 2026-04. Users
34
+ * can override with `.planning/config.json` → `cost.rates`.
35
+ */
36
+
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
40
+ const { PLANNING_DIR } = require('./constants.cjs');
41
+ const { planningPath } = require('./utils.cjs');
42
+
43
+ const METRICS_DIR = 'metrics';
44
+ const TOKENS_FILE = 'tokens.jsonl';
45
+
46
+ /**
47
+ * Default rate table ($ per million tokens).
48
+ * Override per-model in config.json → cost.rates.
49
+ */
50
+ const DEFAULT_RATES = {
51
+ // Anthropic
52
+ 'claude-opus-4-7': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
53
+ 'claude-opus-4-6': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
54
+ 'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
55
+ 'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
56
+
57
+ // Google Gemini — published rates (per million tokens, approximate; users can override via config.json → cost.rates).
58
+ // 2.5 tier uses the <=200K-context tier; long-context calls may be billed at ~2x. Cache rates are Google's context-cache pricing (~25% of input rate).
59
+ 'gemini-2.5-pro': { input: 1.25, output: 10.0, cache_read: 0.3125, cache_write: 1.25 },
60
+ 'gemini-2.5-flash': { input: 0.30, output: 2.50, cache_read: 0.075, cache_write: 0.30 },
61
+ 'gemini-2.5-flash-lite': { input: 0.10, output: 0.40, cache_read: 0.025, cache_write: 0.10 },
62
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00, cache_read: 0.3125, cache_write: 1.25 },
63
+
64
+ // Tier fallbacks when model id is unknown
65
+ 'reasoning': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
66
+ 'mid': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
67
+ 'fast': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
68
+ };
69
+
70
+ function metricsDir(cwd) {
71
+ return path.join(planningPath(cwd), METRICS_DIR);
72
+ }
73
+
74
+ function tokensFile(cwd) {
75
+ return path.join(metricsDir(cwd), TOKENS_FILE);
76
+ }
77
+
78
+ function resolveRate(model, tier, configRates) {
79
+ if (configRates) {
80
+ if (model && configRates[model]) return configRates[model];
81
+ if (tier && configRates[tier]) return configRates[tier];
82
+ }
83
+ if (model && DEFAULT_RATES[model]) return DEFAULT_RATES[model];
84
+ if (tier && DEFAULT_RATES[tier]) return DEFAULT_RATES[tier];
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Compute cost in USD for a single record given known rates.
90
+ * Returns null when rate is unknown.
91
+ * @param {Object} rec - Cost record
92
+ * @param {Object} [configRates] - Optional rate overrides
93
+ * @returns {number|null}
94
+ */
95
+ function computeCost(rec, configRates) {
96
+ const rate = resolveRate(rec.model, rec.tier, configRates);
97
+ if (!rate) return null;
98
+ const input = rec.input_tokens || 0;
99
+ const output = rec.output_tokens || 0;
100
+ const cacheRead = rec.cache_read_tokens || 0;
101
+ const cacheWrite = rec.cache_write_tokens || 0;
102
+ // Non-cache-hit input tokens = input - cache_read (cache_read already in input on some providers,
103
+ // separate on others; we treat cache_read as a reduction of effective new input).
104
+ const newInput = Math.max(0, input - cacheRead);
105
+ const usd = (newInput * rate.input + output * rate.output
106
+ + cacheRead * rate.cache_read + cacheWrite * rate.cache_write) / 1_000_000;
107
+ return Math.round(usd * 10000) / 10000;
108
+ }
109
+
110
+ /**
111
+ * Append a cost record. Non-blocking — errors are swallowed so instrumentation
112
+ * never breaks the caller.
113
+ * @param {string} cwd - Project root
114
+ * @param {Object} rec - Partial record; missing fields default to null/0.
115
+ * @returns {{appended: boolean, file?: string, error?: string}}
116
+ */
117
+ function appendRecord(cwd, rec) {
118
+ const normalized = {
119
+ ts: rec.ts || new Date().toISOString(),
120
+ agent: rec.agent || null,
121
+ command: rec.command || null,
122
+ model: rec.model || null,
123
+ tier: rec.tier || null,
124
+ input_tokens: Number(rec.input_tokens) || 0,
125
+ output_tokens: Number(rec.output_tokens) || 0,
126
+ cache_read_tokens: Number(rec.cache_read_tokens) || 0,
127
+ cache_write_tokens: Number(rec.cache_write_tokens) || 0,
128
+ phase: rec.phase || null,
129
+ session: rec.session || null,
130
+ };
131
+ // Allow caller-supplied cost override; otherwise compute.
132
+ normalized.cost_usd = typeof rec.cost_usd === 'number'
133
+ ? rec.cost_usd
134
+ : computeCost(normalized);
135
+
136
+ try {
137
+ fs.mkdirSync(metricsDir(cwd), { recursive: true });
138
+ fs.appendFileSync(tokensFile(cwd), JSON.stringify(normalized) + '\n', 'utf-8');
139
+ return { appended: true, file: tokensFile(cwd) };
140
+ } catch (e) {
141
+ return { appended: false, error: e.message };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read all cost records from the log.
147
+ * @param {string} cwd
148
+ * @returns {Array<Object>}
149
+ */
150
+ function readRecords(cwd) {
151
+ const raw = safeReadFile(tokensFile(cwd));
152
+ if (!raw) return [];
153
+ const records = [];
154
+ for (const line of raw.split('\n')) {
155
+ if (!line.trim()) continue;
156
+ try {
157
+ records.push(JSON.parse(line));
158
+ } catch { /* skip malformed line */ }
159
+ }
160
+ return records;
161
+ }
162
+
163
+ /**
164
+ * Aggregate records into totals + breakdowns.
165
+ * @param {string} cwd
166
+ * @param {Object} [opts] - {since, until, group_by}
167
+ * @returns {Object} Aggregation
168
+ */
169
+ function aggregate(cwd, opts) {
170
+ const records = readRecords(cwd);
171
+ const since = opts?.since ? new Date(opts.since).getTime() : null;
172
+ const until = opts?.until ? new Date(opts.until).getTime() : null;
173
+ const config = loadConfig(cwd);
174
+ const configRates = config?.cost?.rates;
175
+
176
+ const filtered = records.filter(r => {
177
+ if (!r.ts) return true;
178
+ const t = new Date(r.ts).getTime();
179
+ if (since !== null && t < since) return false;
180
+ if (until !== null && t > until) return false;
181
+ return true;
182
+ });
183
+
184
+ const totals = {
185
+ calls: filtered.length,
186
+ input_tokens: 0,
187
+ output_tokens: 0,
188
+ cache_read_tokens: 0,
189
+ cache_write_tokens: 0,
190
+ cost_usd: 0,
191
+ cost_unknown: 0,
192
+ };
193
+
194
+ const byAgent = {};
195
+ const byCommand = {};
196
+ const byTier = {};
197
+ const byDay = {};
198
+
199
+ function bump(map, key, rec) {
200
+ if (!key) return;
201
+ if (!map[key]) map[key] = { calls: 0, input: 0, output: 0, cache_read: 0, cache_write: 0, cost: 0 };
202
+ map[key].calls += 1;
203
+ map[key].input += rec.input_tokens || 0;
204
+ map[key].output += rec.output_tokens || 0;
205
+ map[key].cache_read += rec.cache_read_tokens || 0;
206
+ map[key].cache_write += rec.cache_write_tokens || 0;
207
+ const cost = typeof rec.cost_usd === 'number' ? rec.cost_usd : computeCost(rec, configRates);
208
+ if (typeof cost === 'number') map[key].cost += cost;
209
+ }
210
+
211
+ for (const r of filtered) {
212
+ totals.input_tokens += r.input_tokens || 0;
213
+ totals.output_tokens += r.output_tokens || 0;
214
+ totals.cache_read_tokens += r.cache_read_tokens || 0;
215
+ totals.cache_write_tokens += r.cache_write_tokens || 0;
216
+ const cost = typeof r.cost_usd === 'number' ? r.cost_usd : computeCost(r, configRates);
217
+ if (typeof cost === 'number') totals.cost_usd += cost;
218
+ else totals.cost_unknown += 1;
219
+
220
+ bump(byAgent, r.agent, r);
221
+ bump(byCommand, r.command, r);
222
+ bump(byTier, r.tier, r);
223
+ const day = r.ts ? r.ts.slice(0, 10) : null;
224
+ bump(byDay, day, r);
225
+ }
226
+
227
+ totals.cost_usd = Math.round(totals.cost_usd * 10000) / 10000;
228
+
229
+ // Cache hit rate: cache_read / (cache_read + new input tokens billed at full rate)
230
+ const billedInput = Math.max(0, totals.input_tokens - totals.cache_read_tokens);
231
+ const hitDenom = totals.cache_read_tokens + billedInput;
232
+ const cacheHitRatePct = hitDenom > 0
233
+ ? Math.round((totals.cache_read_tokens / hitDenom) * 1000) / 10
234
+ : null;
235
+
236
+ return {
237
+ totals,
238
+ cache_hit_rate_pct: cacheHitRatePct,
239
+ by_agent: byAgent,
240
+ by_command: byCommand,
241
+ by_tier: byTier,
242
+ by_day: byDay,
243
+ window: {
244
+ since: opts?.since || null,
245
+ until: opts?.until || null,
246
+ },
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Render aggregation as a human-readable table.
252
+ * @param {Object} agg - from aggregate()
253
+ * @returns {string}
254
+ */
255
+ function renderTable(agg) {
256
+ const lines = [];
257
+ lines.push('=== PAN Wizard Cost Dashboard ===');
258
+ const window = agg.window.since || agg.window.until
259
+ ? ` Window: ${agg.window.since || '(any)'} → ${agg.window.until || 'now'}`
260
+ : ' Window: all time';
261
+ lines.push(window);
262
+ lines.push('');
263
+ lines.push('Totals');
264
+ lines.push(` Calls : ${agg.totals.calls}`);
265
+ lines.push(` Input tokens : ${agg.totals.input_tokens.toLocaleString()}`);
266
+ lines.push(` Output tokens : ${agg.totals.output_tokens.toLocaleString()}`);
267
+ lines.push(` Cache read : ${agg.totals.cache_read_tokens.toLocaleString()}`);
268
+ lines.push(` Cache write : ${agg.totals.cache_write_tokens.toLocaleString()}`);
269
+ lines.push(` Estimated cost : $${agg.totals.cost_usd.toFixed(4)}${agg.totals.cost_unknown > 0 ? ` (+${agg.totals.cost_unknown} unknown)` : ''}`);
270
+ lines.push(` Cache hit rate : ${agg.cache_hit_rate_pct == null ? 'n/a' : `${agg.cache_hit_rate_pct}%`}`);
271
+
272
+ function section(title, map) {
273
+ const keys = Object.keys(map).sort((a, b) => (map[b].cost || 0) - (map[a].cost || 0));
274
+ if (keys.length === 0) return;
275
+ lines.push('');
276
+ lines.push(title);
277
+ lines.push(' ' + 'name'.padEnd(28) + 'calls'.padStart(7) + 'input'.padStart(11) + 'output'.padStart(9) + ' cost');
278
+ for (const k of keys) {
279
+ const row = map[k];
280
+ lines.push(' ' + k.slice(0, 28).padEnd(28)
281
+ + String(row.calls).padStart(7)
282
+ + row.input.toLocaleString().padStart(11)
283
+ + row.output.toLocaleString().padStart(9)
284
+ + ' $' + row.cost.toFixed(4));
285
+ }
286
+ }
287
+ section('By agent', agg.by_agent);
288
+ section('By command', agg.by_command);
289
+ section('By tier', agg.by_tier);
290
+ section('By day', agg.by_day);
291
+
292
+ return lines.join('\n');
293
+ }
294
+
295
+ /**
296
+ * Render aggregation as an ASCII bar chart of cost per day.
297
+ * @param {Object} agg
298
+ * @returns {string}
299
+ */
300
+ function renderChart(agg) {
301
+ const days = Object.keys(agg.by_day).sort();
302
+ if (days.length === 0) return 'No cost data in window.';
303
+ const max = Math.max(...days.map(d => agg.by_day[d].cost || 0), 0.0001);
304
+ const width = 30;
305
+ const lines = ['=== Cost per day ==='];
306
+ for (const day of days) {
307
+ const cost = agg.by_day[day].cost || 0;
308
+ const len = Math.round((cost / max) * width);
309
+ const bar = '█'.repeat(len) + '░'.repeat(width - len);
310
+ lines.push(` ${day} ${bar} $${cost.toFixed(4)}`);
311
+ }
312
+ lines.push('');
313
+ lines.push(` Total window cost: $${agg.totals.cost_usd.toFixed(4)}`);
314
+ return lines.join('\n');
315
+ }
316
+
317
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
318
+
319
+ function cmdCostReport(cwd, opts, raw) {
320
+ const format = opts?.format || 'json';
321
+ const agg = aggregate(cwd, opts);
322
+ if (format === 'table') {
323
+ output(agg, raw, renderTable(agg));
324
+ } else if (format === 'chart') {
325
+ output(agg, raw, renderChart(agg));
326
+ } else {
327
+ output(agg, raw);
328
+ }
329
+ }
330
+
331
+ function cmdCostAppend(cwd, rec, raw) {
332
+ const result = appendRecord(cwd, rec);
333
+ output(result, raw);
334
+ }
335
+
336
+ function cmdCostClear(cwd, raw) {
337
+ try {
338
+ fs.unlinkSync(tokensFile(cwd));
339
+ output({ cleared: true, file: tokensFile(cwd) }, raw);
340
+ } catch (e) {
341
+ output({ cleared: false, error: e.message }, raw);
342
+ }
343
+ }
344
+
345
+ module.exports = {
346
+ computeCost,
347
+ appendRecord,
348
+ readRecords,
349
+ aggregate,
350
+ renderTable,
351
+ renderChart,
352
+ resolveRate,
353
+ cmdCostReport,
354
+ cmdCostAppend,
355
+ cmdCostClear,
356
+ METRICS_DIR,
357
+ TOKENS_FILE,
358
+ DEFAULT_RATES,
359
+ };
@@ -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 {
@@ -874,6 +876,99 @@ function cmdFocusAuto(cwd, raw, ...args) {
874
876
  return focusAutoInit(cwd, raw, getVal, hasFlag);
875
877
  }
876
878
 
879
+ // ─── Opus 4.7: Reflection gate for focus-auto ───────────────────────────────
880
+
881
+ /**
882
+ * Emit a reflection prompt the orchestrator shows to a thinking-capable model
883
+ * before committing to the next auto cycle. Returns null when reflection is
884
+ * disabled (non-reasoning tier, or explicitly turned off).
885
+ *
886
+ * @param {Object} run - Auto-run state (reads totals, config, category)
887
+ * @param {Object} cycle - The just-completed cycle's telemetry
888
+ * @param {Array} proposedNextBatch - Items focus-scan would select for cycle N+1
889
+ * @param {Object} [opts]
890
+ * @param {string} [opts.tier] - Resolved model tier ('reasoning'|'mid'|'fast')
891
+ * @returns {{reflect: boolean, prompt: string|null, reason: string}}
892
+ */
893
+ function determineContinuation(run, cycle, proposedNextBatch, opts) {
894
+ const { REFLECTION_THRESHOLD } = require('./constants.cjs');
895
+ const tier = opts?.tier || 'mid';
896
+ const configFlag = run?.reflection_enabled;
897
+
898
+ const enabled = configFlag !== undefined
899
+ ? Boolean(configFlag)
900
+ : REFLECTION_THRESHOLD.enabled_default ||
901
+ REFLECTION_THRESHOLD.enable_on_tiers.includes(tier);
902
+
903
+ if (!enabled) {
904
+ return { reflect: false, prompt: null, reason: 'reflection_disabled' };
905
+ }
906
+ if (!Array.isArray(proposedNextBatch) || proposedNextBatch.length === 0) {
907
+ return { reflect: false, prompt: null, reason: 'no_next_batch' };
908
+ }
909
+
910
+ const completed = cycle?.items_completed ?? 0;
911
+ const pointsUsed = cycle?.points_used ?? 0;
912
+ const efficiency = pointsUsed > 0 ? (completed / pointsUsed).toFixed(3) : 'n/a';
913
+ const category = run?.category || 'mixed';
914
+ const cyclesDone = run?.totals?.cycles_completed ?? 0;
915
+ const maxCycles = run?.max_cycles ?? 10;
916
+
917
+ const firstThree = proposedNextBatch.slice(0, 3)
918
+ .map(i => `- ${i.id || '(no id)'}: ${i.description || i.title || '(no description)'}`)
919
+ .join('\n');
920
+
921
+ const prompt = [
922
+ `Reflect before committing to cycle ${cyclesDone + 1} of ${maxCycles} in category "${category}".`,
923
+ '',
924
+ 'Just-completed cycle telemetry:',
925
+ ` items_completed: ${completed}`,
926
+ ` points_used: ${pointsUsed}`,
927
+ ` efficiency: ${efficiency} items/point`,
928
+ '',
929
+ `Next batch candidates (top ${Math.min(3, proposedNextBatch.length)} of ${proposedNextBatch.length}):`,
930
+ firstThree,
931
+ '',
932
+ '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": "..."}',
933
+ ].join('\n');
934
+
935
+ return { reflect: true, prompt, reason: 'ok' };
936
+ }
937
+
938
+ // ─── Opus 4.7: Parallel-tool stage dependency DAG for focus-exec ────────────
939
+
940
+ /**
941
+ * Classify focus-exec items into a simple dependency DAG suitable for
942
+ * emitting parallel-tool-use instructions.
943
+ *
944
+ * Today every item is independent (batches come from focus-plan which
945
+ * already resolves dependencies). But items of tier MICRO can run fully
946
+ * parallel, STANDARD can run within-stage parallel, FULL must serialize.
947
+ * This helper expresses that as independent waves the command template
948
+ * can reference when instructing Opus to emit parallel tool calls.
949
+ *
950
+ * @param {Array<{id?: string, tier?: string}>} items - From readLatestBatch(...).batch
951
+ * @returns {{waves: Array<Array<Object>>, parallelism_hint: string}}
952
+ */
953
+ function classifyStageDependencies(items) {
954
+ const { FOCUS_TIERS } = require('./constants.cjs');
955
+ const safe = Array.isArray(items) ? items : [];
956
+ const micro = safe.filter(i => i && i.tier === FOCUS_TIERS.MICRO);
957
+ const standard = safe.filter(i => i && i.tier === FOCUS_TIERS.STANDARD);
958
+ const full = safe.filter(i => i && i.tier === FOCUS_TIERS.FULL);
959
+
960
+ const waves = [];
961
+ if (micro.length) waves.push(micro);
962
+ if (standard.length) waves.push(standard);
963
+ for (const f of full) waves.push([f]);
964
+
965
+ let hint = 'sequential';
966
+ if (micro.length >= 2) hint = 'emit-micro-in-parallel';
967
+ else if (standard.length >= 2) hint = 'emit-standard-in-parallel';
968
+
969
+ return { waves, parallelism_hint: hint };
970
+ }
971
+
877
972
  // ─── Module exports ─────────────────────────────────────────────────────────
878
973
 
879
974
  module.exports = {
@@ -902,4 +997,7 @@ module.exports = {
902
997
  writeAutoRun,
903
998
  cmdFocusAuto,
904
999
  determineStopReason,
1000
+ // Opus 4.7
1001
+ determineContinuation,
1002
+ classifyStageDependencies,
905
1003
  };
@@ -236,13 +236,13 @@ function cmdInitPlanPhase(cwd, phase, raw) {
236
236
  nyquist_validation_enabled: config.nyquist_validation,
237
237
  commit_docs: config.commit_docs,
238
238
 
239
- // Phase info
239
+ // Phase info — fall back to roadmap data when directory doesn't exist yet
240
240
  phase_found: !!phaseInfo,
241
241
  phase_dir: phaseInfo?.directory || null,
242
- phase_number: phaseInfo?.phase_number || null,
243
- phase_name: phaseInfo?.phase_name || null,
244
- phase_slug: phaseInfo?.phase_slug || null,
245
- padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
242
+ phase_number: phaseInfo?.phase_number || roadmapPhase?.phase_number || null,
243
+ phase_name: phaseInfo?.phase_name || roadmapPhase?.phase_name || null,
244
+ phase_slug: phaseInfo?.phase_slug || (roadmapPhase?.phase_name ? generateSlugInternal(roadmapPhase.phase_name) : null),
245
+ padded_phase: (phaseInfo?.phase_number || roadmapPhase?.phase_number)?.toString().padStart(2, '0') || null,
246
246
  phase_req_ids: phaseReqIds,
247
247
 
248
248
  // Existing artifacts