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.
- package/README.md +30 -26
- package/agents/pan-design-checker.md +18 -0
- package/agents/pan-optimizer.md +5 -1
- package/agents/pan-plan-checker.md +23 -0
- package/agents/pan-reviewer.md +19 -0
- package/agents/pan-verifier.md +23 -0
- package/bin/install-lib.cjs +129 -33
- package/bin/install.js +16 -9
- package/commands/pan/army.md +4 -4
- package/commands/pan/design-phase.md +8 -0
- package/commands/pan/exec-phase.md +1 -1
- package/commands/pan/git.md +2 -2
- package/commands/pan/learn.md +2 -0
- package/commands/pan/optimize.md +14 -2
- package/commands/pan/review-deep.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 +42 -1
- package/hooks/dist/pan-cost-logger.js +132 -11
- package/hooks/dist/pan-state-reinject.js +158 -0
- package/hooks/dist/pan-stop-guard.js +50 -4
- package/hooks/dist/pan-trace-logger.js +324 -18
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +22 -5
- package/pan-wizard-core/bin/lib/config.cjs +2 -0
- package/pan-wizard-core/bin/lib/constants.cjs +31 -1
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -2
- package/pan-wizard-core/bin/lib/core.cjs +232 -23
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +11 -0
- package/pan-wizard-core/bin/lib/cost.cjs +135 -46
- package/pan-wizard-core/bin/lib/findings.cjs +381 -0
- package/pan-wizard-core/bin/lib/focus.cjs +72 -19
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +24 -6
- package/pan-wizard-core/bin/lib/frontmatter.cjs +5 -2
- package/pan-wizard-core/bin/lib/hygiene.cjs +12 -2
- package/pan-wizard-core/bin/lib/init.cjs +4 -1
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +12 -6
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +9 -2
- package/pan-wizard-core/bin/lib/optimize.cjs +349 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +4 -1
- package/pan-wizard-core/bin/lib/review-deep.cjs +36 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +87 -18
- package/pan-wizard-core/bin/lib/state.cjs +93 -24
- 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/verdict.cjs +431 -0
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +19 -2
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +156 -0
- package/pan-wizard-core/bin/lib/verify.cjs +25 -2
- package/pan-wizard-core/bin/pan-tools.cjs +69 -6
- package/pan-wizard-core/learnings/README.md +10 -7
- package/pan-wizard-core/mcp/tool-registry.cjs +16 -4
- package/pan-wizard-core/references/verdict-contract.md +101 -0
- package/pan-wizard-core/templates/verification-report.md +16 -0
- package/pan-wizard-core/workflows/exec-phase.md +73 -47
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-wizard-core/workflows/help.md +2 -2
- package/pan-wizard-core/workflows/learn.md +4 -2
- package/pan-wizard-core/workflows/milestone-audit.md +15 -3
- package/pan-wizard-core/workflows/optimize.md +14 -1
- package/pan-wizard-core/workflows/phase-tests.md +1 -1
- package/pan-wizard-core/workflows/plan-phase.md +20 -15
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/todo-add.md +1 -1
- package/pan-wizard-core/workflows/todo-check.md +7 -6
- package/pan-wizard-core/workflows/verify-phase.md +3 -13
- package/pan-zcode/README.md +3 -3
- package/scripts/build-agent-plugin.js +4 -0
- package/scripts/build-hooks.js +2 -1
- package/scripts/build-plugin.js +10 -0
- package/scripts/git-hooks/pre-commit +2 -1
- package/scripts/npm-dist-tag.js +47 -0
- package/scripts/plugin-path.js +6 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
988
|
-
const heading = m[1];
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
|
1029
|
-
// "Shipped: 2025-11-25" style trailers add nothing to the name
|
|
1030
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|