pan-wizard 3.21.1 → 3.24.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 (136) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +101 -17
  68. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
  69. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  70. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  71. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  72. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  73. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  74. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  76. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  77. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  78. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  79. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  80. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  81. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  82. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  83. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  84. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  85. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  86. package/pan-wizard-core/learnings/index.json +7 -7
  87. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  88. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  89. package/pan-wizard-core/references/design-methodology.md +94 -0
  90. package/pan-wizard-core/references/git-integration.md +9 -9
  91. package/pan-wizard-core/references/guardrails.md +4 -1
  92. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  93. package/pan-wizard-core/references/model-profiles.md +44 -34
  94. package/pan-wizard-core/references/planning-config.md +5 -5
  95. package/pan-wizard-core/references/verification-patterns.md +67 -0
  96. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  97. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  98. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  99. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  100. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  101. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  102. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  103. package/pan-wizard-core/templates/design.md +146 -0
  104. package/pan-wizard-core/templates/discovery.md +3 -3
  105. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  106. package/pan-wizard-core/templates/playbook.md +1 -1
  107. package/pan-wizard-core/templates/uat.md +1 -1
  108. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  109. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  110. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  111. package/pan-wizard-core/workflows/health.md +24 -1
  112. package/pan-wizard-core/workflows/help.md +5 -4
  113. package/pan-wizard-core/workflows/learn.md +5 -5
  114. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  116. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  117. package/pan-wizard-core/workflows/new-project.md +47 -32
  118. package/pan-wizard-core/workflows/optimize.md +8 -8
  119. package/pan-wizard-core/workflows/pause.md +1 -1
  120. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  121. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  122. package/pan-wizard-core/workflows/profile.md +13 -10
  123. package/pan-wizard-core/workflows/research-phase.md +1 -1
  124. package/pan-wizard-core/workflows/resume-project.md +1 -1
  125. package/pan-wizard-core/workflows/settings.md +9 -9
  126. package/pan-wizard-core/workflows/transition.md +24 -8
  127. package/pan-wizard-core/workflows/update.md +23 -20
  128. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  129. package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
  130. package/pan-zcode/README.md +14 -2
  131. package/pan-zcode/bin/install-zcode.js +1 -1
  132. package/pan-zcode/mcp/native-tools.cjs +1 -1
  133. package/pan-zcode/mcp/server.cjs +76 -14
  134. package/scripts/build-hooks.js +2 -1
  135. package/scripts/generate-skills-docs.py +0 -4
  136. 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
- return { blocked: true, reason: 'sensitive_file_detected', safetyChecks, hint: 'Remove sensitive files from staging before committing' };
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
- output({ committed: false, hash: null, reason: safety.reason, safety_checks: safety.safetyChecks, hint: safety.hint }, raw, 'blocked');
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
- output({ committed: false, hash: null, reason: 'commit_failed', error: commitResult.stderr }, raw, 'failed');
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
- const match = phaseDirEntries.find(d => d.startsWith(phase.number + '-') || d === phase.number);
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
- output({ committed: false, reason: 'commit_failed', error: commitResult.stderr }, raw, 'failed');
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.name,
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.name,
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
- const escapedPhase = escapeRegex(phaseNum.toString());
593
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
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 Opus 4.7-era capability hints.
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 (only 1M-ctx tier).
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
- * Opus 4.7 capability hints: {context_estimate, needs_thinking, cache_warm}.
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
- // Opus 4.7 capability adjustment (only when hints are present)
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-06. Users
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-06. Opus 4.6+ is $5/$25
52
- // (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache rates
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-06 ($5/$30 standard tier).
62
- // Prompt caching is a 90% input discount with no separate write charge, so
63
- // cache_write bills at the plain input rate.
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 families = Object.keys(DEFAULT_RATES)
100
- .filter(k => model.startsWith(k))
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
- // Non-cache-hit input tokens = input - cache_read (cache_read already in input on some providers,
123
- // separate on others; we treat cache_read as a reduction of effective new input).
124
- const newInput = Math.max(0, input - cacheRead);
125
- const usd = (newInput * rate.input + output * rate.output
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: cache_read / (cache_read + new input tokens billed at full rate)
288
- const billedInput = Math.max(0, totals.input_tokens - totals.cache_read_tokens);
289
- const hitDenom = totals.cache_read_tokens + billedInput;
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-06-10';
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