pan-wizard 2.9.1 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -9
- package/agents/pan-conductor.md +189 -0
- package/agents/pan-counterfactual.md +112 -0
- package/agents/pan-debugger.md +15 -1
- package/agents/pan-distiller.md +82 -0
- package/agents/pan-document_code.md +21 -0
- package/agents/pan-executor.md +16 -0
- package/agents/pan-hardener.md +113 -0
- package/agents/pan-integration-checker.md +2 -0
- package/agents/pan-knowledge.md +81 -0
- package/agents/pan-meta-reviewer.md +91 -0
- package/agents/pan-optimizer.md +242 -0
- package/agents/pan-plan-checker.md +2 -0
- package/agents/pan-previewer.md +98 -0
- package/agents/pan-project-researcher.md +4 -4
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +2 -0
- package/bin/install-lib.cjs +197 -0
- package/bin/install.js +2048 -1959
- package/commands/pan/cost.md +132 -0
- package/commands/pan/exec-phase.md +15 -0
- package/commands/pan/focus-auto.md +168 -3
- package/commands/pan/focus-exec.md +21 -1
- package/commands/pan/focus-scan.md +6 -0
- package/commands/pan/git.md +223 -0
- package/commands/pan/knowledge.md +129 -0
- package/commands/pan/learn.md +61 -0
- package/commands/pan/map-codebase.md +15 -0
- package/commands/pan/mcp-bridge.md +145 -0
- package/commands/pan/milestone-done.md +9 -0
- package/commands/pan/optimize.md +86 -0
- package/commands/pan/plan-phase.md +11 -0
- package/commands/pan/preview.md +114 -0
- package/commands/pan/profile.md +37 -0
- package/commands/pan/review-deep.md +128 -0
- package/commands/pan/verify-phase.md +11 -0
- package/commands/pan/what-if.md +146 -0
- package/hooks/dist/pan-cost-logger.js +102 -0
- package/hooks/dist/pan-statusline.js +154 -108
- package/hooks/dist/pan-trace-logger.js +197 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
- package/pan-wizard-core/bin/lib/bus.cjs +251 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1 -0
- package/pan-wizard-core/bin/lib/constants.cjs +44 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
- package/pan-wizard-core/bin/lib/core.cjs +91 -6
- package/pan-wizard-core/bin/lib/cost.cjs +359 -0
- package/pan-wizard-core/bin/lib/distill.cjs +510 -0
- package/pan-wizard-core/bin/lib/focus.cjs +108 -3
- package/pan-wizard-core/bin/lib/git.cjs +407 -0
- package/pan-wizard-core/bin/lib/init.cjs +5 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
- package/pan-wizard-core/bin/lib/memory.cjs +252 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
- package/pan-wizard-core/bin/lib/phase.cjs +40 -13
- package/pan-wizard-core/bin/lib/preview.cjs +480 -0
- package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
- package/pan-wizard-core/bin/lib/state.cjs +2 -2
- package/pan-wizard-core/bin/lib/verify.cjs +34 -1
- package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
- package/pan-wizard-core/bin/pan-tools.cjs +317 -4
- package/pan-wizard-core/templates/playbook.md +53 -0
- package/pan-wizard-core/templates/preview-report.md +93 -0
- package/pan-wizard-core/templates/roadmap.md +24 -24
- package/pan-wizard-core/templates/state.md +12 -9
- package/pan-wizard-core/workflows/exec-phase.md +97 -0
- package/pan-wizard-core/workflows/learn.md +91 -0
- package/pan-wizard-core/workflows/optimize.md +139 -0
- package/pan-wizard-core/workflows/plan-phase.md +28 -1
- package/pan-wizard-core/workflows/quick.md +7 -0
- package/pan-wizard-core/workflows/verify-phase.md +16 -0
- package/scripts/build-hooks.js +3 -1
|
@@ -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',
|
|
37
|
-
openai: { reasoning: 'inherit', mid: 'mid',
|
|
38
|
-
google: { reasoning: 'inherit', mid: '
|
|
39
|
-
default: { reasoning: 'inherit', mid: 'sonnet',
|
|
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(
|
|
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
|
|
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
|
+
};
|