pan-wizard 3.30.0 → 3.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +30 -26
  2. package/agents/pan-design-checker.md +18 -0
  3. package/agents/pan-optimizer.md +5 -1
  4. package/agents/pan-plan-checker.md +23 -0
  5. package/agents/pan-reviewer.md +19 -0
  6. package/agents/pan-verifier.md +23 -0
  7. package/bin/install-lib.cjs +129 -33
  8. package/bin/install.js +16 -9
  9. package/commands/pan/army.md +4 -4
  10. package/commands/pan/design-phase.md +8 -0
  11. package/commands/pan/exec-phase.md +1 -1
  12. package/commands/pan/git.md +2 -2
  13. package/commands/pan/learn.md +2 -0
  14. package/commands/pan/optimize.md +14 -2
  15. package/commands/pan/review-deep.md +5 -5
  16. package/commands/pan/verify-phase.md +8 -9
  17. package/hooks/dist/pan-check-update.js +53 -2
  18. package/hooks/dist/pan-context-monitor.js +42 -1
  19. package/hooks/dist/pan-cost-logger.js +132 -11
  20. package/hooks/dist/pan-state-reinject.js +158 -0
  21. package/hooks/dist/pan-stop-guard.js +50 -4
  22. package/hooks/dist/pan-trace-logger.js +324 -18
  23. package/package.json +1 -1
  24. package/pan-wizard-core/bin/lib/commands.cjs +22 -5
  25. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  26. package/pan-wizard-core/bin/lib/constants.cjs +31 -1
  27. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  28. package/pan-wizard-core/bin/lib/core.cjs +232 -23
  29. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  30. package/pan-wizard-core/bin/lib/cost.cjs +135 -46
  31. package/pan-wizard-core/bin/lib/findings.cjs +381 -0
  32. package/pan-wizard-core/bin/lib/focus.cjs +72 -19
  33. package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
  34. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  35. package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
  36. package/pan-wizard-core/bin/lib/init.cjs +4 -1
  37. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  38. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
  39. package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
  40. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  41. package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
  42. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  43. package/pan-wizard-core/bin/lib/state.cjs +93 -24
  44. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  45. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  46. package/pan-wizard-core/bin/lib/verdict.cjs +431 -0
  47. package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
  48. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
  49. package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
  50. package/pan-wizard-core/bin/lib/verify.cjs +25 -2
  51. package/pan-wizard-core/bin/pan-tools.cjs +69 -6
  52. package/pan-wizard-core/learnings/README.md +10 -7
  53. package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
  54. package/pan-wizard-core/references/verdict-contract.md +101 -0
  55. package/pan-wizard-core/templates/verification-report.md +16 -0
  56. package/pan-wizard-core/workflows/exec-phase.md +73 -47
  57. package/pan-wizard-core/workflows/health.md +2 -0
  58. package/pan-wizard-core/workflows/help.md +2 -2
  59. package/pan-wizard-core/workflows/learn.md +4 -2
  60. package/pan-wizard-core/workflows/milestone-audit.md +15 -3
  61. package/pan-wizard-core/workflows/optimize.md +14 -1
  62. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  63. package/pan-wizard-core/workflows/plan-phase.md +20 -15
  64. package/pan-wizard-core/workflows/progress.md +1 -1
  65. package/pan-wizard-core/workflows/todo-add.md +1 -1
  66. package/pan-wizard-core/workflows/todo-check.md +7 -6
  67. package/pan-wizard-core/workflows/verify-phase.md +3 -13
  68. package/pan-zcode/README.md +3 -3
  69. package/scripts/build-agent-plugin.js +4 -0
  70. package/scripts/build-hooks.js +2 -1
  71. package/scripts/build-plugin.js +10 -0
  72. package/scripts/git-hooks/pre-commit +2 -1
  73. package/scripts/npm-dist-tag.js +47 -0
  74. package/scripts/plugin-path.js +6 -1
  75. package/scripts/test-surface.cjs +1 -0
@@ -541,6 +541,15 @@ const HEALTH_STATUS = { HEALTHY: 'healthy', DEGRADED: 'degraded', BROKEN: 'broke
541
541
  /** Max JSON payload size before writing to tmpfile (bytes) */
542
542
  const MAX_JSON_SIZE = 50000;
543
543
 
544
+ /**
545
+ * Shape version of the machine-readable state JSON: `state` (load), `state json`,
546
+ * `progress` (json) and the MCP resources that wrap them (market item M17).
547
+ * Additive rule: fields may be added within a major version and are never renamed
548
+ * or removed; readers accept any 1.x and ignore fields they do not know. A rename
549
+ * or removal is a major bump ("2.0").
550
+ */
551
+ const STATE_CONTRACT = '1.0';
552
+
544
553
  /** Width of progress bar in status display (character count) */
545
554
  const PROGRESS_BAR_WIDTH = 10;
546
555
 
@@ -607,7 +616,9 @@ const SKILL_ALIGN_MAX_TASKS = 50; // larger drafts are a planning
607
616
  const SKILL_ALIGN_CONTENT_CAP = 700; // chars of file head scored (≈ objective paragraph)
608
617
 
609
618
  /** Valid conventional commit types */
610
- const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'chore'];
619
+ // `perf` is a task commit type in references/git-integration.md; it was missing
620
+ // here, so `pan-tools commit --type perf` refused a type the docs prescribe.
621
+ const VALID_COMMIT_TYPES = ['feat', 'fix', 'docs', 'test', 'refactor', 'perf', 'chore'];
611
622
 
612
623
  /** Default sensitive file patterns for commit safety checks */
613
624
  const DEFAULT_SENSITIVE_PATTERNS = ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'];
@@ -722,6 +733,24 @@ const FOREIGN_PLANNING_MARKERS = Object.freeze({
722
733
  'graphify.enabled', 'intel.enabled', 'hooks.workflow_guard', 'dynamic_routing', 'resolve_model_ids',
723
734
  ]),
724
735
  }),
736
+ // planning-with-files (OthmanAdi/planning-with-files README, read 2026-09-28 at
737
+ // v3.21.0; market item M3 / MI-028). It writes INTO .planning/ beside whatever else
738
+ // lives there: `.active_plan`, `.attestation`, `sessions/`, `ledger-<agent>.jsonl`, and
739
+ // one `YYYY-MM-DD-slug/` directory per task holding task_plan.md / findings.md /
740
+ // progress.md. PAN writes none of those names. `coexists`: unlike gsd-core, a match
741
+ // means the tree is SHARED, not owned. PAN keeps working in it and says so, rather
742
+ // than refusing. planning-with-files gitignores .planning/ by default, which is what
743
+ // the I005 health check exists for.
744
+ planning_with_files: Object.freeze({
745
+ tool: 'planning-with-files',
746
+ coexists: true,
747
+ files: Object.freeze(['.active_plan', '.attestation']),
748
+ filePatterns: Object.freeze([/^ledger-[A-Za-z0-9._-]+\.jsonl$/]),
749
+ dirs: Object.freeze(['sessions']),
750
+ // A dated task directory is strong evidence only with the tool's plan file in it.
751
+ dirPatterns: Object.freeze([{ re: /^\d{4}-\d{2}-\d{2}-[A-Za-z0-9][A-Za-z0-9._-]*$/, requires: 'task_plan.md' }]),
752
+ configKeys: Object.freeze([]),
753
+ }),
725
754
  });
726
755
 
727
756
  module.exports = {
@@ -804,6 +833,7 @@ module.exports = {
804
833
  HEALTH_STATUS,
805
834
  FOREIGN_PLANNING_MARKERS,
806
835
  MAX_JSON_SIZE,
836
+ STATE_CONTRACT,
807
837
  PROGRESS_BAR_WIDTH,
808
838
  MAX_SLUG_LENGTH,
809
839
  FILLED_BLOCK,
@@ -113,9 +113,23 @@ function assessCacheTtl(records, opts = {}) {
113
113
  // last record: its cache writes are not a subagent's re-write after an idle
114
114
  // gap, and the subagent cache lifetime setting does not govern them.
115
115
  .filter(r => !(r && r.token_source === 'session-transcript'))
116
- .map(r => ({ t: r && r.ts ? new Date(r.ts).getTime() : NaN, w: Number(r && r.cache_write_tokens) || 0 }))
116
+ // A row that recorded the cache-write lifetime split (M13) says which writes
117
+ // were five-minute ones; only those can be a re-write after a short idle gap —
118
+ // a one-hour write already outlives it. Rows without the split fall back to the
119
+ // gap heuristic on the whole write.
120
+ .map(r => {
121
+ const measured = r && typeof r.cache_write_5m_tokens === 'number';
122
+ return {
123
+ t: r && r.ts ? new Date(r.ts).getTime() : NaN,
124
+ w: measured ? r.cache_write_5m_tokens : (Number(r && r.cache_write_tokens) || 0),
125
+ w1h: measured ? (Number(r.cache_write_1h_tokens) || 0) : 0,
126
+ measured,
127
+ };
128
+ })
117
129
  .filter(r => Number.isFinite(r.t))
118
130
  .sort((a, b) => a.t - b.t);
131
+ const measuredRows = rows.filter(r => r.measured);
132
+ const basis = measuredRows.length === 0 ? 'inferred' : measuredRows.length === rows.length ? 'measured' : 'mixed';
119
133
  let shortIdle = 0; let shortIdleTokens = 0; let longIdle = 0;
120
134
  for (let i = 1; i < rows.length; i++) {
121
135
  if (rows[i].w < minWrite) continue;
@@ -126,14 +140,25 @@ function assessCacheTtl(records, opts = {}) {
126
140
  const recommend = shortIdle >= recommendAt;
127
141
  const setting = 'subagentPromptCacheTtl';
128
142
  const severity = recommend && shortIdleTokens >= (opts.warnTokens ?? TTL_WARN_TOKENS) ? 'warn' : 'info';
143
+ const basisNote = basis === 'measured'
144
+ ? ' (measured: the ledger records each write\'s cache lifetime)'
145
+ : basis === 'mixed'
146
+ ? ' (partly measured: rows written before PAN recorded the cache lifetime are counted from idle gaps alone)'
147
+ : ' (inferred from idle gaps between rows: no row records the cache lifetime)';
129
148
  const advice = recommend
130
- ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.`
149
+ ? `${shortIdle} cache writes followed an idle gap of ${TTL_SHORT_MIN}–${TTL_LONG_MIN} min (~${shortIdleTokens.toLocaleString()} tokens re-written): subagents get the five-minute cache lifetime by default — set \`${setting}: "1h"\` in a Claude Code settings file. One-hour writes bill at 2× base input against 1.25×, so this pays off once a block is read twice within the hour.${basisNote}`
131
150
  : null;
132
151
  return {
133
152
  records_considered: rows.length,
134
153
  writes_after_short_idle: shortIdle,
135
154
  tokens_after_short_idle: shortIdleTokens,
136
155
  writes_after_long_idle: longIdle,
156
+ // Where the split came from: 'measured' (every row carries it), 'mixed', or
157
+ // 'inferred' (none does — the gap heuristic alone).
158
+ basis,
159
+ measured_rows: measuredRows.length,
160
+ cache_write_1h_tokens: measuredRows.reduce((n, r) => n + r.w1h, 0),
161
+ cache_write_5m_tokens: measuredRows.reduce((n, r) => n + r.w, 0),
137
162
  recommend,
138
163
  setting,
139
164
  severity,
@@ -51,6 +51,54 @@ const PROVIDER_MODELS = {
51
51
  default: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
52
52
  };
53
53
 
54
+ // OpenCode names every model `provider/model` — in agent frontmatter, in config and
55
+ // wherever a model is passed — and neither a bare API id nor a Claude Code alias
56
+ // (`sonnet`) is one. OpenCode's models doc: "the full ID is `provider_id/model_id`",
57
+ // with its built-in providers and ids taken from models.dev (anomalyco/opencode
58
+ // packages/web/src/content/docs/models.mdx and models.dev/api.json, both read
59
+ // 2026-09-26; every id below exists there under that provider). Same tiers as
60
+ // PROVIDER_MODELS, provider-qualified; `default` is Anthropic as it is there.
61
+ // Reality check R41.
62
+ const OPENCODE_MODELS = {
63
+ anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
64
+ openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol', fast: 'openai/gpt-6-luna' },
65
+ google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash', fast: 'google/gemini-3.5-flash-lite' },
66
+ default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
67
+ };
68
+
69
+ /**
70
+ * The runtime whose copy of PAN is running, read from where this module is
71
+ * installed: each runtime gets its own `pan-wizard-core`, so the copy under
72
+ * `.opencode/` (project) or `~/.config/opencode/` (global) is only ever invoked by
73
+ * OpenCode's commands. Only OpenCode is distinguished — it is the one runtime
74
+ * whose model ids differ in form. A relocated `--config-dir` install is not
75
+ * recognised and keeps the plain ids.
76
+ * @param {string} [dir] - this module's directory (injectable for tests)
77
+ * @returns {'opencode'|null}
78
+ */
79
+ function hostRuntime(dir = __dirname) {
80
+ const p = String(dir || '').replace(/\\/g, '/');
81
+ return /(^|\/)\.?opencode\/pan-wizard-core\/bin\/lib\/?$/.test(p) ? 'opencode' : null;
82
+ }
83
+
84
+ /**
85
+ * The provider prefix of OpenCode's own configured `model` (`"anthropic/…"`), from
86
+ * the project's `opencode.json` or `.opencode/opencode.json`, when it is one PAN
87
+ * routes for. Plain JSON only — a file that does not parse is skipped.
88
+ * @param {string} cwd
89
+ * @returns {'anthropic'|'openai'|'google'|null}
90
+ */
91
+ function opencodeConfiguredProvider(cwd) {
92
+ for (const rel of ['opencode.json', path.join('.opencode', 'opencode.json')]) {
93
+ let parsed;
94
+ try { parsed = JSON.parse(fs.readFileSync(path.join(cwd, rel), 'utf8')); } catch { continue; }
95
+ const model = parsed && typeof parsed.model === 'string' ? parsed.model : '';
96
+ const prefix = model.includes('/') ? model.slice(0, model.indexOf('/')) : '';
97
+ if (prefix === 'anthropic' || prefix === 'openai' || prefix === 'google') return prefix;
98
+ }
99
+ return null;
100
+ }
101
+
54
102
  /** Maps legacy Anthropic model names to provider-agnostic tier aliases. */
55
103
  const LEGACY_ALIASES = { opus: 'reasoning', sonnet: 'mid', haiku: 'fast' };
56
104
 
@@ -354,6 +402,7 @@ function loadConfig(cwd) {
354
402
  research: true,
355
403
  plan_checker: true,
356
404
  verifier: true,
405
+ nyquist_validation: false,
357
406
  parallelization: true,
358
407
  brave_search: false,
359
408
  };
@@ -391,6 +440,9 @@ function loadConfig(cwd) {
391
440
  research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
392
441
  plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
393
442
  verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
443
+ // Written as workflow.nyquist_validation by config-ensure-section; loadConfig
444
+ // never read it, so `init plan-phase` reported it off whatever the config said.
445
+ nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
394
446
  parallelization,
395
447
  brave_search: get('brave_search') ?? defaults.brave_search,
396
448
  budget: parsed.budget || { default_points: 50, micro_threshold_tasks: 3, micro_threshold_files: 2, enforce: false },
@@ -787,6 +839,37 @@ function adjustTierForCapabilities(tier, opts) {
787
839
  return tier;
788
840
  }
789
841
 
842
+ // ─── Failure-tier escalation (market-ideas queue M8, MI-029) ────────────────
843
+ //
844
+ // A retry of work that already failed once on a cheaper tier should not run on
845
+ // that tier again. Three peers converged on the same rule (gsd-core's
846
+ // dynamic_routing.escalate_on_failure, Superpowers' "more capable model" after
847
+ // repeated failures, HydraFusion's cascade); PAN only chose a tier BEFORE a run.
848
+ // Each failed attempt now raises the tier one step up this ladder, capped by
849
+ // `routing.max_escalations` and never above the agent's own `quality` tier —
850
+ // so under `quality` and `balanced`, where every agent already runs on the
851
+ // reasoning tier, escalation is a no-op; it is the `budget` profile's retry path.
852
+ const TIER_LADDER = Object.freeze(['fast', 'mid', 'reasoning']);
853
+ const DEFAULT_MAX_ESCALATIONS = 1;
854
+
855
+ /**
856
+ * Pure: the tier for the given attempt.
857
+ * @param {string} tier - the tier the profile/routing chose (legacy names accepted)
858
+ * @param {number} attempt - 1 for the first try; each later attempt follows a failure
859
+ * @param {{maxEscalations?: number, ceiling?: string}} [opts]
860
+ * @returns {{tier: string, escalated_from: string|null}}
861
+ */
862
+ function escalateTier(tier, attempt, opts = {}) {
863
+ const from = LEGACY_ALIASES[tier] || tier;
864
+ const max = Number.isInteger(opts.maxEscalations) && opts.maxEscalations >= 0 ? opts.maxEscalations : DEFAULT_MAX_ESCALATIONS;
865
+ const ceiling = TIER_LADDER.indexOf(LEGACY_ALIASES[opts.ceiling] || opts.ceiling || 'reasoning');
866
+ const at = TIER_LADDER.indexOf(from);
867
+ const steps = Math.min(Number.isInteger(attempt) && attempt > 1 ? attempt - 1 : 0, max);
868
+ if (at === -1 || ceiling === -1 || steps === 0 || at >= ceiling) return { tier: from, escalated_from: null };
869
+ const to = Math.min(at + steps, ceiling);
870
+ return { tier: TIER_LADDER[to], escalated_from: to > at ? from : null };
871
+ }
872
+
790
873
  /**
791
874
  * Resolve the model for a given agent type based on profile, provider, and routing strategy.
792
875
  * Returns "inherit" for reasoning-tier to let the host runtime use its top-tier model.
@@ -797,27 +880,38 @@ function adjustTierForCapabilities(tier, opts) {
797
880
  * @returns {string} Model identifier: "inherit", a Claude Code alias ("sonnet", "haiku"), or a provider model id from PROVIDER_MODELS
798
881
  */
799
882
  function resolveModelInternal(cwd, agentType, taskMetadata) {
883
+ return resolveModelDetailed(cwd, agentType, taskMetadata).model;
884
+ }
885
+
886
+ /**
887
+ * resolveModelInternal with its reasoning: the tier, and — when `taskMetadata.attempt`
888
+ * raised it (M8) — the tier it was escalated from. Explicit pins (a model_overrides
889
+ * entry, a roadmap per-phase tier) are the user's choice and are never escalated.
890
+ * @returns {{model: string, tier: string|null, escalated_from: string|null}}
891
+ */
892
+ function resolveModelDetailed(cwd, agentType, taskMetadata) {
800
893
  const config = loadConfig(cwd);
801
894
  const provider = detectProvider(cwd, config);
895
+ const pinned = (tier) => ({ model: resolveTierToModel(tier, provider), tier: LEGACY_ALIASES[tier] || tier, escalated_from: null });
802
896
 
803
897
  // Check per-agent override first (highest priority)
804
898
  const override = config.model_overrides?.[agentType];
805
899
  if (override) {
806
- return resolveTierToModel(override, provider);
900
+ return pinned(override);
807
901
  }
808
902
 
809
903
  // Check per-phase override from roadmap (second priority)
810
904
  if (taskMetadata?.phaseNum) {
811
905
  const phaseTier = getPhaseModelTier(cwd, taskMetadata.phaseNum);
812
906
  if (phaseTier) {
813
- return resolveTierToModel(phaseTier, provider);
907
+ return pinned(phaseTier);
814
908
  }
815
909
  }
816
910
 
817
911
  // Fall back to profile lookup
818
912
  const profile = config.model_profile || 'balanced';
819
913
  const agentModels = MODEL_PROFILES[agentType];
820
- if (!agentModels) return resolveTierToModel('mid', provider);
914
+ if (!agentModels) return pinned('mid');
821
915
 
822
916
  let tier = agentModels[profile] || agentModels['balanced'] || 'mid';
823
917
 
@@ -837,7 +931,19 @@ function resolveModelInternal(cwd, agentType, taskMetadata) {
837
931
  tier = adjustTierForCapabilities(tier, taskMetadata);
838
932
  }
839
933
 
840
- return resolveTierToModel(tier, provider);
934
+ // Failure-tier escalation: a retry climbs toward the agent's quality tier (M8).
935
+ let escalatedFrom = null;
936
+ if (taskMetadata && Number.isInteger(taskMetadata.attempt) && taskMetadata.attempt > 1) {
937
+ const maxEscalations = config.routing?.max_escalations;
938
+ const esc = escalateTier(tier, taskMetadata.attempt, {
939
+ maxEscalations: Number.isInteger(maxEscalations) ? maxEscalations : DEFAULT_MAX_ESCALATIONS,
940
+ ceiling: agentModels.quality || 'reasoning',
941
+ });
942
+ tier = esc.tier;
943
+ escalatedFrom = esc.escalated_from;
944
+ }
945
+
946
+ return { model: resolveTierToModel(tier, provider), tier: LEGACY_ALIASES[tier] || tier, escalated_from: escalatedFrom };
841
947
  }
842
948
 
843
949
  /**
@@ -846,7 +952,7 @@ function resolveModelInternal(cwd, agentType, taskMetadata) {
846
952
  * @param {Object} config - Loaded config object
847
953
  * @returns {string} Provider name: "anthropic", "openai", "google", or "default"
848
954
  */
849
- function detectProvider(cwd, config) {
955
+ function detectProvider(cwd, config, host = hostRuntime()) {
850
956
  // 1. Explicit config
851
957
  if (config.routing?.provider && config.routing.provider !== 'auto') {
852
958
  const p = config.routing.provider;
@@ -857,7 +963,13 @@ function detectProvider(cwd, config) {
857
963
  if (envProvider) {
858
964
  return PROVIDER_MODELS[envProvider] ? envProvider : 'default';
859
965
  }
860
- // 3. Runtime directory detection
966
+ // 3. Under OpenCode, the provider of OpenCode's own configured model — OpenCode
967
+ // runs any provider, so a `.opencode` directory says nothing about which.
968
+ if (host === 'opencode') {
969
+ const p = opencodeConfiguredProvider(cwd);
970
+ if (p) return p;
971
+ }
972
+ // 4. Runtime directory detection
861
973
  const checks = [
862
974
  ['.claude', 'anthropic'], ['.codex', 'openai'],
863
975
  ['.gemini', 'google'], ['.opencode', 'openai'], ['.github', 'default'],
@@ -873,11 +985,13 @@ function detectProvider(cwd, config) {
873
985
  * Resolve a tier alias (or legacy model name) to a provider-specific model name.
874
986
  * @param {string} tier - Tier alias ("reasoning", "mid", "fast") or legacy name ("opus", "sonnet", "haiku")
875
987
  * @param {string} provider - Provider key from detectProvider()
988
+ * @param {string|null} [host] - hostRuntime(); OpenCode gets `provider/model` ids
876
989
  * @returns {string} Provider-specific model name
877
990
  */
878
- function resolveTierToModel(tier, provider) {
991
+ function resolveTierToModel(tier, provider, host = hostRuntime()) {
879
992
  const normalizedTier = LEGACY_ALIASES[tier] || tier;
880
- const providerMap = PROVIDER_MODELS[provider] || PROVIDER_MODELS['default'];
993
+ const table = host === 'opencode' ? OPENCODE_MODELS : PROVIDER_MODELS;
994
+ const providerMap = table[provider] || table['default'];
881
995
  return providerMap[normalizedTier] || providerMap['mid'];
882
996
  }
883
997
 
@@ -958,7 +1072,7 @@ const MILESTONE_SUMMARY_RE = /^[ \t]*<summary>(.*\bv\d+(?:\.\d+)+\b.*?)<\/summar
958
1072
 
959
1073
  /** Status markers PAN's own roadmap template emits, plus their prose forms. */
960
1074
  const MILESTONE_STATUS_MARKERS = [
961
- { status: 'shipped', re: /✅|\bshipped\b|\bcomplete[d]?\b|\bdone\b/i },
1075
+ { status: 'shipped', re: /✅|\bshipped\b|\bcomplete[d]?\b|\bdone\b|\bclosed\s+\d{4}-\d{2}-\d{2}/i },
962
1076
  // `current` is matched as a word anywhere in the heading, not as the whole
963
1077
  // parenthetical: real roadmaps write "(current, phases 1–10)", and requiring
964
1078
  // an exact "(current)" silently missed the marker and fell through to
@@ -980,15 +1094,22 @@ const MILESTONE_STATUS_MARKERS = [
980
1094
  */
981
1095
  function parseMilestoneHeadings(roadmap) {
982
1096
  const out = [];
1097
+ const byLabel = new Map();
983
1098
  const lines = String(roadmap || '').split(/\r?\n/);
984
1099
 
985
1100
  lines.forEach((line, i) => {
986
1101
  const m = line.match(MILESTONE_HEADING_RE) || line.match(MILESTONE_SUMMARY_RE);
987
- if (!m) return;
988
- const heading = m[1];
989
-
990
- const versionMatch = heading.match(/\bv(\d+(?:\.\d+)+)\b/);
991
- if (!versionMatch) return;
1102
+ const plain = m ? null : line.match(ANY_HEADING_RE);
1103
+ const heading = m ? m[1] : plain ? plain[1] : null;
1104
+ if (!heading) return;
1105
+
1106
+ // A version (`v4.1`) anywhere in the heading, or a letter-series label (`R-2`)
1107
+ // as its first word. The resolver used to know only the first, so a project
1108
+ // naming its milestones R-1, R-2 … had none, and every reader fell back to a
1109
+ // made-up `v1.0`.
1110
+ const versionMatch = m ? heading.match(/\bv(\d+(?:\.\d+)+)\b/) : null;
1111
+ const label = versionMatch ? `v${versionMatch[1]}` : seriesLabel(heading);
1112
+ if (!label) return;
992
1113
 
993
1114
  let status = 'unknown';
994
1115
  for (const marker of MILESTONE_STATUS_MARKERS) {
@@ -998,18 +1119,70 @@ function parseMilestoneHeadings(roadmap) {
998
1119
  // it carries no explicit marker.
999
1120
  if (status === 'unknown' && MILESTONE_SUMMARY_RE.test(line)) status = 'shipped';
1000
1121
 
1001
- out.push({
1002
- version: `v${versionMatch[1]}`,
1003
- name: extractMilestoneName(heading, versionMatch[0]),
1122
+ // One milestone per label. A roadmap that repeats a heading (a traceability
1123
+ // or archive section) used to count each repeat as another milestone — and an
1124
+ // unmarked repeat of a shipped one as UNSHIPPED work. The first heading keeps
1125
+ // its place and name; a later one only fills in a status the first lacked.
1126
+ const seen = byLabel.get(label);
1127
+ if (seen) {
1128
+ if (seen.status === 'unknown' && status !== 'unknown') seen.status = status;
1129
+ return;
1130
+ }
1131
+ const entry = {
1132
+ version: label,
1133
+ name: extractMilestoneName(heading, versionMatch ? versionMatch[0] : label),
1004
1134
  status,
1005
1135
  line: i + 1,
1006
1136
  heading: heading.trim(),
1007
- });
1137
+ };
1138
+ byLabel.set(label, entry);
1139
+ out.push(entry);
1008
1140
  });
1009
1141
 
1010
1142
  return out;
1011
1143
  }
1012
1144
 
1145
+ /** Any markdown heading line — letter-series milestones carry no `vN.N`. */
1146
+ const ANY_HEADING_RE = /^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*$/;
1147
+
1148
+ /** A letter-series milestone label: one to three capitals, a hyphen, one to three capitals or digits. */
1149
+ const SERIES_LABEL_RE = /^[A-Z]{1,3}-[A-Z0-9]{1,3}$/;
1150
+
1151
+ /**
1152
+ * The letter-series label a heading opens with — after status glyphs, emphasis
1153
+ * and an optional "Milestone" — or null. Only the FIRST word counts, so a label
1154
+ * mentioned inside a heading's text does not make the heading a milestone.
1155
+ */
1156
+ function seriesLabel(heading) {
1157
+ const text = String(heading)
1158
+ .replace(/\p{Extended_Pictographic}️?/gu, ' ')
1159
+ .replace(/[*_`]+/g, '')
1160
+ .trim()
1161
+ .replace(/^milestone\s+/i, '');
1162
+ const first = (text.match(/^([^\s:·—–(]+)/) || [])[1] || '';
1163
+ return SERIES_LABEL_RE.test(first) ? first : null;
1164
+ }
1165
+
1166
+ /**
1167
+ * Order two milestone labels: numerically for `vN.N`, by series then position for
1168
+ * letter-series labels. 0 when they cannot be compared (mixed kinds).
1169
+ */
1170
+ function compareMilestoneLabels(a, b) {
1171
+ const num = (v) => { const m = /^v(\d+(?:\.\d+)*)$/.exec(v); return m ? m[1].split('.').map(Number) : null; };
1172
+ const na = num(a), nb = num(b);
1173
+ if (na && nb) {
1174
+ for (let i = 0; i < Math.max(na.length, nb.length); i++) {
1175
+ const d = (na[i] || 0) - (nb[i] || 0);
1176
+ if (d) return d;
1177
+ }
1178
+ return 0;
1179
+ }
1180
+ const ser = (v) => /^([A-Z]{1,3})-([A-Z0-9]{1,3})$/.exec(v);
1181
+ const sa = ser(a), sb = ser(b);
1182
+ if (sa && sb && sa[1] === sb[1]) return sa[2].localeCompare(sb[2], 'en', { numeric: true });
1183
+ return 0;
1184
+ }
1185
+
1013
1186
  /**
1014
1187
  * Reduce a milestone heading to its bare name.
1015
1188
  * "### 🚧 Milestone v4.1 — Full Platform (phases 1–12)" → "Full Platform"
@@ -1025,9 +1198,12 @@ function extractMilestoneName(heading, versionToken) {
1025
1198
  name = name.replace(/<\/?[^>]+>/g, ' '); // stray inline tags
1026
1199
  name = name.replace(/[*_`]+/g, ''); // markdown emphasis
1027
1200
  name = name.replace(/\p{Extended_Pictographic}️?/gu, ' '); // ✅ 🚧 📋 status glyphs
1028
- name = name.replace(/^[\s:—–\-–]+/, '').replace(/[\s:—–\-–]+$/, '');
1029
- // "Shipped: 2025-11-25" style trailers add nothing to the name.
1030
- name = name.replace(/\b(shipped|completed?|done)\b[:\s]*\d{4}-\d{2}-\d{2}\s*$/i, '').trim();
1201
+ name = name.replace(/^[\s:·—–\-–]+/, '').replace(/[\s:·—–\-–]+$/, '');
1202
+ // "Shipped: 2025-11-25" / "CLOSED 2026-08-21" style trailers add nothing to the name,
1203
+ // and neither does a "— Phases 19-22" range.
1204
+ name = name.replace(/\b(shipped|completed?|done|closed)\b[:\s]*\d{4}-\d{2}-\d{2}\s*$/i, '').trim();
1205
+ name = name.replace(/[\s·—–-]*\bphases?\s+\d+(?:\.\d+)?\s*[-–—]\s*\d+(?:\.\d+)?\s*$/i, '').trim();
1206
+ name = name.replace(/[\s:·—–-]+$/, '');
1031
1207
  return name.trim();
1032
1208
  }
1033
1209
 
@@ -1055,7 +1231,11 @@ function selectCurrentMilestone(headings) {
1055
1231
  const unshipped = headings.find(h => h.status !== 'shipped');
1056
1232
  if (unshipped) return { milestone: unshipped, ambiguous: false, basis: 'first-unshipped' };
1057
1233
 
1058
- return { milestone: headings[headings.length - 1], ambiguous: false, basis: 'last-shipped' };
1234
+ // Everything shipped: the newest is the HIGHEST label, not the last in document
1235
+ // order — a roadmap listing milestones newest-first put the oldest last.
1236
+ let newest = headings[headings.length - 1];
1237
+ for (const h of headings) if (compareMilestoneLabels(h.version, newest.version) > 0) newest = h;
1238
+ return { milestone: newest, ambiguous: false, basis: 'last-shipped' };
1059
1239
  }
1060
1240
 
1061
1241
  /**
@@ -1068,6 +1248,23 @@ function selectCurrentMilestone(headings) {
1068
1248
  * @returns {{version: string, name: string, status: string, basis: string, ambiguous: boolean, candidates: number}}
1069
1249
  * Milestone info (defaults: v1.0, "milestone")
1070
1250
  */
1251
+ /**
1252
+ * `milestone:` / `milestone_name:` from state.md's front matter, or null. Read
1253
+ * inline (frontmatter.cjs requires this module, so it cannot be required here).
1254
+ */
1255
+ function recordedStateMilestone(cwd) {
1256
+ let text;
1257
+ try { text = fs.readFileSync(planningPath(cwd, 'state.md'), 'utf-8'); } catch { return null; }
1258
+ const block = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
1259
+ if (!block) return null;
1260
+ const field = (key) => {
1261
+ const m = block[1].match(new RegExp(`^${key}:[ \\t]*(.+)$`, 'm'));
1262
+ return m ? m[1].trim().replace(/^(['"])(.*)\1$/, '$2') : null;
1263
+ };
1264
+ const version = field('milestone');
1265
+ return version ? { version, name: field('milestone_name') } : null;
1266
+ }
1267
+
1071
1268
  function getMilestoneInfo(cwd) {
1072
1269
  const fallback = { version: 'v1.0', name: 'milestone', status: 'unknown', basis: 'default', ambiguous: false, candidates: 0 };
1073
1270
  let roadmap;
@@ -1079,7 +1276,14 @@ function getMilestoneInfo(cwd) {
1079
1276
 
1080
1277
  const headings = parseMilestoneHeadings(roadmap);
1081
1278
  const { milestone, ambiguous, basis } = selectCurrentMilestone(headings);
1082
- if (!milestone) return { ...fallback, basis: 'no-milestone-heading' };
1279
+ if (!milestone) {
1280
+ // No milestone heading: what state.md records beats a constant. The resolver
1281
+ // used to return `v1.0` / `milestone` here, so correcting state.md's
1282
+ // `milestone:` changed nothing — the fallback never read it.
1283
+ const recorded = recordedStateMilestone(cwd);
1284
+ if (recorded) return { version: recorded.version, name: recorded.name || 'milestone', status: 'unknown', basis: 'state', ambiguous: false, candidates: 0 };
1285
+ return { ...fallback, basis: 'no-milestone-heading' };
1286
+ }
1083
1287
 
1084
1288
  return {
1085
1289
  version: milestone.version,
@@ -1235,6 +1439,8 @@ module.exports = {
1235
1439
  EFFORT_ORDER,
1236
1440
  resolveEffortInternal,
1237
1441
  PROVIDER_MODELS,
1442
+ OPENCODE_MODELS,
1443
+ hostRuntime,
1238
1444
  LEGACY_ALIASES,
1239
1445
  COST_MULTIPLIERS,
1240
1446
  output,
@@ -1255,6 +1461,9 @@ module.exports = {
1255
1461
  getArchivedPhaseDirs,
1256
1462
  getRoadmapPhaseInternal,
1257
1463
  resolveModelInternal,
1464
+ resolveModelDetailed,
1465
+ escalateTier,
1466
+ TIER_LADDER,
1258
1467
  adjustTierForCapabilities,
1259
1468
  detectProvider,
1260
1469
  resolveTierToModel,
@@ -136,6 +136,14 @@ function sumTranscriptUsage(file) {
136
136
  totals.output_tokens += num(u, 'output_tokens');
137
137
  totals.cache_read_tokens += num(u, 'cache_read_input_tokens');
138
138
  totals.cache_write_tokens += num(u, 'cache_creation_input_tokens');
139
+ // The cache-write lifetime split (M13), the same rule as the cost-logger hook:
140
+ // summed only when a record carries `cache_creation`, so the row gains the two
141
+ // fields only when the transcript measured them.
142
+ const cc = u.cache_creation;
143
+ if (cc && typeof cc === 'object') {
144
+ totals.cache_write_1h_tokens = (totals.cache_write_1h_tokens || 0) + num(cc, 'ephemeral_1h_input_tokens');
145
+ totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + num(cc, 'ephemeral_5m_input_tokens');
146
+ }
139
147
  }
140
148
  totals.turns = byMessage.size;
141
149
  let best = null;
@@ -293,6 +301,9 @@ function makeRow(fields) {
293
301
  output_tokens: fields.usage.output_tokens,
294
302
  cache_read_tokens: fields.usage.cache_read_tokens,
295
303
  cache_write_tokens: fields.usage.cache_write_tokens,
304
+ ...(typeof fields.usage.cache_write_1h_tokens === 'number'
305
+ ? { cache_write_1h_tokens: fields.usage.cache_write_1h_tokens, cache_write_5m_tokens: fields.usage.cache_write_5m_tokens || 0 }
306
+ : {}),
296
307
  cost_usd: null,
297
308
  duration_ms: fields.usage.first_ts && fields.usage.last_ts ? Date.parse(fields.usage.last_ts) - Date.parse(fields.usage.first_ts) : null,
298
309
  phase: null,