pan-wizard 3.29.0 → 3.31.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 (50) hide show
  1. package/README.md +40 -40
  2. package/bin/install-lib.cjs +186 -83
  3. package/bin/install.js +213 -195
  4. package/commands/pan/army.md +4 -4
  5. package/commands/pan/audit-deployment.md +2 -2
  6. package/commands/pan/cost.md +5 -5
  7. package/commands/pan/exec-phase.md +2 -0
  8. package/commands/pan/focus-auto.md +5 -5
  9. package/commands/pan/verify-phase.md +8 -9
  10. package/hooks/dist/pan-check-update.js +53 -2
  11. package/hooks/dist/pan-context-monitor.js +41 -0
  12. package/hooks/dist/pan-cost-logger.js +71 -2
  13. package/hooks/dist/pan-state-reinject.js +158 -0
  14. package/hooks/dist/pan-stop-guard.js +127 -2
  15. package/hooks/dist/pan-trace-logger.js +42 -1
  16. package/package.json +1 -1
  17. package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
  18. package/pan-wizard-core/bin/lib/commands.cjs +20 -4
  19. package/pan-wizard-core/bin/lib/config.cjs +2 -0
  20. package/pan-wizard-core/bin/lib/constants.cjs +3 -1
  21. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
  22. package/pan-wizard-core/bin/lib/core.cjs +249 -27
  23. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
  24. package/pan-wizard-core/bin/lib/cost.cjs +234 -88
  25. package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
  26. package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
  27. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
  28. package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
  29. package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
  30. package/pan-wizard-core/bin/lib/state.cjs +86 -22
  31. package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
  32. package/pan-wizard-core/bin/lib/template.cjs +6 -3
  33. package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
  34. package/pan-wizard-core/bin/pan-tools.cjs +10 -1
  35. package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
  36. package/pan-wizard-core/references/model-profiles.md +4 -4
  37. package/pan-wizard-core/references/planning-config.md +19 -23
  38. package/pan-wizard-core/workflows/exec-phase.md +8 -0
  39. package/pan-wizard-core/workflows/help.md +2 -2
  40. package/pan-wizard-core/workflows/phase-tests.md +1 -1
  41. package/pan-wizard-core/workflows/progress.md +1 -1
  42. package/pan-wizard-core/workflows/settings.md +2 -4
  43. package/pan-wizard-core/workflows/todo-add.md +1 -1
  44. package/pan-wizard-core/workflows/todo-check.md +7 -6
  45. package/scripts/build-agent-plugin.js +4 -0
  46. package/scripts/build-hooks.js +2 -1
  47. package/scripts/build-plugin.js +10 -0
  48. package/scripts/npm-dist-tag.js +47 -0
  49. package/scripts/plugin-path.js +6 -1
  50. package/scripts/test-surface.cjs +11 -9
@@ -31,13 +31,74 @@ const { planningPath, planningRel } = require('./utils.cjs');
31
31
  * Each provider maps reasoning/mid/fast to its native model identifiers.
32
32
  * "inherit" means the host runtime uses its own top-tier model selection.
33
33
  */
34
+ //
35
+ // Every non-inherit value must be a model id the host accepts: a Claude Code alias
36
+ // (sonnet, haiku) for Anthropic, otherwise a concrete API id that DEFAULT_RATES in
37
+ // cost.cjs prices exactly (tests/documented-default-models.test.cjs pins both).
38
+ // Until 2026-09-22 the OpenAI row held the literal strings 'mid' and 'fast', so a
39
+ // budget-profile project on Codex or OpenCode was told to spawn a model called
40
+ // "mid" (reality check R28).
41
+ // - openai: Codex's subagent docs (learn.chatgpt.com/docs/agent-configuration/
42
+ // subagents, read 2026-09-23): "start with gpt-6-sol. Use gpt-6-luna when you
43
+ // want a faster, lower-cost option". gpt-6-astra is the flagship above both.
44
+ // - google: the newest stable Flash and Flash-Lite on ai.google.dev's models page
45
+ // (read 2026-09-23). The 2.5 family these rows used is "not deprecated" but
46
+ // limited to users who have used it before, so a new project may not reach it.
34
47
  const PROVIDER_MODELS = {
35
48
  anthropic: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
36
- openai: { reasoning: 'inherit', mid: 'mid', fast: 'fast' },
37
- google: { reasoning: 'inherit', mid: 'gemini-2.5-flash', fast: 'gemini-2.5-flash-lite' },
49
+ openai: { reasoning: 'inherit', mid: 'gpt-6-sol', fast: 'gpt-6-luna' },
50
+ google: { reasoning: 'inherit', mid: 'gemini-3.8-flash', fast: 'gemini-3.5-flash-lite' },
38
51
  default: { reasoning: 'inherit', mid: 'sonnet', fast: 'haiku' },
39
52
  };
40
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
+
41
102
  /** Maps legacy Anthropic model names to provider-agnostic tier aliases. */
42
103
  const LEGACY_ALIASES = { opus: 'reasoning', sonnet: 'mid', haiku: 'fast' };
43
104
 
@@ -52,7 +113,7 @@ const COST_MULTIPLIERS = { reasoning: 15, mid: 3, fast: 1 };
52
113
  // isolation (each subagent runs in its own window), not a cheaper model, is what
53
114
  // keeps the main conversation clean. Cheapness is now OPT-IN: choose the `budget`
54
115
  // profile (the only column that still down-tiers) or pin a specific agent via
55
- // config `model_overrides`. The 3 security agents additionally pin `model: opus`
116
+ // config `model_overrides`. The reviewer-class agents additionally pin `model: opus`
56
117
  // in their own frontmatter (a native, deliberate exception). resolve-model /
57
118
  // MODEL_PROFILES is advisory + cost-estimation; native Claude Code delegation
58
119
  // reads each agent file's static `model:` (unset → inherit).
@@ -341,6 +402,7 @@ function loadConfig(cwd) {
341
402
  research: true,
342
403
  plan_checker: true,
343
404
  verifier: true,
405
+ nyquist_validation: false,
344
406
  parallelization: true,
345
407
  brave_search: false,
346
408
  };
@@ -378,6 +440,9 @@ function loadConfig(cwd) {
378
440
  research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
379
441
  plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
380
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,
381
446
  parallelization,
382
447
  brave_search: get('brave_search') ?? defaults.brave_search,
383
448
  budget: parsed.budget || { default_points: 50, micro_threshold_tasks: 3, micro_threshold_files: 2, enforce: false },
@@ -774,6 +839,37 @@ function adjustTierForCapabilities(tier, opts) {
774
839
  return tier;
775
840
  }
776
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
+
777
873
  /**
778
874
  * Resolve the model for a given agent type based on profile, provider, and routing strategy.
779
875
  * Returns "inherit" for reasoning-tier to let the host runtime use its top-tier model.
@@ -781,30 +877,41 @@ function adjustTierForCapabilities(tier, opts) {
781
877
  * @param {string} agentType - Agent name (e.g., "pan-planner", "pan-executor")
782
878
  * @param {Object} [taskMetadata] - Optional metadata. Supports complexity fields and
783
879
  * capability hints: {context_estimate, needs_thinking, cache_warm}.
784
- * @returns {string} Model identifier: "inherit", "sonnet", "haiku", "mid", "fast", etc.
880
+ * @returns {string} Model identifier: "inherit", a Claude Code alias ("sonnet", "haiku"), or a provider model id from PROVIDER_MODELS
785
881
  */
786
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) {
787
893
  const config = loadConfig(cwd);
788
894
  const provider = detectProvider(cwd, config);
895
+ const pinned = (tier) => ({ model: resolveTierToModel(tier, provider), tier: LEGACY_ALIASES[tier] || tier, escalated_from: null });
789
896
 
790
897
  // Check per-agent override first (highest priority)
791
898
  const override = config.model_overrides?.[agentType];
792
899
  if (override) {
793
- return resolveTierToModel(override, provider);
900
+ return pinned(override);
794
901
  }
795
902
 
796
903
  // Check per-phase override from roadmap (second priority)
797
904
  if (taskMetadata?.phaseNum) {
798
905
  const phaseTier = getPhaseModelTier(cwd, taskMetadata.phaseNum);
799
906
  if (phaseTier) {
800
- return resolveTierToModel(phaseTier, provider);
907
+ return pinned(phaseTier);
801
908
  }
802
909
  }
803
910
 
804
911
  // Fall back to profile lookup
805
912
  const profile = config.model_profile || 'balanced';
806
913
  const agentModels = MODEL_PROFILES[agentType];
807
- if (!agentModels) return resolveTierToModel('mid', provider);
914
+ if (!agentModels) return pinned('mid');
808
915
 
809
916
  let tier = agentModels[profile] || agentModels['balanced'] || 'mid';
810
917
 
@@ -824,7 +931,19 @@ function resolveModelInternal(cwd, agentType, taskMetadata) {
824
931
  tier = adjustTierForCapabilities(tier, taskMetadata);
825
932
  }
826
933
 
827
- 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 };
828
947
  }
829
948
 
830
949
  /**
@@ -833,7 +952,7 @@ function resolveModelInternal(cwd, agentType, taskMetadata) {
833
952
  * @param {Object} config - Loaded config object
834
953
  * @returns {string} Provider name: "anthropic", "openai", "google", or "default"
835
954
  */
836
- function detectProvider(cwd, config) {
955
+ function detectProvider(cwd, config, host = hostRuntime()) {
837
956
  // 1. Explicit config
838
957
  if (config.routing?.provider && config.routing.provider !== 'auto') {
839
958
  const p = config.routing.provider;
@@ -844,7 +963,13 @@ function detectProvider(cwd, config) {
844
963
  if (envProvider) {
845
964
  return PROVIDER_MODELS[envProvider] ? envProvider : 'default';
846
965
  }
847
- // 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
848
973
  const checks = [
849
974
  ['.claude', 'anthropic'], ['.codex', 'openai'],
850
975
  ['.gemini', 'google'], ['.opencode', 'openai'], ['.github', 'default'],
@@ -860,11 +985,13 @@ function detectProvider(cwd, config) {
860
985
  * Resolve a tier alias (or legacy model name) to a provider-specific model name.
861
986
  * @param {string} tier - Tier alias ("reasoning", "mid", "fast") or legacy name ("opus", "sonnet", "haiku")
862
987
  * @param {string} provider - Provider key from detectProvider()
988
+ * @param {string|null} [host] - hostRuntime(); OpenCode gets `provider/model` ids
863
989
  * @returns {string} Provider-specific model name
864
990
  */
865
- function resolveTierToModel(tier, provider) {
991
+ function resolveTierToModel(tier, provider, host = hostRuntime()) {
866
992
  const normalizedTier = LEGACY_ALIASES[tier] || tier;
867
- const providerMap = PROVIDER_MODELS[provider] || PROVIDER_MODELS['default'];
993
+ const table = host === 'opencode' ? OPENCODE_MODELS : PROVIDER_MODELS;
994
+ const providerMap = table[provider] || table['default'];
868
995
  return providerMap[normalizedTier] || providerMap['mid'];
869
996
  }
870
997
 
@@ -945,7 +1072,7 @@ const MILESTONE_SUMMARY_RE = /^[ \t]*<summary>(.*\bv\d+(?:\.\d+)+\b.*?)<\/summar
945
1072
 
946
1073
  /** Status markers PAN's own roadmap template emits, plus their prose forms. */
947
1074
  const MILESTONE_STATUS_MARKERS = [
948
- { 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 },
949
1076
  // `current` is matched as a word anywhere in the heading, not as the whole
950
1077
  // parenthetical: real roadmaps write "(current, phases 1–10)", and requiring
951
1078
  // an exact "(current)" silently missed the marker and fell through to
@@ -967,15 +1094,22 @@ const MILESTONE_STATUS_MARKERS = [
967
1094
  */
968
1095
  function parseMilestoneHeadings(roadmap) {
969
1096
  const out = [];
1097
+ const byLabel = new Map();
970
1098
  const lines = String(roadmap || '').split(/\r?\n/);
971
1099
 
972
1100
  lines.forEach((line, i) => {
973
1101
  const m = line.match(MILESTONE_HEADING_RE) || line.match(MILESTONE_SUMMARY_RE);
974
- if (!m) return;
975
- const heading = m[1];
976
-
977
- const versionMatch = heading.match(/\bv(\d+(?:\.\d+)+)\b/);
978
- 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;
979
1113
 
980
1114
  let status = 'unknown';
981
1115
  for (const marker of MILESTONE_STATUS_MARKERS) {
@@ -985,18 +1119,70 @@ function parseMilestoneHeadings(roadmap) {
985
1119
  // it carries no explicit marker.
986
1120
  if (status === 'unknown' && MILESTONE_SUMMARY_RE.test(line)) status = 'shipped';
987
1121
 
988
- out.push({
989
- version: `v${versionMatch[1]}`,
990
- 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),
991
1134
  status,
992
1135
  line: i + 1,
993
1136
  heading: heading.trim(),
994
- });
1137
+ };
1138
+ byLabel.set(label, entry);
1139
+ out.push(entry);
995
1140
  });
996
1141
 
997
1142
  return out;
998
1143
  }
999
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
+
1000
1186
  /**
1001
1187
  * Reduce a milestone heading to its bare name.
1002
1188
  * "### 🚧 Milestone v4.1 — Full Platform (phases 1–12)" → "Full Platform"
@@ -1012,9 +1198,12 @@ function extractMilestoneName(heading, versionToken) {
1012
1198
  name = name.replace(/<\/?[^>]+>/g, ' '); // stray inline tags
1013
1199
  name = name.replace(/[*_`]+/g, ''); // markdown emphasis
1014
1200
  name = name.replace(/\p{Extended_Pictographic}️?/gu, ' '); // ✅ 🚧 📋 status glyphs
1015
- name = name.replace(/^[\s:—–\-–]+/, '').replace(/[\s:—–\-–]+$/, '');
1016
- // "Shipped: 2025-11-25" style trailers add nothing to the name.
1017
- 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:·—–-]+$/, '');
1018
1207
  return name.trim();
1019
1208
  }
1020
1209
 
@@ -1042,7 +1231,11 @@ function selectCurrentMilestone(headings) {
1042
1231
  const unshipped = headings.find(h => h.status !== 'shipped');
1043
1232
  if (unshipped) return { milestone: unshipped, ambiguous: false, basis: 'first-unshipped' };
1044
1233
 
1045
- 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' };
1046
1239
  }
1047
1240
 
1048
1241
  /**
@@ -1055,6 +1248,23 @@ function selectCurrentMilestone(headings) {
1055
1248
  * @returns {{version: string, name: string, status: string, basis: string, ambiguous: boolean, candidates: number}}
1056
1249
  * Milestone info (defaults: v1.0, "milestone")
1057
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
+
1058
1268
  function getMilestoneInfo(cwd) {
1059
1269
  const fallback = { version: 'v1.0', name: 'milestone', status: 'unknown', basis: 'default', ambiguous: false, candidates: 0 };
1060
1270
  let roadmap;
@@ -1066,7 +1276,14 @@ function getMilestoneInfo(cwd) {
1066
1276
 
1067
1277
  const headings = parseMilestoneHeadings(roadmap);
1068
1278
  const { milestone, ambiguous, basis } = selectCurrentMilestone(headings);
1069
- 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
+ }
1070
1287
 
1071
1288
  return {
1072
1289
  version: milestone.version,
@@ -1222,6 +1439,8 @@ module.exports = {
1222
1439
  EFFORT_ORDER,
1223
1440
  resolveEffortInternal,
1224
1441
  PROVIDER_MODELS,
1442
+ OPENCODE_MODELS,
1443
+ hostRuntime,
1225
1444
  LEGACY_ALIASES,
1226
1445
  COST_MULTIPLIERS,
1227
1446
  output,
@@ -1242,6 +1461,9 @@ module.exports = {
1242
1461
  getArchivedPhaseDirs,
1243
1462
  getRoadmapPhaseInternal,
1244
1463
  resolveModelInternal,
1464
+ resolveModelDetailed,
1465
+ escalateTier,
1466
+ TIER_LADDER,
1245
1467
  adjustTierForCapabilities,
1246
1468
  detectProvider,
1247
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,