pan-wizard 3.22.0 → 3.25.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 +38 -24
- package/agents/pan-conductor.md +4 -4
- package/agents/pan-design-checker.md +83 -0
- package/agents/pan-designer.md +67 -0
- package/agents/pan-document_code.md +3 -2
- package/agents/pan-executor.md +2 -2
- package/agents/pan-plan-checker.md +19 -6
- package/agents/pan-planner.md +4 -1
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-roadmapper.md +3 -1
- package/agents/pan-verifier.md +10 -10
- package/bin/install-lib.cjs +128 -17
- package/bin/install.js +480 -74
- package/commands/pan/army.md +34 -14
- package/commands/pan/audit-deployment.md +17 -14
- package/commands/pan/cost.md +2 -2
- package/commands/pan/debug.md +1 -1
- package/commands/pan/design-phase.md +77 -0
- package/commands/pan/exec-phase.md +2 -2
- package/commands/pan/experiment.md +18 -14
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/focus-design.md +13 -1
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/learn.md +1 -1
- package/commands/pan/links.md +3 -1
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-new.md +3 -1
- package/commands/pan/optimize.md +2 -2
- package/commands/pan/patches.md +1 -1
- package/commands/pan/plan-phase.md +3 -1
- package/commands/pan/preview.md +3 -3
- package/commands/pan/profile.md +3 -3
- package/commands/pan/research-phase.md +1 -1
- package/commands/pan/retro.md +4 -1
- package/commands/pan/review-deep.md +1 -1
- package/commands/pan/settings.md +2 -2
- package/commands/pan/what-if.md +1 -1
- package/hooks/dist/pan-check-update.js +149 -38
- package/hooks/dist/pan-context-monitor.js +155 -81
- package/hooks/dist/pan-cost-logger.js +288 -13
- package/hooks/dist/pan-statusline.js +24 -8
- package/hooks/dist/pan-stop-guard.js +160 -0
- package/hooks/dist/pan-trace-logger.js +261 -19
- package/package.json +2 -2
- package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
- package/pan-wizard-core/bin/lib/bus.cjs +19 -4
- package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
- package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
- package/pan-wizard-core/bin/lib/commands.cjs +88 -6
- package/pan-wizard-core/bin/lib/config.cjs +27 -2
- package/pan-wizard-core/bin/lib/constants.cjs +1 -0
- package/pan-wizard-core/bin/lib/core.cjs +113 -8
- package/pan-wizard-core/bin/lib/cost.cjs +61 -24
- package/pan-wizard-core/bin/lib/distill.cjs +22 -4
- package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
- package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
- package/pan-wizard-core/bin/lib/focus.cjs +42 -7
- package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
- package/pan-wizard-core/bin/lib/git.cjs +51 -16
- package/pan-wizard-core/bin/lib/hud.cjs +17 -2
- package/pan-wizard-core/bin/lib/init.cjs +2 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
- package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
- package/pan-wizard-core/bin/lib/links.cjs +9 -6
- package/pan-wizard-core/bin/lib/lock.cjs +23 -4
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +11 -6
- package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
- package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
- package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
- package/pan-wizard-core/bin/lib/phase.cjs +82 -11
- package/pan-wizard-core/bin/lib/preview.cjs +23 -4
- package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
- package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
- package/pan-wizard-core/bin/lib/runner.cjs +17 -7
- package/pan-wizard-core/bin/lib/squads.cjs +43 -11
- package/pan-wizard-core/bin/lib/state.cjs +32 -11
- package/pan-wizard-core/bin/lib/template.cjs +8 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
- package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
- package/pan-wizard-core/bin/lib/verify.cjs +84 -39
- package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
- package/pan-wizard-core/bin/lib/worktree.cjs +98 -0
- package/pan-wizard-core/bin/pan-tools.cjs +45 -20
- package/pan-wizard-core/learnings/index.json +7 -7
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
- package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
- package/pan-wizard-core/references/design-methodology.md +94 -0
- package/pan-wizard-core/references/git-integration.md +9 -9
- package/pan-wizard-core/references/guardrails.md +4 -1
- package/pan-wizard-core/references/model-profile-resolution.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +44 -34
- package/pan-wizard-core/references/planning-config.md +5 -5
- package/pan-wizard-core/references/verification-patterns.md +67 -0
- package/pan-wizard-core/templates/codebase/architecture.md +1 -1
- package/pan-wizard-core/templates/codebase/concerns.md +1 -1
- package/pan-wizard-core/templates/codebase/conventions.md +1 -1
- package/pan-wizard-core/templates/codebase/integrations.md +1 -1
- package/pan-wizard-core/templates/codebase/stack.md +1 -1
- package/pan-wizard-core/templates/codebase/structure.md +1 -1
- package/pan-wizard-core/templates/codebase/testing.md +1 -1
- package/pan-wizard-core/templates/design.md +146 -0
- package/pan-wizard-core/templates/discovery.md +3 -3
- package/pan-wizard-core/templates/milestone-archive.md +2 -2
- package/pan-wizard-core/templates/playbook.md +1 -1
- package/pan-wizard-core/templates/uat.md +1 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
- package/pan-wizard-core/workflows/exec-phase.md +28 -8
- package/pan-wizard-core/workflows/execute-plan.md +2 -3
- package/pan-wizard-core/workflows/health.md +24 -1
- package/pan-wizard-core/workflows/help.md +5 -4
- package/pan-wizard-core/workflows/learn.md +5 -5
- package/pan-wizard-core/workflows/map-codebase.md +1 -1
- package/pan-wizard-core/workflows/milestone-audit.md +1 -1
- package/pan-wizard-core/workflows/milestone-new.md +4 -4
- package/pan-wizard-core/workflows/new-project.md +47 -32
- package/pan-wizard-core/workflows/optimize.md +8 -8
- package/pan-wizard-core/workflows/pause.md +1 -1
- package/pan-wizard-core/workflows/phase-tests.md +2 -2
- package/pan-wizard-core/workflows/plan-phase.md +4 -0
- package/pan-wizard-core/workflows/profile.md +13 -10
- package/pan-wizard-core/workflows/research-phase.md +1 -1
- package/pan-wizard-core/workflows/resume-project.md +1 -1
- package/pan-wizard-core/workflows/settings.md +9 -9
- package/pan-wizard-core/workflows/transition.md +24 -8
- package/pan-wizard-core/workflows/update.md +23 -20
- package/pan-wizard-core/workflows/verify-phase.md +7 -2
- package/pan-zcode/README.md +12 -2
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/pan-zcode/mcp/native-tools.cjs +1 -1
- package/pan-zcode/mcp/server.cjs +76 -14
- package/scripts/build-hooks.js +2 -1
- package/scripts/generate-skills-docs.py +0 -4
- package/scripts/run-tests.cjs +11 -4
|
@@ -274,6 +274,17 @@ function cmdEstimateCost(cwd, raw) {
|
|
|
274
274
|
* @param {boolean} [opts.force] - Skip deleted-file safety check
|
|
275
275
|
* @returns {void}
|
|
276
276
|
*/
|
|
277
|
+
/**
|
|
278
|
+
* List the paths currently in the index (staged), or [] if git says nothing.
|
|
279
|
+
* @param {string} cwd - Working directory
|
|
280
|
+
* @returns {string[]} Staged paths, as git reports them
|
|
281
|
+
*/
|
|
282
|
+
function stagedFiles(cwd) {
|
|
283
|
+
const r = execGit(cwd, ['diff', '--cached', '--name-only']);
|
|
284
|
+
if (r.exitCode !== 0 || !r.stdout) return [];
|
|
285
|
+
return r.stdout.split('\n').filter(Boolean);
|
|
286
|
+
}
|
|
287
|
+
|
|
277
288
|
/**
|
|
278
289
|
* Run commit safety checks (deleted files, sensitive patterns).
|
|
279
290
|
* @param {string} cwd - Working directory
|
|
@@ -315,7 +326,22 @@ function runCommitSafetyChecks(cwd, config, force) {
|
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
328
|
if (safetyChecks.sensitive_files_blocked.length > 0) {
|
|
318
|
-
|
|
329
|
+
// The hint must not say "unstage them" — cmdCommit now unstages PAN's own
|
|
330
|
+
// additions before returning, so that advice would describe work already done.
|
|
331
|
+
// It matters that the user knows this can be a FALSE positive: the patterns
|
|
332
|
+
// match the PATH, never file content, and the bare words in
|
|
333
|
+
// DEFAULT_SENSITIVE_PATTERNS (credentials/secret/password/token) are unanchored,
|
|
334
|
+
// so an ordinary planning doc under a phase like `03-secrets-design/` trips it
|
|
335
|
+
// with no secret anywhere. Without that context the block reads as "PAN found a
|
|
336
|
+
// secret in your files", which is not what was checked.
|
|
337
|
+
return {
|
|
338
|
+
blocked: true,
|
|
339
|
+
reason: 'sensitive_file_detected',
|
|
340
|
+
safetyChecks,
|
|
341
|
+
hint: 'Matched on filename/path, not content — this can be a false positive. '
|
|
342
|
+
+ 'If the file is safe, narrow commit.sensitive_patterns in .planning/config.json '
|
|
343
|
+
+ 'or commit it yourself; if it is a real secret, gitignore it.',
|
|
344
|
+
};
|
|
319
345
|
}
|
|
320
346
|
|
|
321
347
|
return { blocked: false, safetyChecks };
|
|
@@ -331,6 +357,10 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
|
|
|
331
357
|
const failOnError = opts && opts.failOnError;
|
|
332
358
|
|
|
333
359
|
if (!isGitRepo(cwd)) {
|
|
360
|
+
// No error key, exit 0. Unlike `git commit` (an explicit git command, where a
|
|
361
|
+
// missing repo IS the failure), commit-docs is an auto-commit convenience and
|
|
362
|
+
// PAN fully supports non-git projects - the skip is the expected outcome of the
|
|
363
|
+
// environment the caller chose, not a malfunction.
|
|
334
364
|
output({ committed: false, hash: null, reason: 'not_a_git_repo', hint: 'Run git init to initialize a repository' }, raw, 'not a git repo');
|
|
335
365
|
return;
|
|
336
366
|
}
|
|
@@ -345,14 +375,25 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
|
|
|
345
375
|
const config = loadConfig(cwd);
|
|
346
376
|
|
|
347
377
|
if (!config.commit_docs) {
|
|
378
|
+
// No error key, exit 0: the user set commit_docs=false. Honouring configuration
|
|
379
|
+
// is a success; exiting non-zero here would break every workflow of every user
|
|
380
|
+
// who opted out of doc commits.
|
|
348
381
|
output({ committed: false, hash: null, reason: 'skipped_commit_docs_false' }, raw, 'skipped');
|
|
349
382
|
return;
|
|
350
383
|
}
|
|
351
384
|
if (isGitIgnored(cwd, PLANNING_DIR)) {
|
|
385
|
+
// No error key, exit 0: .planning/ is gitignored - also the user's choice.
|
|
352
386
|
output({ committed: false, hash: null, reason: 'skipped_gitignored' }, raw, 'skipped');
|
|
353
387
|
return;
|
|
354
388
|
}
|
|
355
389
|
|
|
390
|
+
// Snapshot the index BEFORE staging so a blocked commit can put it back exactly
|
|
391
|
+
// as the user left it. The safety check reads `git diff --cached`, so it can only
|
|
392
|
+
// see what staging produced — `--files .planning/` is a directory, and the file
|
|
393
|
+
// that trips the check is only discoverable after `git add`. So we stage, check,
|
|
394
|
+
// and undo on refusal, rather than trying to predict the expansion ourselves.
|
|
395
|
+
const preStaged = new Set(stagedFiles(cwd));
|
|
396
|
+
|
|
356
397
|
// Stage files
|
|
357
398
|
const filesToStage = files && files.length > 0 ? files : [PLANNING_DIR + '/'];
|
|
358
399
|
for (const file of filesToStage) execGit(cwd, ['add', file]);
|
|
@@ -360,7 +401,22 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
|
|
|
360
401
|
// Safety checks
|
|
361
402
|
const safety = runCommitSafetyChecks(cwd, config, force);
|
|
362
403
|
if (safety.blocked) {
|
|
363
|
-
|
|
404
|
+
// Unstage what WE just added. Leaving it staged was the whole harm: the check
|
|
405
|
+
// that exists to keep a secret out of git was what put it INTO the index, where
|
|
406
|
+
// the next `git commit` from any source — the user, an IDE, another tool — would
|
|
407
|
+
// have included it. It also wedged every later `pan-tools commit`, since the
|
|
408
|
+
// offending file stayed staged and kept tripping the same check, so an autonomous
|
|
409
|
+
// run silently stopped persisting its plans behind a message that reads like a
|
|
410
|
+
// safety success. Restore only OUR additions; anything the user had staged before
|
|
411
|
+
// this call is theirs and stays untouched.
|
|
412
|
+
const added = stagedFiles(cwd).filter(f => !preStaged.has(f));
|
|
413
|
+
for (const f of added) execGit(cwd, ['reset', '--quiet', '--', f]);
|
|
414
|
+
safety.safetyChecks.unstaged_by_pan = added;
|
|
415
|
+
// error key => exit 1. A blocked commit is a refusal that protected something
|
|
416
|
+
// (a staged deletion, a secret) - the docs did NOT land, which is the same harm
|
|
417
|
+
// as commit_failed for an autonomous loop. The JSON body still goes to stdout,
|
|
418
|
+
// so callers that parse `reason`/`safety_checks` are unaffected.
|
|
419
|
+
output({ committed: false, hash: null, reason: safety.reason, error: 'commit_blocked', safety_checks: safety.safetyChecks, hint: safety.hint }, raw, 'blocked');
|
|
364
420
|
return;
|
|
365
421
|
}
|
|
366
422
|
|
|
@@ -378,7 +434,12 @@ function cmdCommit(cwd, message, files, raw, amend, opts) {
|
|
|
378
434
|
if (failOnError) {
|
|
379
435
|
error('commit_failed: ' + (commitResult.stderr || 'unknown git error').trim());
|
|
380
436
|
}
|
|
381
|
-
|
|
437
|
+
// `|| 'unknown git error'`: the exit code is derived from a TRUTHY error key, and
|
|
438
|
+
// git does not always write to stderr — an empty string would launder this real
|
|
439
|
+
// failure back into exit 0. (experiment.cjs documents the harm: an autonomous run
|
|
440
|
+
// took `committed: false, reason: commit_failed` with exit 0 and kept going,
|
|
441
|
+
// leaving 24 minutes of work uncommitted.)
|
|
442
|
+
output({ committed: false, hash: null, reason: 'commit_failed', error: commitResult.stderr || 'unknown git error' }, raw, 'failed');
|
|
382
443
|
return;
|
|
383
444
|
}
|
|
384
445
|
|
|
@@ -471,6 +532,10 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
471
532
|
|
|
472
533
|
if (!apiKey) {
|
|
473
534
|
// No key = silent skip, agent falls back to built-in WebSearch
|
|
535
|
+
// No error key, exit 0: an unconfigured capability is an ANSWER ("web search is
|
|
536
|
+
// unavailable") and callers degrade gracefully. Contrast the sites below - a
|
|
537
|
+
// configured search that then fails (bad query, API 5xx, network) IS a failure
|
|
538
|
+
// and carries `error`. Unconfigured vs broken is the distinction.
|
|
474
539
|
output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, '');
|
|
475
540
|
return;
|
|
476
541
|
}
|
|
@@ -525,7 +590,7 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
525
590
|
}, raw, results.map(item => `${item.title}\n${item.url}\n${item.description}`).join('\n\n'));
|
|
526
591
|
} catch (err) {
|
|
527
592
|
// Network error, DNS failure, or JSON parse error from Brave API
|
|
528
|
-
output({ available: false, error: err.message }, raw, '');
|
|
593
|
+
output({ available: false, error: err.message || 'web_search_failed' }, raw, '');
|
|
529
594
|
}
|
|
530
595
|
}
|
|
531
596
|
|
|
@@ -641,7 +706,11 @@ function renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries
|
|
|
641
706
|
try { phaseDirEntries = fs.readdirSync(phasesDir); } catch { phaseDirEntries = []; }
|
|
642
707
|
|
|
643
708
|
for (const phase of phases) {
|
|
644
|
-
|
|
709
|
+
// Normalize first — roadmap headings are unpadded ("Phase 1") while phase
|
|
710
|
+
// directories are zero-padded ("01-foundation"), so the raw number never
|
|
711
|
+
// matched. Same defect as focus.cjs collectWorkItems; see the note there.
|
|
712
|
+
const normalizedNum = normalizePhaseName(phase.number);
|
|
713
|
+
const match = phaseDirEntries.find(d => d.startsWith(normalizedNum + '-') || d === normalizedNum);
|
|
645
714
|
if (!match) continue;
|
|
646
715
|
const phaseDir = path.join(phasesDir, match);
|
|
647
716
|
try {
|
|
@@ -769,6 +838,14 @@ function cmdScaffold(cwd, type, options, raw) {
|
|
|
769
838
|
error(`Phase ${phase} directory not found`);
|
|
770
839
|
}
|
|
771
840
|
|
|
841
|
+
// Every artifact type except phase-dir writes into a resolved phase directory.
|
|
842
|
+
// Without --phase there is no directory to write to, so guard before the
|
|
843
|
+
// switch — otherwise path.join(phaseDir, …) below crashes with a raw
|
|
844
|
+
// TypeError instead of a usage error.
|
|
845
|
+
if (!phaseDir && type !== 'phase-dir') {
|
|
846
|
+
error(`--phase required for ${type} scaffold`);
|
|
847
|
+
}
|
|
848
|
+
|
|
772
849
|
let filePath, content;
|
|
773
850
|
|
|
774
851
|
switch (type) {
|
|
@@ -812,6 +889,9 @@ function cmdScaffold(cwd, type, options, raw) {
|
|
|
812
889
|
fs.writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'wx' });
|
|
813
890
|
} catch (e) {
|
|
814
891
|
if (e.code === 'EEXIST') {
|
|
892
|
+
// No error key, exit 0: scaffolding is idempotent - the file the caller asked
|
|
893
|
+
// for exists, which is the state it wanted. `created:false` reports only that
|
|
894
|
+
// this call was not the one that made it.
|
|
815
895
|
output({ created: false, reason: 'already_exists', path: relPath }, raw, 'exists');
|
|
816
896
|
return;
|
|
817
897
|
}
|
|
@@ -880,6 +960,7 @@ function cmdBatchCommit(cwd, items, raw) {
|
|
|
880
960
|
return;
|
|
881
961
|
}
|
|
882
962
|
if (!Array.isArray(items) || items.length === 0) {
|
|
963
|
+
// No error key, exit 0: an empty batch is a legitimate no-op.
|
|
883
964
|
output({ committed: false, reason: 'no_items' }, raw, 'no items');
|
|
884
965
|
return;
|
|
885
966
|
}
|
|
@@ -904,7 +985,8 @@ function cmdBatchCommit(cwd, items, raw) {
|
|
|
904
985
|
const message = 'docs: focus-exec batch — ' + items.length + ' items completed\n\n' + titles;
|
|
905
986
|
const commitResult = execGit(cwd, ['commit', '-m', message]);
|
|
906
987
|
if (commitResult.exitCode !== 0) {
|
|
907
|
-
|
|
988
|
+
// See cmdCommitDocs above: a truthy error key is what makes this exit non-zero.
|
|
989
|
+
output({ committed: false, reason: 'commit_failed', error: commitResult.stderr || 'unknown git error' }, raw, 'failed');
|
|
908
990
|
return;
|
|
909
991
|
}
|
|
910
992
|
|
|
@@ -140,6 +140,12 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
140
140
|
error('Usage: config-set <key.path> <value>');
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// A missing value would be assigned as `undefined`, which JSON.stringify drops
|
|
144
|
+
// — so the command reported updated:true while writing nothing. Reject it.
|
|
145
|
+
if (value === undefined) {
|
|
146
|
+
error('Usage: config-set <key.path> <value>');
|
|
147
|
+
}
|
|
148
|
+
|
|
143
149
|
// Parse value (handle booleans and numbers)
|
|
144
150
|
let parsedValue = value;
|
|
145
151
|
if (value === 'true') parsedValue = true;
|
|
@@ -157,6 +163,24 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
// Valid JSON of the wrong SHAPE is still unusable, and used to fail two different
|
|
167
|
+
// silent ways. `null` crashed with a raw TypeError stack dump ("Cannot set
|
|
168
|
+
// properties of null") because the traversal assigned straight into it. An array,
|
|
169
|
+
// string or number took the assignment without complaint and then serialized
|
|
170
|
+
// without the key, so the command reported {"updated": true} at exit 0 while
|
|
171
|
+
// persisting nothing — the user is told their setting took effect and PAN keeps
|
|
172
|
+
// using the old one forever.
|
|
173
|
+
//
|
|
174
|
+
// Refuse rather than overwrite: a config.json that is not an object is the user's
|
|
175
|
+
// data in an unexpected state, and replacing it wholesale would be the
|
|
176
|
+
// settings.json data-loss bug in another file.
|
|
177
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
178
|
+
error(
|
|
179
|
+
`config.json is not a JSON object (found ${config === null ? 'null' : Array.isArray(config) ? 'an array' : typeof config})`
|
|
180
|
+
+ ` — refusing to overwrite it. Fix or remove ${configPath} and re-run.`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
160
184
|
// Traverse the dot-notation key path to build nested objects.
|
|
161
185
|
// For a path like "workflow.research", this loop walks through each
|
|
162
186
|
// segment except the last, creating intermediate objects as needed.
|
|
@@ -558,7 +582,7 @@ function cmdStandardsPhaseTrack(cwd, phaseNum, raw) {
|
|
|
558
582
|
if (!combinedContent) {
|
|
559
583
|
output({
|
|
560
584
|
phase: phaseNum,
|
|
561
|
-
phase_name: phase.
|
|
585
|
+
phase_name: phase.phase_name,
|
|
562
586
|
relevant_standards: [],
|
|
563
587
|
compliance: [],
|
|
564
588
|
message: 'No plan files found in phase',
|
|
@@ -578,7 +602,7 @@ function cmdStandardsPhaseTrack(cwd, phaseNum, raw) {
|
|
|
578
602
|
|
|
579
603
|
output({
|
|
580
604
|
phase: phaseNum,
|
|
581
|
-
phase_name: phase.
|
|
605
|
+
phase_name: phase.phase_name,
|
|
582
606
|
relevant_standards: detectedIds,
|
|
583
607
|
compliance,
|
|
584
608
|
}, raw, compliance.map(c => `${c.standard_id}: ${c.selected ? c.coverage || 'N/A' : 'not selected'}`).join('\n'));
|
|
@@ -637,6 +661,7 @@ function cmdStandardsTools(cwd, standardId, raw) {
|
|
|
637
661
|
}
|
|
638
662
|
|
|
639
663
|
module.exports = {
|
|
664
|
+
buildConfigDefaults,
|
|
640
665
|
cmdConfigEnsureSection,
|
|
641
666
|
cmdConfigSet,
|
|
642
667
|
cmdConfigGet,
|
|
@@ -653,6 +653,7 @@ const CACHEABLE_CONTEXT_FILES = [
|
|
|
653
653
|
/** Default thinking budget (tokens) for verification-heavy agents */
|
|
654
654
|
const THINKING_BUDGETS = {
|
|
655
655
|
'pan-plan-checker': 8000,
|
|
656
|
+
'pan-design-checker': 8000,
|
|
656
657
|
'pan-verifier': 6000,
|
|
657
658
|
'pan-integration-checker': 6000,
|
|
658
659
|
'pan-reviewer': 4000,
|
|
@@ -60,6 +60,7 @@ const COST_MULTIPLIERS = { reasoning: 15, mid: 3, fast: 1 };
|
|
|
60
60
|
const MODEL_PROFILES = {
|
|
61
61
|
// Original planning/execution agents (pre-v3.0)
|
|
62
62
|
'pan-planner': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
63
|
+
'pan-designer': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
63
64
|
'pan-roadmapper': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
64
65
|
'pan-executor': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
65
66
|
'pan-phase-researcher': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
@@ -69,6 +70,7 @@ const MODEL_PROFILES = {
|
|
|
69
70
|
'pan-document_code': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
70
71
|
'pan-verifier': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
71
72
|
'pan-plan-checker': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
73
|
+
'pan-design-checker': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
72
74
|
'pan-integration-checker': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
73
75
|
'pan-reviewer': { quality: 'reasoning', balanced: 'reasoning', budget: 'fast' },
|
|
74
76
|
// Spec B v2 agents (v3.0–v3.4) — added v3.7.5 to close MODEL_PROFILES drift
|
|
@@ -103,9 +105,11 @@ const EFFORT_ORDER = ['low', 'medium', 'high', 'xhigh'];
|
|
|
103
105
|
const AGENT_BASE_EFFORT = {
|
|
104
106
|
// Heavy planning/orchestration/debugging — deepest reasoning
|
|
105
107
|
'pan-planner': 'xhigh',
|
|
108
|
+
'pan-designer': 'xhigh',
|
|
106
109
|
'pan-conductor': 'xhigh',
|
|
107
110
|
'pan-debugger': 'xhigh',
|
|
108
111
|
'pan-plan-checker': 'xhigh',
|
|
112
|
+
'pan-design-checker': 'xhigh',
|
|
109
113
|
// Execution and verification — thorough but bounded
|
|
110
114
|
'pan-executor': 'high',
|
|
111
115
|
'pan-roadmapper': 'high',
|
|
@@ -154,14 +158,102 @@ function resolveEffortInternal(cwd, agentType) {
|
|
|
154
158
|
|
|
155
159
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Explicit opt-in to exit 0 for a payload that carries an `error` key but is NOT
|
|
163
|
+
* a failure — a legitimate empty/negative answer the caller must not treat as one.
|
|
164
|
+
* Pass it as output()'s 4th argument so the intent is visible and greppable:
|
|
165
|
+
* `grep -rn "EXIT_OK" pan-wizard-core/bin` enumerates every documented exception.
|
|
166
|
+
*/
|
|
167
|
+
const EXIT_OK = 0;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Names of the ERROR FAMILY: `error` itself, and any key ending in `_error`
|
|
171
|
+
* (`worktree_error`, `drain_error`, `commit_error`). A renamed error key is still
|
|
172
|
+
* an error key — that rename is exactly how this class escaped the first fix,
|
|
173
|
+
* which matched the literal name `error` and therefore missed
|
|
174
|
+
* `whatif prepare`'s `worktree_error` sitting three lines below a guard that
|
|
175
|
+
* handled plain `ctx.error` correctly.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately NOT in the family:
|
|
178
|
+
* - plural collections — `errors`, `schema_errors`, `error_patterns`. Those are
|
|
179
|
+
* the *detail* of a verdict payload (`{ passed, errors, warnings }`), and an
|
|
180
|
+
* empty array is truthy in JS, so matching them would make every clean
|
|
181
|
+
* `verify` run exit 1. Commands that gate on a verdict pass their code
|
|
182
|
+
* explicitly (see `links validate`, `doc-lint`).
|
|
183
|
+
* - counters — `error_count`, `total_errors_traced`.
|
|
184
|
+
*/
|
|
185
|
+
const ERROR_FAMILY_KEY = /(^|_)error$/;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* True when `result` reports a FAILURE: an object carrying a truthy own key in
|
|
189
|
+
* the error family. `error: null` / `''` / `false` are "no error", so a payload
|
|
190
|
+
* may carry the key unset without being reported as a failure.
|
|
191
|
+
*
|
|
192
|
+
* This is the ONLY shape that means "failure" to output(). See the exit-code
|
|
193
|
+
* contract below for why no other shape is inferred.
|
|
194
|
+
*/
|
|
195
|
+
function reportsFailure(result) {
|
|
196
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return false;
|
|
197
|
+
// Object.keys → own enumerable keys only: an inherited `error` is not ours,
|
|
198
|
+
// and a nested `{ summary: { error } }` is data, not a top-level signal.
|
|
199
|
+
for (const key of Object.keys(result)) {
|
|
200
|
+
if (ERROR_FAMILY_KEY.test(key) && result[key]) return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
157
205
|
/**
|
|
158
206
|
* Write result to stdout and exit. JSON by default, or raw string if --raw flag is set.
|
|
159
207
|
* Large JSON (>50KB) is written to a tmpfile with @file: prefix.
|
|
208
|
+
*
|
|
209
|
+
* ── EXIT-CODE CONTRACT (read before adding an output() call) ──
|
|
210
|
+
* A payload carrying a truthy ERROR-FAMILY key (`error` or `*_error`) exits **1**
|
|
211
|
+
* by default. That default is deliberate and must not be relaxed: PAN's own
|
|
212
|
+
* orchestrators, hooks, CI steps and autonomous loops gate on the exit code, and an
|
|
213
|
+
* error body delivered with exit 0 is a dead gate — invisible. (Historically every
|
|
214
|
+
* `output({ error: … })` site exited 0, so `pan-tools state json` in a project with
|
|
215
|
+
* no state.md printed `{"error":"state.md not found"}` and reported success. An
|
|
216
|
+
* over-eager exit code is loud and gets fixed; a missed failure is silent, so
|
|
217
|
+
* failure is the safe default.) The error body still goes to **stdout** — callers
|
|
218
|
+
* parse it as before; only the exit code changed. `error()` remains the
|
|
219
|
+
* stderr+exit-1 path for bare messages.
|
|
220
|
+
*
|
|
221
|
+
* Three ways to choose an exit code, in precedence order:
|
|
222
|
+
* 1. Pass `exitCode` explicitly — for gates and verdicts whose payload has no
|
|
223
|
+
* error-family key (e.g. `campaign due` answering "not due",
|
|
224
|
+
* `verify stubs --gate`, `links validate`, `doc-lint`).
|
|
225
|
+
* 2. Pass `EXIT_OK` — an error-keyed payload that is a legitimate empty/negative
|
|
226
|
+
* RESULT, not a failure. Every such site must carry a comment saying why.
|
|
227
|
+
* 3. Pass nothing — derived: truthy error-family key ⇒ 1, otherwise 0.
|
|
228
|
+
*
|
|
229
|
+
* ── WHY THE DERIVATION STOPS AT THE ERROR FAMILY ──
|
|
230
|
+
* PAN's other failure shape is `<verb>: false` plus a `reason`/`detail`. That shape
|
|
231
|
+
* cannot be classified structurally, because the identical shape carries both
|
|
232
|
+
* meanings — sometimes inside one function:
|
|
233
|
+
* { committed: false, reason: 'commit_failed' } ← failure
|
|
234
|
+
* { committed: false, reason: 'skipped_commit_docs_false' } ← the user's own config
|
|
235
|
+
* { advanced: false, reason: 'last_plan' } ← normal end of phase
|
|
236
|
+
* { updated: false, reason: 'No plans found' } ← nothing to sync
|
|
237
|
+
* { available: false, reason: 'BRAVE_API_KEY not set' } ← capability absent
|
|
238
|
+
* { found: false, phase_number: 12 } ← the answer is "no"
|
|
239
|
+
* { clean: false, … } / { exists: false, … } ← a state description
|
|
240
|
+
* A rule like "false flag + a reason ⇒ failure" would turn all but the first into
|
|
241
|
+
* failures, and a false failure is a NEW bug, as loud as the one it fixes. So the
|
|
242
|
+
* classification is made per site, by the author, and recorded in the payload: a
|
|
243
|
+
* site that reports a FAILURE gives its payload an error-family key, which routes
|
|
244
|
+
* it through this one derivation — including payloads built by pure functions in
|
|
245
|
+
* other modules and passed straight through by the dispatcher.
|
|
246
|
+
*
|
|
247
|
+
* Corollary for such sites: harden the value against emptiness
|
|
248
|
+
* (`error: r.stderr || 'unknown git error'`). A subprocess that fails silently
|
|
249
|
+
* yields `''`, and an empty string would launder the failure back into exit 0.
|
|
250
|
+
*
|
|
160
251
|
* @param {Object} result - The result object to serialize as JSON
|
|
161
252
|
* @param {boolean} [raw] - If true and rawValue is provided, output rawValue as plain string
|
|
162
253
|
* @param {string} [rawValue] - Plain string to output when raw mode is active
|
|
254
|
+
* @param {number} [exitCode] - Explicit exit code; omit to derive it from the payload
|
|
163
255
|
*/
|
|
164
|
-
function output(result, raw, rawValue) {
|
|
256
|
+
function output(result, raw, rawValue, exitCode) {
|
|
165
257
|
if (raw && rawValue !== undefined) {
|
|
166
258
|
process.stdout.write(String(rawValue));
|
|
167
259
|
} else {
|
|
@@ -186,7 +278,7 @@ function output(result, raw, rawValue) {
|
|
|
186
278
|
process.stdout.write(json);
|
|
187
279
|
}
|
|
188
280
|
}
|
|
189
|
-
process.exit(0);
|
|
281
|
+
process.exit(exitCode === undefined ? (reportsFailure(result) ? 1 : 0) : exitCode);
|
|
190
282
|
}
|
|
191
283
|
|
|
192
284
|
/**
|
|
@@ -589,8 +681,13 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
589
681
|
|
|
590
682
|
try {
|
|
591
683
|
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
592
|
-
|
|
593
|
-
|
|
684
|
+
// Normalize the phase number so a padded id ('01') still matches an
|
|
685
|
+
// unpadded 'Phase 1:' heading (and vice-versa) — matching findPhaseInternal,
|
|
686
|
+
// which accepts both forms. Strip leading zeros, then allow any zero-padding
|
|
687
|
+
// in the heading via the `0*` prefix.
|
|
688
|
+
const unpadded = phaseNum.toString().trim().replace(/^0+(?=\d)/, '');
|
|
689
|
+
const escapedPhase = escapeRegex(unpadded);
|
|
690
|
+
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${escapedPhase}:\\s*([^\\n]+)`, 'i');
|
|
594
691
|
const headerMatch = content.match(phasePattern);
|
|
595
692
|
if (!headerMatch) return null;
|
|
596
693
|
|
|
@@ -631,14 +728,20 @@ function getPhaseModelTier(cwd, phaseNum) {
|
|
|
631
728
|
}
|
|
632
729
|
|
|
633
730
|
/**
|
|
634
|
-
* Adjust a resolved tier given
|
|
731
|
+
* Adjust a resolved tier given optional task capability hints.
|
|
635
732
|
*
|
|
636
733
|
* Rules, in priority order:
|
|
637
|
-
* 1. context_estimate > LARGE_CONTEXT_TOKEN_THRESHOLD → force reasoning
|
|
734
|
+
* 1. context_estimate > LARGE_CONTEXT_TOKEN_THRESHOLD → force reasoning.
|
|
638
735
|
* 2. needs_thinking → upgrade fast → mid; leave mid/reasoning alone.
|
|
639
736
|
* 3. cache_warm + !needs_thinking + context_estimate < SMALL_CONTEXT_TOKEN_THRESHOLD →
|
|
640
737
|
* allow downgrade mid → fast (cheap, cached, simple tasks don't need mid).
|
|
641
738
|
*
|
|
739
|
+
* These are TIER hints, not capability facts. `reasoning` resolves to `inherit`
|
|
740
|
+
* on Anthropic (see PROVIDER_MODELS above), so rule 1 asks the host runtime for
|
|
741
|
+
* its own top-tier model — it neither selects a large-context model nor checks
|
|
742
|
+
* that the session model has one. PAN never probes the running model's real
|
|
743
|
+
* context window or feature set; nothing here is a gate.
|
|
744
|
+
*
|
|
642
745
|
* @param {string} tier - Baseline tier (reasoning|mid|fast)
|
|
643
746
|
* @param {Object} [opts] - {context_estimate, needs_thinking, cache_warm}
|
|
644
747
|
* @returns {string} Possibly-adjusted tier
|
|
@@ -672,7 +775,7 @@ function adjustTierForCapabilities(tier, opts) {
|
|
|
672
775
|
* @param {string} cwd - Project root directory
|
|
673
776
|
* @param {string} agentType - Agent name (e.g., "pan-planner", "pan-executor")
|
|
674
777
|
* @param {Object} [taskMetadata] - Optional metadata. Supports complexity fields and
|
|
675
|
-
*
|
|
778
|
+
* capability hints: {context_estimate, needs_thinking, cache_warm}.
|
|
676
779
|
* @returns {string} Model identifier: "inherit", "sonnet", "haiku", "mid", "fast", etc.
|
|
677
780
|
*/
|
|
678
781
|
function resolveModelInternal(cwd, agentType, taskMetadata) {
|
|
@@ -707,7 +810,7 @@ function resolveModelInternal(cwd, agentType, taskMetadata) {
|
|
|
707
810
|
tier = resolveComplexityTier(tier, { ...taskMetadata, thresholds });
|
|
708
811
|
}
|
|
709
812
|
|
|
710
|
-
//
|
|
813
|
+
// Capability-hint adjustment (only when hints are present)
|
|
711
814
|
if (taskMetadata && (
|
|
712
815
|
taskMetadata.context_estimate !== undefined ||
|
|
713
816
|
taskMetadata.needs_thinking !== undefined ||
|
|
@@ -962,6 +1065,8 @@ module.exports = {
|
|
|
962
1065
|
LEGACY_ALIASES,
|
|
963
1066
|
COST_MULTIPLIERS,
|
|
964
1067
|
output,
|
|
1068
|
+
EXIT_OK,
|
|
1069
|
+
reportsFailure,
|
|
965
1070
|
error,
|
|
966
1071
|
verbose,
|
|
967
1072
|
safeReadFile,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* - hit rate: cache_read / (cache_read + input - cache_write) if any cache activity
|
|
31
31
|
*
|
|
32
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-
|
|
33
|
+
* Rates are US dollars per million tokens, indicative as of 2026-08. Users
|
|
34
34
|
* can override with `.planning/config.json` → `cost.rates`.
|
|
35
35
|
*/
|
|
36
36
|
|
|
@@ -48,19 +48,29 @@ const TOKENS_FILE = 'tokens.jsonl';
|
|
|
48
48
|
* Override per-model in config.json → cost.rates.
|
|
49
49
|
*/
|
|
50
50
|
const DEFAULT_RATES = {
|
|
51
|
-
// Anthropic — verified against platform pricing 2026-
|
|
52
|
-
// (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache
|
|
53
|
-
// follow Anthropic's convention: read ≈ 0.1× input, write ≈ 1.25× input.
|
|
51
|
+
// Anthropic — verified against platform pricing 2026-08. Opus 4.6+/Opus 5 are
|
|
52
|
+
// $5/$25 (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache
|
|
53
|
+
// rates follow Anthropic's convention: read ≈ 0.1× input, write ≈ 1.25× input.
|
|
54
54
|
'claude-fable-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
|
|
55
|
+
'claude-opus-5': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
55
56
|
'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
56
57
|
'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
57
58
|
'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
|
|
59
|
+
// Sonnet 5 standard is $3/$15; a launch promo runs $2/$10 through 2026-08-31.
|
|
60
|
+
// We track the stable post-promo rate (the table is indicative; the staleness
|
|
61
|
+
// checker flags it for re-verification).
|
|
62
|
+
'claude-sonnet-5': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
58
63
|
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
59
64
|
'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
|
|
60
65
|
|
|
61
|
-
// OpenAI — verified against published pricing 2026-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
66
|
+
// OpenAI — verified against published pricing 2026-08. Prompt caching is a 90%
|
|
67
|
+
// input discount with no separate write charge, so cache_write bills at the
|
|
68
|
+
// plain input rate. GPT-5.6 ships in three tiers; the bare `gpt-5.6` id prices
|
|
69
|
+
// as the Sol flagship, with tier-specific keys for Terra/Luna (longest-prefix
|
|
70
|
+
// match wins in resolveRate). Luna reflects the 2026-07-30 price cut.
|
|
71
|
+
'gpt-5.6': { input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 5.0 },
|
|
72
|
+
'gpt-5.6-terra': { input: 2.0, output: 12.0, cache_read: 0.2, cache_write: 2.0 },
|
|
73
|
+
'gpt-5.6-luna': { input: 0.20, output: 1.20, cache_read: 0.02, cache_write: 0.20 },
|
|
64
74
|
'gpt-5.5': { input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 5.0 },
|
|
65
75
|
|
|
66
76
|
// Google Gemini — published rates (per million tokens, approximate; users can override via config.json → cost.rates).
|
|
@@ -86,20 +96,35 @@ function tokensFile(cwd) {
|
|
|
86
96
|
return path.join(metricsDir(cwd), TOKENS_FILE);
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
// Longest-prefix family match against a rates table. Transcript/hook-captured
|
|
100
|
+
// ids are versioned ("claude-opus-4-8-20260301", "claude-fable-5[1m]") while
|
|
101
|
+
// rate tables use family keys — match the longest key the model starts with so
|
|
102
|
+
// the most specific family wins.
|
|
103
|
+
function familyPrefixRate(rates, model) {
|
|
104
|
+
const families = Object.keys(rates)
|
|
105
|
+
.filter(k => model.startsWith(k))
|
|
106
|
+
.sort((a, b) => b.length - a.length);
|
|
107
|
+
return families.length > 0 ? rates[families[0]] : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
function resolveRate(model, tier, configRates) {
|
|
111
|
+
// Config overrides win over the built-in table — including for versioned ids.
|
|
112
|
+
// Without the family-prefix pass here, a cost.rates override keyed on a family
|
|
113
|
+
// ("claude-opus-5") was silently ignored for the versioned id the hooks
|
|
114
|
+
// actually record ("claude-opus-5-20260101"), which fell through to
|
|
115
|
+
// DEFAULT_RATES instead.
|
|
90
116
|
if (configRates) {
|
|
91
117
|
if (model && configRates[model]) return configRates[model];
|
|
118
|
+
if (model) {
|
|
119
|
+
const fam = familyPrefixRate(configRates, model);
|
|
120
|
+
if (fam) return fam;
|
|
121
|
+
}
|
|
92
122
|
if (tier && configRates[tier]) return configRates[tier];
|
|
93
123
|
}
|
|
94
124
|
if (model && DEFAULT_RATES[model]) return DEFAULT_RATES[model];
|
|
95
|
-
// Transcript/hook-captured ids are versioned ("claude-opus-4-8-20260301",
|
|
96
|
-
// "claude-fable-5[1m]") while the table uses family keys — prefix-match,
|
|
97
|
-
// longest key first so the most specific family wins.
|
|
98
125
|
if (model) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
.sort((a, b) => b.length - a.length);
|
|
102
|
-
if (families.length > 0) return DEFAULT_RATES[families[0]];
|
|
126
|
+
const fam = familyPrefixRate(DEFAULT_RATES, model);
|
|
127
|
+
if (fam) return fam;
|
|
103
128
|
}
|
|
104
129
|
if (tier && DEFAULT_RATES[tier]) return DEFAULT_RATES[tier];
|
|
105
130
|
return null;
|
|
@@ -119,10 +144,19 @@ function computeCost(rec, configRates) {
|
|
|
119
144
|
const output = rec.output_tokens || 0;
|
|
120
145
|
const cacheRead = rec.cache_read_tokens || 0;
|
|
121
146
|
const cacheWrite = rec.cache_write_tokens || 0;
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
// The three input axes are DISJOINT as PAN records them. hooks/pan-cost-logger.js
|
|
148
|
+
// copies Anthropic's `input_tokens`, `cache_read_input_tokens` and
|
|
149
|
+
// `cache_creation_input_tokens` into separate fields, and Anthropic's
|
|
150
|
+
// `input_tokens` already EXCLUDES both cache axes — so each token is counted once
|
|
151
|
+
// and billed at its own rate.
|
|
152
|
+
//
|
|
153
|
+
// This previously subtracted cache_read from input, hedging that "cache_read is
|
|
154
|
+
// already in input on some providers". That double-discounted: with a warm cache,
|
|
155
|
+
// cache_read is far larger than input, so Math.max(0, …) zeroed the billed input
|
|
156
|
+
// outright. On a realistic row (30k input / 5k output / 200k cache read / 12k
|
|
157
|
+
// cache write on Opus-5 rates) it reported $0.30 against a true $0.45 — a 33%
|
|
158
|
+
// understatement, always in the direction of looking cheaper.
|
|
159
|
+
const usd = (input * rate.input + output * rate.output
|
|
126
160
|
+ cacheRead * rate.cache_read + cacheWrite * rate.cache_write) / 1_000_000;
|
|
127
161
|
return Math.round(usd * 10000) / 10000;
|
|
128
162
|
}
|
|
@@ -161,7 +195,7 @@ function appendRecord(cwd, rec) {
|
|
|
161
195
|
fs.appendFileSync(tokensFile(cwd), JSON.stringify(normalized) + '\n', 'utf-8');
|
|
162
196
|
return { appended: true, file: tokensFile(cwd) };
|
|
163
197
|
} catch (e) {
|
|
164
|
-
return { appended: false, error: e.message };
|
|
198
|
+
return { appended: false, error: e.message || 'ledger_append_failed' };
|
|
165
199
|
}
|
|
166
200
|
}
|
|
167
201
|
|
|
@@ -284,9 +318,12 @@ function aggregate(cwd, opts) {
|
|
|
284
318
|
|
|
285
319
|
totals.cost_usd = Math.round(totals.cost_usd * 10000) / 10000;
|
|
286
320
|
|
|
287
|
-
// Cache hit rate:
|
|
288
|
-
|
|
289
|
-
|
|
321
|
+
// Cache hit rate: cached input / all input read. Same disjoint-axes fact as
|
|
322
|
+
// computeCost — input_tokens excludes the cache axes, so the denominator is simply
|
|
323
|
+
// their sum. Subtracting cache_read from input here made the denominator collapse
|
|
324
|
+
// to cache_read whenever the cache was warm (the normal case), pinning the metric
|
|
325
|
+
// at exactly 100% and making it carry no information at all.
|
|
326
|
+
const hitDenom = totals.cache_read_tokens + totals.input_tokens;
|
|
290
327
|
const cacheHitRatePct = hitDenom > 0
|
|
291
328
|
? Math.round((totals.cache_read_tokens / hitDenom) * 1000) / 10
|
|
292
329
|
: null;
|
|
@@ -396,7 +433,7 @@ function cmdCostClear(cwd, raw) {
|
|
|
396
433
|
fs.unlinkSync(tokensFile(cwd));
|
|
397
434
|
output({ cleared: true, file: tokensFile(cwd) }, raw);
|
|
398
435
|
} catch (e) {
|
|
399
|
-
output({ cleared: false, error: e.message }, raw);
|
|
436
|
+
output({ cleared: false, error: e.message || 'ledger_clear_failed' }, raw);
|
|
400
437
|
}
|
|
401
438
|
}
|
|
402
439
|
|
|
@@ -406,7 +443,7 @@ function cmdCostClear(cwd, raw) {
|
|
|
406
443
|
// Bump this whenever the table is re-verified; `models check` flags the table
|
|
407
444
|
// once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
|
|
408
445
|
// than PAN releases do).
|
|
409
|
-
const RATES_VERIFIED_AT = '2026-
|
|
446
|
+
const RATES_VERIFIED_AT = '2026-08-03';
|
|
410
447
|
const RATES_STALE_AFTER_DAYS = 180;
|
|
411
448
|
const RATE_TIERS = ['reasoning', 'mid', 'fast'];
|
|
412
449
|
|