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
@@ -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', 'prompts'];
127
+ const FOCUS_CATEGORIES = ['cleanup', 'tests', 'stability', 'features', 'docs', 'optimize', 'prompts', 'security', 'distill'];
127
128
 
128
129
  /** Category → priority index range (indices into PRIORITY_LEVELS) */
129
130
  const CATEGORY_PRIORITY_RANGE = {
@@ -134,6 +135,8 @@ const CATEGORY_PRIORITY_RANGE = {
134
135
  docs: { min: 5, max: 6 }, // P5-P6
135
136
  optimize: { min: 1, max: 4 }, // P1-P4
136
137
  prompts: { min: 0, max: 6 }, // P0-P6 (all priorities — prompt order is authoritative)
138
+ security: { min: 0, max: 2 }, // P0-P2 (critical/high/medium only — low/info skipped)
139
+ distill: { min: 1, max: 5 }, // P1-P5 (AI bloat: structural quality, not safety-critical)
137
140
  };
138
141
 
139
142
  /** Category → default mode + budget */
@@ -145,6 +148,8 @@ const CATEGORY_DEFAULTS = {
145
148
  docs: { mode: 'balanced', budget: 30 },
146
149
  optimize: { mode: 'balanced', budget: 50 },
147
150
  prompts: { mode: 'balanced', budget: 100 },
151
+ security: { mode: 'bugfix', budget: 40 },
152
+ distill: { mode: 'balanced', budget: 50 },
148
153
  };
149
154
 
150
155
  /** Doc files to scan for staleness (focus sync) */
@@ -595,6 +600,37 @@ const AUTORUN_STATUSES = {
595
600
  const FILLED_BLOCK = '\u2588';
596
601
  const EMPTY_BLOCK = '\u2591';
597
602
 
603
+ // ─── Opus 4.7 capability thresholds ─────────────────────────────────────────
604
+ // Used by resolveModel to pick tier given cache/thinking/context hints.
605
+
606
+ /** Context estimate (tokens) above which only 1M-context models (reasoning tier) apply */
607
+ const LARGE_CONTEXT_TOKEN_THRESHOLD = 700000;
608
+ /** Context estimate below which fast tier is viable for cached + non-thinking work */
609
+ const SMALL_CONTEXT_TOKEN_THRESHOLD = 50000;
610
+ /** Files whose content is stable across agent calls in a phase — candidates for prompt caching */
611
+ const CACHEABLE_CONTEXT_FILES = [
612
+ 'project.md',
613
+ 'requirements.md',
614
+ 'roadmap.md',
615
+ 'state.md',
616
+ 'standards.md',
617
+ ];
618
+ /** Default thinking budget (tokens) for verification-heavy agents */
619
+ const THINKING_BUDGETS = {
620
+ 'pan-plan-checker': 8000,
621
+ 'pan-verifier': 6000,
622
+ 'pan-integration-checker': 6000,
623
+ 'pan-reviewer': 4000,
624
+ 'pan-debugger': 8000,
625
+ 'pan-roadmapper': 4000,
626
+ default: 2000,
627
+ };
628
+ /** Whether focus-auto should insert a thinking-gated reflection step between cycles */
629
+ const REFLECTION_THRESHOLD = {
630
+ enabled_default: false,
631
+ enable_on_tiers: ['reasoning'],
632
+ };
633
+
598
634
  module.exports = {
599
635
  // Directories
600
636
  PLANNING_DIR,
@@ -619,6 +655,7 @@ module.exports = {
619
655
  RESEARCH_SUFFIX,
620
656
  VERIFICATION_SUFFIX,
621
657
  UAT_SUFFIX,
658
+ VALIDATION_SUFFIX,
622
659
  // File matchers
623
660
  isPlanFile,
624
661
  isSummaryFile,
@@ -674,6 +711,12 @@ module.exports = {
674
711
  MAX_SLUG_LENGTH,
675
712
  FILLED_BLOCK,
676
713
  EMPTY_BLOCK,
714
+ // Opus 4.7 capabilities
715
+ LARGE_CONTEXT_TOKEN_THRESHOLD,
716
+ SMALL_CONTEXT_TOKEN_THRESHOLD,
717
+ CACHEABLE_CONTEXT_FILES,
718
+ THINKING_BUDGETS,
719
+ REFLECTION_THRESHOLD,
677
720
  CONTEXT_WINDOW,
678
721
  WARNING_THRESHOLD,
679
722
  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
  };
@@ -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
+ };