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.
- package/README.md +40 -40
- package/bin/install-lib.cjs +186 -83
- package/bin/install.js +213 -195
- package/commands/pan/army.md +4 -4
- package/commands/pan/audit-deployment.md +2 -2
- package/commands/pan/cost.md +5 -5
- package/commands/pan/exec-phase.md +2 -0
- package/commands/pan/focus-auto.md +5 -5
- package/commands/pan/verify-phase.md +8 -9
- package/hooks/dist/pan-check-update.js +53 -2
- package/hooks/dist/pan-context-monitor.js +41 -0
- package/hooks/dist/pan-cost-logger.js +71 -2
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +127 -2
- package/hooks/dist/pan-trace-logger.js +42 -1
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands.cjs +20 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +3 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +249 -27
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +234 -88
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +12 -5
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +86 -22
- package/pan-wizard-core/bin/lib/suggest.cjs +21 -9
- package/pan-wizard-core/bin/lib/template.cjs +6 -3
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +20 -3
- package/pan-wizard-core/bin/pan-tools.cjs +10 -1
- package/pan-wizard-core/mcp/tool-registry.cjs +14 -4
- package/pan-wizard-core/references/model-profiles.md +4 -4
- package/pan-wizard-core/references/planning-config.md +19 -23
- package/pan-wizard-core/workflows/exec-phase.md +8 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/settings.md +2 -4
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- 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: '
|
|
37
|
-
google: { reasoning: 'inherit', mid: 'gemini-
|
|
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
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
975
|
-
const heading = m[1];
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
1016
|
-
// "Shipped: 2025-11-25" style trailers add nothing to the name
|
|
1017
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|