pan-wizard 2.9.0 → 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 (69) 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/assumptions.md +38 -3
  19. package/commands/pan/audit-deployment.md +6 -0
  20. package/commands/pan/cost.md +132 -0
  21. package/commands/pan/debug.md +71 -2
  22. package/commands/pan/exec-phase.md +105 -0
  23. package/commands/pan/focus-auto.md +199 -18
  24. package/commands/pan/focus-design.md +67 -2
  25. package/commands/pan/focus-exec.md +178 -47
  26. package/commands/pan/focus-scan.md +17 -5
  27. package/commands/pan/knowledge.md +129 -0
  28. package/commands/pan/map-codebase.md +47 -6
  29. package/commands/pan/mcp-bridge.md +145 -0
  30. package/commands/pan/milestone-audit.md +23 -0
  31. package/commands/pan/new-project.md +64 -0
  32. package/commands/pan/pause.md +42 -1
  33. package/commands/pan/plan-phase.md +95 -0
  34. package/commands/pan/preview.md +114 -0
  35. package/commands/pan/profile.md +37 -0
  36. package/commands/pan/quick.md +15 -0
  37. package/commands/pan/resume.md +62 -2
  38. package/commands/pan/review-deep.md +128 -0
  39. package/commands/pan/verify-phase.md +53 -0
  40. package/commands/pan/what-if.md +146 -0
  41. package/hooks/dist/pan-cost-logger.js +102 -0
  42. package/hooks/dist/pan-statusline.js +154 -108
  43. package/package.json +1 -1
  44. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  45. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  46. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  47. package/pan-wizard-core/bin/lib/constants.cjs +42 -1
  48. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  49. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  50. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  51. package/pan-wizard-core/bin/lib/focus.cjs +105 -2
  52. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  53. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  54. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  55. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  56. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  57. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  58. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  59. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  60. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  61. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  62. package/pan-wizard-core/bin/pan-tools.cjs +239 -4
  63. package/pan-wizard-core/templates/playbook.md +53 -0
  64. package/pan-wizard-core/templates/preview-report.md +93 -0
  65. package/pan-wizard-core/templates/roadmap.md +24 -24
  66. package/pan-wizard-core/templates/state.md +12 -9
  67. package/pan-wizard-core/workflows/plan-phase.md +1 -1
  68. package/scripts/build-hooks.js +2 -1
  69. package/scripts/generate-skills-docs.py +560 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Bus — file-backed message channels for agent-to-agent communication
3
+ *
4
+ * Part of Spec B v2 Y-7 infrastructure (v3.0). Enables future hierarchical
5
+ * agent spawning (exec-phase --hierarchical, Wave 5) and inter-agent
6
+ * coordination (review-deep, Wave 3) without committing to an in-process
7
+ * IPC mechanism.
8
+ *
9
+ * Storage model:
10
+ * .planning/bus/<channel>.jsonl — append-only JSON Lines
11
+ * Each line: {ts, source, payload}
12
+ *
13
+ * Channels are created on first publish. Readers use cursor-based drain
14
+ * (read N lines from an offset) or consume-all drain (read + truncate).
15
+ *
16
+ * Concurrent-write safety: each publish opens the file with append flag
17
+ * (`a`) which the OS treats atomically for writes <PIPE_BUF on POSIX and
18
+ * sub-buffer writes on Windows. Entries are single lines. For parallel
19
+ * publishers writing large payloads, see the safety note below.
20
+ *
21
+ * Agent-name / channel-name validation: restricted to
22
+ * `^[a-zA-Z0-9_-]+$` to block path traversal.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { output, error } = require('./core.cjs');
28
+ const { PLANNING_DIR } = require('./constants.cjs');
29
+ const { planningPath } = require('./utils.cjs');
30
+
31
+ const BUS_DIR = 'bus';
32
+ const NAME_RE = /^[a-zA-Z0-9_-]+$/;
33
+ const DEFAULT_DRAIN_LIMIT = 1000;
34
+
35
+ function busDir(cwd) {
36
+ return path.join(planningPath(cwd), BUS_DIR);
37
+ }
38
+
39
+ function channelFile(cwd, channel) {
40
+ return path.join(busDir(cwd), `${channel}.jsonl`);
41
+ }
42
+
43
+ function validateName(name, label) {
44
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
45
+ return `Invalid ${label}: ${name}. Must match ${NAME_RE}`;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function nowIso() {
51
+ return new Date().toISOString();
52
+ }
53
+
54
+ /**
55
+ * Publish a message to a channel. Creates channel file + dir if missing.
56
+ *
57
+ * @param {string} cwd - Project root
58
+ * @param {string} channel - Channel name (validated)
59
+ * @param {*} payload - JSON-serializable payload
60
+ * @param {Object} [opts] - {source: string} — who sent it (agent name, command name)
61
+ * @returns {{published: true, ts: string, file: string, size: number}|{error: string}}
62
+ */
63
+ function publish(cwd, channel, payload, opts) {
64
+ const chErr = validateName(channel, 'channel');
65
+ if (chErr) return { error: chErr };
66
+ const source = opts?.source;
67
+ // Treat null + undefined + empty string as "no source provided".
68
+ if (source !== undefined && source !== null && source !== '') {
69
+ const sErr = validateName(source, 'source');
70
+ if (sErr) return { error: sErr };
71
+ }
72
+
73
+ const normalizedSource = (source !== undefined && source !== null && source !== '') ? source : null;
74
+ let line;
75
+ try {
76
+ line = JSON.stringify({ ts: nowIso(), source: normalizedSource, payload }) + '\n';
77
+ } catch (e) {
78
+ return { error: `payload not JSON-serializable: ${e.message}` };
79
+ }
80
+
81
+ try {
82
+ fs.mkdirSync(busDir(cwd), { recursive: true });
83
+ } catch (e) {
84
+ return { error: `Failed to create bus dir: ${e.message}` };
85
+ }
86
+
87
+ const file = channelFile(cwd, channel);
88
+ try {
89
+ fs.appendFileSync(file, line, { encoding: 'utf-8' });
90
+ } catch (e) {
91
+ return { error: `Failed to append to channel ${channel}: ${e.message}` };
92
+ }
93
+
94
+ let size = 0;
95
+ try { size = fs.statSync(file).size; } catch { /* race — ignore */ }
96
+
97
+ return { published: true, ts: JSON.parse(line).ts, file, size };
98
+ }
99
+
100
+ /**
101
+ * Parse a channel file into an array of entries.
102
+ * @param {string} cwd - Project root
103
+ * @param {string} channel - Channel name
104
+ * @param {Object} [opts] - {offset: number, limit: number}
105
+ * @returns {{entries: Array, total: number, offset: number, more: boolean}|{error: string}}
106
+ */
107
+ function readChannel(cwd, channel, opts) {
108
+ const chErr = validateName(channel, 'channel');
109
+ if (chErr) return { error: chErr };
110
+ const offset = Math.max(0, Number(opts?.offset) || 0);
111
+ const rawLimit = Number(opts?.limit);
112
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : DEFAULT_DRAIN_LIMIT;
113
+
114
+ let raw;
115
+ try {
116
+ raw = fs.readFileSync(channelFile(cwd, channel), 'utf-8');
117
+ } catch {
118
+ return { entries: [], total: 0, offset: 0, more: false };
119
+ }
120
+
121
+ const lines = raw.split('\n').filter(Boolean);
122
+ const total = lines.length;
123
+ const slice = lines.slice(offset, offset + limit);
124
+ const entries = [];
125
+ for (let i = 0; i < slice.length; i++) {
126
+ try {
127
+ entries.push(JSON.parse(slice[i]));
128
+ } catch {
129
+ // Skip malformed lines but don't fail the whole read.
130
+ entries.push({ ts: null, source: null, payload: null, malformed: true, raw: slice[i] });
131
+ }
132
+ }
133
+
134
+ return { entries, total, offset, more: offset + entries.length < total };
135
+ }
136
+
137
+ /**
138
+ * Drain (read + optionally truncate) messages from a channel.
139
+ *
140
+ * Three drain modes:
141
+ * - `peek` (default): read entries, leave file untouched
142
+ * - `consume`: read entries, truncate file to zero bytes
143
+ * - `archive`: read entries, rename file to `<channel>-<ts>.archive.jsonl` so
144
+ * historical data is preserved while the channel restarts empty
145
+ *
146
+ * @param {string} cwd - Project root
147
+ * @param {string} channel - Channel name
148
+ * @param {Object} [opts] - {mode: 'peek'|'consume'|'archive', limit, offset}
149
+ * @returns {Object} Drain result
150
+ */
151
+ function drain(cwd, channel, opts) {
152
+ const mode = opts?.mode || 'peek';
153
+ const read = readChannel(cwd, channel, opts);
154
+ if (read.error) return read;
155
+
156
+ if (mode === 'peek' || read.total === 0) return { ...read, mode };
157
+
158
+ const file = channelFile(cwd, channel);
159
+ if (mode === 'consume') {
160
+ try {
161
+ fs.writeFileSync(file, '', 'utf-8');
162
+ } catch (e) {
163
+ return { ...read, mode, drain_error: e.message };
164
+ }
165
+ } else if (mode === 'archive') {
166
+ const stamp = nowIso().replace(/[:.]/g, '-');
167
+ const archivePath = path.join(busDir(cwd), `${channel}-${stamp}.archive.jsonl`);
168
+ try {
169
+ fs.renameSync(file, archivePath);
170
+ } catch (e) {
171
+ return { ...read, mode, drain_error: e.message };
172
+ }
173
+ } else {
174
+ return { error: `unknown drain mode: ${mode}` };
175
+ }
176
+
177
+ return { ...read, mode };
178
+ }
179
+
180
+ /**
181
+ * List channels + message counts + sizes for observability.
182
+ * @param {string} cwd - Project root
183
+ * @returns {{channels: Array<{channel: string, messages: number, bytes: number, archive: boolean}>}}
184
+ */
185
+ function listChannels(cwd) {
186
+ let files;
187
+ try {
188
+ files = fs.readdirSync(busDir(cwd));
189
+ } catch {
190
+ return { channels: [] };
191
+ }
192
+
193
+ const channels = [];
194
+ for (const f of files) {
195
+ if (!f.endsWith('.jsonl')) continue;
196
+ const archive = f.includes('.archive.');
197
+ const nameBase = f.replace(/\.jsonl$/, '');
198
+ const channel = archive ? nameBase.replace(/\.archive$/, '') : nameBase;
199
+ const filePath = path.join(busDir(cwd), f);
200
+ let bytes = 0;
201
+ let messages = 0;
202
+ try {
203
+ const content = fs.readFileSync(filePath, 'utf-8');
204
+ bytes = Buffer.byteLength(content, 'utf-8');
205
+ messages = content.split('\n').filter(Boolean).length;
206
+ } catch { /* unreadable — skip */ }
207
+ channels.push({ channel, messages, bytes, archive });
208
+ }
209
+ channels.sort((a, b) => a.channel.localeCompare(b.channel) || (a.archive ? 1 : -1));
210
+ return { channels };
211
+ }
212
+
213
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
214
+
215
+ function cmdBusPublish(cwd, channel, rawPayload, opts, raw) {
216
+ if (!channel || rawPayload === undefined) {
217
+ error('Usage: bus publish <channel> <json-payload> [--source <name>]');
218
+ }
219
+ let payload;
220
+ try {
221
+ payload = JSON.parse(rawPayload);
222
+ } catch {
223
+ // Fall back: treat as plain string if not valid JSON.
224
+ payload = rawPayload;
225
+ }
226
+ const result = publish(cwd, channel, payload, opts);
227
+ output(result, raw);
228
+ }
229
+
230
+ function cmdBusDrain(cwd, channel, opts, raw) {
231
+ if (!channel) error('Usage: bus drain <channel> [--mode peek|consume|archive] [--limit N] [--offset N]');
232
+ const result = drain(cwd, channel, opts);
233
+ output(result, raw);
234
+ }
235
+
236
+ function cmdBusList(cwd, raw) {
237
+ output(listChannels(cwd), raw);
238
+ }
239
+
240
+ module.exports = {
241
+ publish,
242
+ readChannel,
243
+ drain,
244
+ listChannels,
245
+ validateName,
246
+ cmdBusPublish,
247
+ cmdBusDrain,
248
+ cmdBusList,
249
+ BUS_DIR,
250
+ DEFAULT_DRAIN_LIMIT,
251
+ };
@@ -721,6 +721,121 @@ function cmdBestPractices(cwd, raw) {
721
721
  output(result, raw);
722
722
  }
723
723
 
724
+ // ─── E-2: Repo token-size estimation (Opus 4.7 single-shot map-codebase) ────
725
+
726
+ /**
727
+ * Estimate total token count of source files in a repository.
728
+ *
729
+ * Uses CHARS_PER_TOKEN as a rough approximation (no tokenizer shipped).
730
+ * Walks source files via walkSourceFiles (respects SKIP_DIRS) plus a curated
731
+ * set of "planning" files the map-codebase agent actually reads (top-level
732
+ * README, package.json, CLAUDE.md, docs/ top level).
733
+ *
734
+ * Returns enough info for map-codebase to choose between single-shot mode
735
+ * (all files fit in 1M context) and sharded mode (6-way parallel today).
736
+ *
737
+ * @param {string} cwd - Project root
738
+ * @param {Object} [opts]
739
+ * @param {number} [opts.threshold=700000] - Single-shot cutoff (tokens)
740
+ * @param {boolean} [opts.include_docs=true] - Whether to include docs/*.md top level
741
+ * @returns {{
742
+ * total_bytes: number,
743
+ * total_tokens: number,
744
+ * threshold: number,
745
+ * mode: 'single-shot'|'sharded',
746
+ * file_count: number,
747
+ * languages: Object<string, number>
748
+ * }}
749
+ */
750
+ function estimateRepoTokenSize(cwd, opts) {
751
+ const { CHARS_PER_TOKEN } = require('./constants.cjs');
752
+ const threshold = (opts && typeof opts.threshold === 'number' && opts.threshold > 0)
753
+ ? opts.threshold
754
+ : 700000;
755
+ const includeDocs = !opts || opts.include_docs !== false;
756
+
757
+ let totalBytes = 0;
758
+ let fileCount = 0;
759
+ const languages = {};
760
+
761
+ // Source files via the existing walker — respects gitignore-like skip list.
762
+ const walked = walkSourceFiles(cwd, cwd);
763
+ for (const [lang, files] of Object.entries(walked.files_by_language)) {
764
+ for (const relPath of files) {
765
+ const abs = path.join(cwd, relPath);
766
+ try {
767
+ const stat = fs.statSync(abs);
768
+ totalBytes += stat.size;
769
+ languages[lang] = (languages[lang] || 0) + stat.size;
770
+ fileCount += 1;
771
+ } catch { /* file may have been removed between walk and stat — ignore */ }
772
+ }
773
+ }
774
+
775
+ // Curated top-level planning files the agent actually reads.
776
+ const planningCandidates = [
777
+ 'README.md',
778
+ 'CLAUDE.md',
779
+ 'AGENTS.md',
780
+ 'package.json',
781
+ 'pyproject.toml',
782
+ 'go.mod',
783
+ 'Cargo.toml',
784
+ ];
785
+ for (const rel of planningCandidates) {
786
+ try {
787
+ const stat = fs.statSync(path.join(cwd, rel));
788
+ if (stat.isFile()) {
789
+ totalBytes += stat.size;
790
+ languages.docs = (languages.docs || 0) + stat.size;
791
+ fileCount += 1;
792
+ }
793
+ } catch { /* missing — expected */ }
794
+ }
795
+
796
+ // docs/*.md at top level only (avoid recursing into specs/decisions/archive).
797
+ if (includeDocs) {
798
+ const docsDir = path.join(cwd, 'docs');
799
+ try {
800
+ const entries = fs.readdirSync(docsDir, { withFileTypes: true });
801
+ for (const e of entries) {
802
+ if (!e.isFile() || !e.name.endsWith('.md')) continue;
803
+ try {
804
+ const stat = fs.statSync(path.join(docsDir, e.name));
805
+ totalBytes += stat.size;
806
+ languages.docs = (languages.docs || 0) + stat.size;
807
+ fileCount += 1;
808
+ } catch { /* ignore */ }
809
+ }
810
+ } catch { /* no docs dir — expected in greenfield */ }
811
+ }
812
+
813
+ const totalTokens = Math.ceil(totalBytes / CHARS_PER_TOKEN);
814
+ const mode = totalTokens <= threshold ? 'single-shot' : 'sharded';
815
+
816
+ return {
817
+ total_bytes: totalBytes,
818
+ total_tokens: totalTokens,
819
+ threshold,
820
+ mode,
821
+ file_count: fileCount,
822
+ languages,
823
+ };
824
+ }
825
+
826
+ function cmdEstimateRepoSize(cwd, raw, args) {
827
+ const thresholdIdx = Array.isArray(args) ? args.indexOf('--threshold') : -1;
828
+ const threshold = thresholdIdx !== -1 && args[thresholdIdx + 1]
829
+ ? Number(args[thresholdIdx + 1])
830
+ : undefined;
831
+ const noDocs = Array.isArray(args) && args.includes('--no-docs');
832
+ const opts = {};
833
+ if (threshold && Number.isFinite(threshold) && threshold > 0) opts.threshold = threshold;
834
+ if (noDocs) opts.include_docs = false;
835
+ const result = estimateRepoTokenSize(cwd, opts);
836
+ output(result, raw);
837
+ }
838
+
724
839
  // ─── Exports ────────────────────────────────────────────────────────────────
725
840
 
726
841
  module.exports = {
@@ -743,4 +858,7 @@ module.exports = {
743
858
  cmdDetectLanguages,
744
859
  cmdAnalyzeImports,
745
860
  cmdBestPractices,
861
+ cmdEstimateRepoSize,
862
+ // E-2
863
+ estimateRepoTokenSize,
746
864
  };
@@ -33,6 +33,7 @@ const CONTEXT_SUFFIX = '-context.md';
33
33
  const RESEARCH_SUFFIX = '-research.md';
34
34
  const VERIFICATION_SUFFIX = '-verification.md';
35
35
  const UAT_SUFFIX = '-uat.md';
36
+ const VALIDATION_SUFFIX = '-validation.md';
36
37
 
37
38
  // ─── File matching helpers ───────────────────────────────────────────────────
38
39
 
@@ -123,7 +124,7 @@ const FOCUS_DIR = 'focus';
123
124
  const AUTO_RUN_FILE = 'auto-run.json';
124
125
 
125
126
  /** Focus auto-runner categories */
126
- const FOCUS_CATEGORIES = ['cleanup', 'tests', 'stability', 'features', 'docs', 'optimize'];
127
+ const FOCUS_CATEGORIES = ['cleanup', 'tests', 'stability', 'features', 'docs', 'optimize', 'prompts'];
127
128
 
128
129
  /** Category → priority index range (indices into PRIORITY_LEVELS) */
129
130
  const CATEGORY_PRIORITY_RANGE = {
@@ -133,6 +134,7 @@ const CATEGORY_PRIORITY_RANGE = {
133
134
  features: { min: 3, max: 5 }, // P3-P5
134
135
  docs: { min: 5, max: 6 }, // P5-P6
135
136
  optimize: { min: 1, max: 4 }, // P1-P4
137
+ prompts: { min: 0, max: 6 }, // P0-P6 (all priorities — prompt order is authoritative)
136
138
  };
137
139
 
138
140
  /** Category → default mode + budget */
@@ -143,6 +145,7 @@ const CATEGORY_DEFAULTS = {
143
145
  features: { mode: 'features', budget: 50 },
144
146
  docs: { mode: 'balanced', budget: 30 },
145
147
  optimize: { mode: 'balanced', budget: 50 },
148
+ prompts: { mode: 'balanced', budget: 100 },
146
149
  };
147
150
 
148
151
  /** Doc files to scan for staleness (focus sync) */
@@ -593,6 +596,37 @@ const AUTORUN_STATUSES = {
593
596
  const FILLED_BLOCK = '\u2588';
594
597
  const EMPTY_BLOCK = '\u2591';
595
598
 
599
+ // ─── Opus 4.7 capability thresholds ─────────────────────────────────────────
600
+ // Used by resolveModel to pick tier given cache/thinking/context hints.
601
+
602
+ /** Context estimate (tokens) above which only 1M-context models (reasoning tier) apply */
603
+ const LARGE_CONTEXT_TOKEN_THRESHOLD = 700000;
604
+ /** Context estimate below which fast tier is viable for cached + non-thinking work */
605
+ const SMALL_CONTEXT_TOKEN_THRESHOLD = 50000;
606
+ /** Files whose content is stable across agent calls in a phase — candidates for prompt caching */
607
+ const CACHEABLE_CONTEXT_FILES = [
608
+ 'project.md',
609
+ 'requirements.md',
610
+ 'roadmap.md',
611
+ 'state.md',
612
+ 'standards.md',
613
+ ];
614
+ /** Default thinking budget (tokens) for verification-heavy agents */
615
+ const THINKING_BUDGETS = {
616
+ 'pan-plan-checker': 8000,
617
+ 'pan-verifier': 6000,
618
+ 'pan-integration-checker': 6000,
619
+ 'pan-reviewer': 4000,
620
+ 'pan-debugger': 8000,
621
+ 'pan-roadmapper': 4000,
622
+ default: 2000,
623
+ };
624
+ /** Whether focus-auto should insert a thinking-gated reflection step between cycles */
625
+ const REFLECTION_THRESHOLD = {
626
+ enabled_default: false,
627
+ enable_on_tiers: ['reasoning'],
628
+ };
629
+
596
630
  module.exports = {
597
631
  // Directories
598
632
  PLANNING_DIR,
@@ -617,6 +651,7 @@ module.exports = {
617
651
  RESEARCH_SUFFIX,
618
652
  VERIFICATION_SUFFIX,
619
653
  UAT_SUFFIX,
654
+ VALIDATION_SUFFIX,
620
655
  // File matchers
621
656
  isPlanFile,
622
657
  isSummaryFile,
@@ -672,6 +707,12 @@ module.exports = {
672
707
  MAX_SLUG_LENGTH,
673
708
  FILLED_BLOCK,
674
709
  EMPTY_BLOCK,
710
+ // Opus 4.7 capabilities
711
+ LARGE_CONTEXT_TOKEN_THRESHOLD,
712
+ SMALL_CONTEXT_TOKEN_THRESHOLD,
713
+ CACHEABLE_CONTEXT_FILES,
714
+ THINKING_BUDGETS,
715
+ REFLECTION_THRESHOLD,
675
716
  CONTEXT_WINDOW,
676
717
  WARNING_THRESHOLD,
677
718
  CRITICAL_THRESHOLD,
@@ -101,6 +101,29 @@ function cmdContextBudget(cwd, raw) {
101
101
  recommendation = `Within budget. ~${additionalPlans} more plans could fit before degradation.`;
102
102
  }
103
103
 
104
+ // E-8: cache metrics — surface how much of the total context would be
105
+ // served from prompt cache when Opus 4.7 cache_control is active.
106
+ const { buildCachedContext } = require('./core.cjs');
107
+ let cache = null;
108
+ try {
109
+ const cached = buildCachedContext(cwd);
110
+ const cacheTokens = Math.ceil(cached.total_bytes / 4); // CHARS_PER_TOKEN ~ 4
111
+ const eligiblePct = totalTokens > 0
112
+ ? Math.round((cacheTokens / totalTokens) * 1000) / 10
113
+ : 0;
114
+ cache = {
115
+ block_count: cached.blocks.length,
116
+ block_paths: cached.blocks.map(b => b.path),
117
+ total_bytes: cached.total_bytes,
118
+ total_tokens: cacheTokens,
119
+ eligible_pct: eligiblePct,
120
+ sha: cached.sha,
121
+ };
122
+ } catch {
123
+ // buildCachedContext failed — surface as null, not as an error.
124
+ cache = null;
125
+ }
126
+
104
127
  const result = {
105
128
  status,
106
129
  currentPhase: currentPhase || null,
@@ -117,6 +140,7 @@ function cmdContextBudget(cwd, raw) {
117
140
  },
118
141
  contextWindow: CONTEXT_WINDOW,
119
142
  budgetUtilization: Math.round(utilization * 1000) / 1000,
143
+ cache,
120
144
  recommendation,
121
145
  };
122
146
 
@@ -136,6 +160,9 @@ function cmdContextBudget(cwd, raw) {
136
160
  ` Total: ${totalTokens.toLocaleString()} / ${CONTEXT_WINDOW.toLocaleString()}`,
137
161
  ``,
138
162
  `Utilization: ${(utilization * 100).toFixed(1)}%`,
163
+ cache && cache.block_count > 0
164
+ ? `Cache: ${cache.block_count} blocks, ${cache.total_tokens.toLocaleString()} tokens (${cache.eligible_pct}% of total)`
165
+ : `Cache: 0 blocks (no cacheable .planning files)`,
139
166
  `${recommendation}`,
140
167
  ];
141
168
  return output(result, true, lines.join('\n'));
@@ -33,10 +33,10 @@ const {
33
33
  * "inherit" means the host runtime uses its own top-tier model selection.
34
34
  */
35
35
  const PROVIDER_MODELS = {
36
- anthropic: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
37
- openai: { reasoning: 'inherit', mid: 'mid', fast: 'fast' },
38
- google: { reasoning: 'inherit', mid: 'mid', fast: 'fast' },
39
- default: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
36
+ anthropic: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
37
+ openai: { reasoning: 'inherit', mid: 'mid', fast: 'fast' },
38
+ google: { reasoning: 'inherit', mid: 'gemini-2.5-flash', fast: 'gemini-2.5-flash-lite' },
39
+ default: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
40
40
  };
41
41
 
42
42
  /** Maps legacy Anthropic model names to provider-agnostic tier aliases. */
@@ -493,7 +493,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
493
493
  const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
494
494
  const section = content.slice(headerIndex, sectionEnd).trim();
495
495
 
496
- const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
496
+ const goalMatch = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
497
497
  const goal = goalMatch ? goalMatch[1].trim() : null;
498
498
 
499
499
  return {
@@ -522,12 +522,49 @@ function getPhaseModelTier(cwd, phaseNum) {
522
522
  return match ? match[1] : null;
523
523
  }
524
524
 
525
+ /**
526
+ * Adjust a resolved tier given Opus 4.7-era capability hints.
527
+ *
528
+ * Rules, in priority order:
529
+ * 1. context_estimate > LARGE_CONTEXT_TOKEN_THRESHOLD → force reasoning (only 1M-ctx tier).
530
+ * 2. needs_thinking → upgrade fast → mid; leave mid/reasoning alone.
531
+ * 3. cache_warm + !needs_thinking + context_estimate < SMALL_CONTEXT_TOKEN_THRESHOLD →
532
+ * allow downgrade mid → fast (cheap, cached, simple tasks don't need mid).
533
+ *
534
+ * @param {string} tier - Baseline tier (reasoning|mid|fast)
535
+ * @param {Object} [opts] - {context_estimate, needs_thinking, cache_warm}
536
+ * @returns {string} Possibly-adjusted tier
537
+ */
538
+ function adjustTierForCapabilities(tier, opts) {
539
+ if (!opts) return tier;
540
+ const { context_estimate, needs_thinking, cache_warm } = opts;
541
+ const { LARGE_CONTEXT_TOKEN_THRESHOLD, SMALL_CONTEXT_TOKEN_THRESHOLD } = require('./constants.cjs');
542
+
543
+ if (typeof context_estimate === 'number' && context_estimate > LARGE_CONTEXT_TOKEN_THRESHOLD) {
544
+ return 'reasoning';
545
+ }
546
+ if (needs_thinking && tier === 'fast') {
547
+ return 'mid';
548
+ }
549
+ if (
550
+ cache_warm &&
551
+ !needs_thinking &&
552
+ typeof context_estimate === 'number' &&
553
+ context_estimate < SMALL_CONTEXT_TOKEN_THRESHOLD &&
554
+ tier === 'mid'
555
+ ) {
556
+ return 'fast';
557
+ }
558
+ return tier;
559
+ }
560
+
525
561
  /**
526
562
  * Resolve the model for a given agent type based on profile, provider, and routing strategy.
527
563
  * Returns "inherit" for reasoning-tier to let the host runtime use its top-tier model.
528
564
  * @param {string} cwd - Project root directory
529
565
  * @param {string} agentType - Agent name (e.g., "pan-planner", "pan-executor")
530
- * @param {Object} [taskMetadata] - Optional metadata for complexity routing
566
+ * @param {Object} [taskMetadata] - Optional metadata. Supports complexity fields and
567
+ * Opus 4.7 capability hints: {context_estimate, needs_thinking, cache_warm}.
531
568
  * @returns {string} Model identifier: "inherit", "sonnet", "haiku", "mid", "fast", etc.
532
569
  */
533
570
  function resolveModelInternal(cwd, agentType, taskMetadata) {
@@ -562,6 +599,15 @@ function resolveModelInternal(cwd, agentType, taskMetadata) {
562
599
  tier = resolveComplexityTier(tier, { ...taskMetadata, thresholds });
563
600
  }
564
601
 
602
+ // Opus 4.7 capability adjustment (only when hints are present)
603
+ if (taskMetadata && (
604
+ taskMetadata.context_estimate !== undefined ||
605
+ taskMetadata.needs_thinking !== undefined ||
606
+ taskMetadata.cache_warm !== undefined
607
+ )) {
608
+ tier = adjustTierForCapabilities(tier, taskMetadata);
609
+ }
610
+
565
611
  return resolveTierToModel(tier, provider);
566
612
  }
567
613
 
@@ -731,6 +777,43 @@ function scanPendingTodos(cwd, area) {
731
777
  * @param {string} cwd - Project root
732
778
  * @returns {{ count: number, items: Array<{file: string, line: number, tag: string, text: string}> }}
733
779
  */
780
+ /**
781
+ * Build an ordered list of cacheable context blocks for agent prompts.
782
+ *
783
+ * Reads files from .planning/ that are stable across agent calls within a phase
784
+ * (project.md, requirements.md, roadmap.md, state.md, standards.md). Each block
785
+ * is tagged `cache: true` so the host runtime (or installer) can translate to
786
+ * the appropriate per-runtime caching syntax (Anthropic cache_control, etc.).
787
+ *
788
+ * Files that don't exist are skipped silently. The order matches the file list
789
+ * in constants.cjs to keep prompt prefixes byte-stable across calls (which is
790
+ * what cache key matching requires).
791
+ *
792
+ * @param {string} cwd - Project root
793
+ * @returns {{blocks: Array<{path: string, content: string, cache: true}>, total_bytes: number, sha: string}}
794
+ */
795
+ function buildCachedContext(cwd) {
796
+ const { PLANNING_DIR, CACHEABLE_CONTEXT_FILES } = require('./constants.cjs');
797
+ const crypto = require('crypto');
798
+ const blocks = [];
799
+ let totalBytes = 0;
800
+ const hasher = crypto.createHash('sha256');
801
+
802
+ for (const file of CACHEABLE_CONTEXT_FILES) {
803
+ const abs = path.join(cwd, PLANNING_DIR, file);
804
+ try {
805
+ const content = fs.readFileSync(abs, 'utf-8');
806
+ blocks.push({ path: toPosix(path.join(PLANNING_DIR, file)), content, cache: true });
807
+ totalBytes += Buffer.byteLength(content, 'utf-8');
808
+ hasher.update(file + '\0' + content + '\0');
809
+ } catch {
810
+ // Missing files are expected (e.g. standards.md in non-regulated projects).
811
+ }
812
+ }
813
+
814
+ return { blocks, total_bytes: totalBytes, sha: hasher.digest('hex').slice(0, 16) };
815
+ }
816
+
734
817
  function scanSourceTodos(cwd) {
735
818
  const items = [];
736
819
  const libDir = path.join(cwd, 'pan-wizard-core', 'bin', 'lib');
@@ -783,6 +866,7 @@ module.exports = {
783
866
  getArchivedPhaseDirs,
784
867
  getRoadmapPhaseInternal,
785
868
  resolveModelInternal,
869
+ adjustTierForCapabilities,
786
870
  detectProvider,
787
871
  resolveTierToModel,
788
872
  resolveComplexityTier,
@@ -792,6 +876,7 @@ module.exports = {
792
876
  generateSlugInternal,
793
877
  getMilestoneInfo,
794
878
  toPosix,
879
+ buildCachedContext,
795
880
  scanPendingTodos,
796
881
  scanSourceTodos,
797
882
  };