instar 1.3.467 → 1.3.469

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 (86) hide show
  1. package/dist/commands/init.js +5 -0
  2. package/dist/commands/init.js.map +1 -1
  3. package/dist/commands/server.d.ts.map +1 -1
  4. package/dist/commands/server.js +11 -1
  5. package/dist/commands/server.js.map +1 -1
  6. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  7. package/dist/config/ConfigDefaults.js +18 -0
  8. package/dist/config/ConfigDefaults.js.map +1 -1
  9. package/dist/core/CartographerTree.d.ts +124 -0
  10. package/dist/core/CartographerTree.d.ts.map +1 -0
  11. package/dist/core/CartographerTree.js +360 -0
  12. package/dist/core/CartographerTree.js.map +1 -0
  13. package/dist/core/EscalationGovernor.d.ts +138 -0
  14. package/dist/core/EscalationGovernor.d.ts.map +1 -0
  15. package/dist/core/EscalationGovernor.js +308 -0
  16. package/dist/core/EscalationGovernor.js.map +1 -0
  17. package/dist/core/ModelSwapService.d.ts +116 -0
  18. package/dist/core/ModelSwapService.d.ts.map +1 -0
  19. package/dist/core/ModelSwapService.js +266 -0
  20. package/dist/core/ModelSwapService.js.map +1 -0
  21. package/dist/core/ModelTierEscalation.d.ts +128 -0
  22. package/dist/core/ModelTierEscalation.d.ts.map +1 -0
  23. package/dist/core/ModelTierEscalation.js +232 -0
  24. package/dist/core/ModelTierEscalation.js.map +1 -0
  25. package/dist/core/PostUpdateMigrator.d.ts +3 -1
  26. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  27. package/dist/core/PostUpdateMigrator.js +321 -1
  28. package/dist/core/PostUpdateMigrator.js.map +1 -1
  29. package/dist/core/ProjectMapper.d.ts.map +1 -1
  30. package/dist/core/ProjectMapper.js +3 -5
  31. package/dist/core/ProjectMapper.js.map +1 -1
  32. package/dist/core/SessionManager.d.ts +5 -0
  33. package/dist/core/SessionManager.d.ts.map +1 -1
  34. package/dist/core/SessionManager.js +9 -4
  35. package/dist/core/SessionManager.js.map +1 -1
  36. package/dist/core/skipDirs.d.ts +9 -0
  37. package/dist/core/skipDirs.d.ts.map +1 -0
  38. package/dist/core/skipDirs.js +13 -0
  39. package/dist/core/skipDirs.js.map +1 -0
  40. package/dist/core/types.d.ts +14 -0
  41. package/dist/core/types.d.ts.map +1 -1
  42. package/dist/core/types.js.map +1 -1
  43. package/dist/monitoring/BurnDetector.d.ts +10 -0
  44. package/dist/monitoring/BurnDetector.d.ts.map +1 -1
  45. package/dist/monitoring/BurnDetector.js +6 -0
  46. package/dist/monitoring/BurnDetector.js.map +1 -1
  47. package/dist/monitoring/GuardPostureTripwire.d.ts +3 -0
  48. package/dist/monitoring/GuardPostureTripwire.d.ts.map +1 -1
  49. package/dist/monitoring/GuardPostureTripwire.js +44 -0
  50. package/dist/monitoring/GuardPostureTripwire.js.map +1 -1
  51. package/dist/monitoring/TokenLedger.d.ts +7 -0
  52. package/dist/monitoring/TokenLedger.d.ts.map +1 -1
  53. package/dist/monitoring/TokenLedger.js +17 -0
  54. package/dist/monitoring/TokenLedger.js.map +1 -1
  55. package/dist/monitoring/UltraSessionCapMonitor.d.ts +61 -0
  56. package/dist/monitoring/UltraSessionCapMonitor.d.ts.map +1 -0
  57. package/dist/monitoring/UltraSessionCapMonitor.js +103 -0
  58. package/dist/monitoring/UltraSessionCapMonitor.js.map +1 -0
  59. package/dist/scaffold/templates.d.ts.map +1 -1
  60. package/dist/scaffold/templates.js +5 -0
  61. package/dist/scaffold/templates.js.map +1 -1
  62. package/dist/server/AgentServer.d.ts +3 -0
  63. package/dist/server/AgentServer.d.ts.map +1 -1
  64. package/dist/server/AgentServer.js +121 -1
  65. package/dist/server/AgentServer.js.map +1 -1
  66. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  67. package/dist/server/CapabilityIndex.js +16 -0
  68. package/dist/server/CapabilityIndex.js.map +1 -1
  69. package/dist/server/routes.d.ts +7 -0
  70. package/dist/server/routes.d.ts.map +1 -1
  71. package/dist/server/routes.js +113 -6
  72. package/dist/server/routes.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/data/builtin-manifest.json +84 -68
  75. package/src/scaffold/templates.ts +5 -0
  76. package/src/templates/hooks/model-tier-reconciler.js +137 -0
  77. package/src/templates/hooks/model-tier-skill-entry.sh +72 -0
  78. package/src/templates/hooks/settings-template.json +15 -0
  79. package/upgrades/1.3.468.md +25 -0
  80. package/upgrades/1.3.469.md +71 -0
  81. package/upgrades/session-mgmt-buildctx-race.eli16.md +9 -0
  82. package/upgrades/side-effects/cartographer-doc-tree-schema.md +82 -0
  83. package/upgrades/side-effects/model-tier-escalation.md +267 -0
  84. package/upgrades/side-effects/session-mgmt-buildctx-race.md +48 -0
  85. package/upgrades/side-effects/watchdog-launchctl-test-guard.md +84 -0
  86. package/upgrades/watchdog-launchctl-test-guard.eli16.md +9 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * ModelSwapService — §5.3 of docs/specs/FABLE-MODEL-ESCALATION-SPEC.md.
3
+ *
4
+ * The narrow, server-side mid-session model swap (Trigger #1 only). The
5
+ * reconciler hook only SIGNALS — this service is the single authority that
6
+ * performs a swap, and it verifies every swap with an independent oracle.
7
+ *
8
+ * Contract (each clause spec-cited):
9
+ * - `:name` resolves by EXACT match against the live session registry —
10
+ * never globbed, substring-matched, or concatenated into the tmux target
11
+ * (round-2 Security-N1). The only string ever sent to the pane is the
12
+ * server-derived `/model <validated-id>`.
13
+ * - The model id is derived server-side via the §5.1 resolver — NEVER
14
+ * accepted from the caller (Sec-F5).
15
+ * - Refuses protected sessions (authorization boundary) and sessions that
16
+ * are not idle-with-empty-input (round-2 Security-F6 live-input
17
+ * collision window).
18
+ * - Honors `enabled:false` / `dryRun:true`; dryRun evaluates every gate
19
+ * but acquires nothing and injects nothing.
20
+ * - Injects via tmux `send-keys -l --` + a SEPARATE Enter (SessionManager
21
+ * .sendInput — the same hardened primitive the input route uses).
22
+ * - **Canary read-back via an oracle independent of the Session.model
23
+ * field this swap would write** (round-3 Integration-NEW-1): the pane's
24
+ * own acknowledgment of the /model command, parsed to exclude the echo
25
+ * of our injected input. Only on a confirmed match is Session.model
26
+ * updated. Unconfirmed ⇒ Session.model untouched, behaviourally default,
27
+ * ONE Attention item (round-1 Adversarial-H5 / Lessons-C3).
28
+ * - **Accounting fails toward counting** (round-2 Adversarial-NEW-2): the
29
+ * escalation is counted at injection, before the canary, once per
30
+ * (instance, transition) episode.
31
+ * - Server-side dwell guard: never swaps the same session twice within
32
+ * `minTierDwellMs` (§5.5 hysteresis backstop; suppressed flaps audited).
33
+ */
34
+ import * as fs from 'node:fs';
35
+ import * as path from 'node:path';
36
+ import { IDLE_PROMPT_PATTERNS } from './SessionManager.js';
37
+ import { SWAP_CAPABILITY, escalatedModelIds, resolveTierModel, } from './ModelTierEscalation.js';
38
+ /**
39
+ * Does the pane tail show a prompt-ready idle state WITH an empty input
40
+ * line? Fail-closed: anything ambiguous returns false and the swap is
41
+ * refused as retryable (round-2 Security-F6).
42
+ */
43
+ export function paneIdleWithEmptyInput(tail) {
44
+ if (!tail)
45
+ return false;
46
+ const idle = IDLE_PROMPT_PATTERNS.some(p => tail.includes(p));
47
+ if (!idle)
48
+ return false;
49
+ // Empty input line: a prompt row with nothing typed after the prompt char —
50
+ // bare, or showing only the CLI's own placeholder hint (`Try "…`). The REAL
51
+ // CLI renders the prompt as `❯` (U+276F); synthetic/test panes and older
52
+ // renders use ASCII `>` — accept both (live-canary finding, 2026-06-09:
53
+ // the ASCII-only match made idle detection fail against every real
54
+ // session, refusing all swaps as not-idle).
55
+ const lines = tail.split('\n');
56
+ return lines.some(line => {
57
+ const stripped = line.replace(/[│|]/g, ' ').replace(/❯/g, '>').trim();
58
+ if (stripped === '>')
59
+ return true;
60
+ if (/^>\s*Try "/.test(stripped))
61
+ return true;
62
+ return false;
63
+ });
64
+ }
65
+ /**
66
+ * Independent-oracle parse (§5.3 canary): does the pane acknowledge that
67
+ * the model is now `modelId`, on a line that is NOT the echo of our own
68
+ * injected `/model …` input? Conservative by design: an unrecognized format
69
+ * reads as NOT confirmed (the spec's honest-degrade direction).
70
+ *
71
+ * The REAL CLI acks with the model's DISPLAY NAME, not the id (live-canary
72
+ * finding, 2026-06-09): `/model claude-fable-5` → "Set model to Fable 5 and
73
+ * saved as your default for new sessions". We accept the exact id OR the
74
+ * display form derived from the id (closed-enum ids only ever reach here,
75
+ * so the derivation is over a known, validated vocabulary): family token
76
+ * capitalized + up to two leading version components dot-joined —
77
+ * claude-fable-5 → "Fable 5", claude-opus-4-8 → "Opus 4.8".
78
+ */
79
+ export function paneConfirmsModel(tail, modelId) {
80
+ if (!tail)
81
+ return false;
82
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ const alternatives = [escapeRe(modelId)];
84
+ const m = modelId.match(/^(?:claude-)?([a-z]+)((?:-[0-9]+)*)/i);
85
+ if (m) {
86
+ const family = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
87
+ const version = m[2].split('-').filter(Boolean).slice(0, 2).join('.');
88
+ alternatives.push(escapeRe(version ? `${family} ${version}` : family));
89
+ }
90
+ const ack = new RegExp(`set model to[^\\n]*\\b(?:${alternatives.join('|')})\\b`, 'i');
91
+ return tail.split('\n').some(line => {
92
+ if (line.includes('/model'))
93
+ return false; // echo of our injected input
94
+ return ack.test(line);
95
+ });
96
+ }
97
+ export class ModelSwapService {
98
+ deps;
99
+ auditPath;
100
+ now;
101
+ wait;
102
+ /** In-memory per-session last-swap timestamps (dwell backstop). Reset on
103
+ * restart — the safe direction (a restart may swap immediately). */
104
+ lastSwapAt = new Map();
105
+ constructor(deps) {
106
+ this.deps = deps;
107
+ this.auditPath = path.join(deps.stateDir, 'state', 'model-tier-escalation', 'audit.jsonl');
108
+ this.now = deps.now ?? (() => Date.now());
109
+ this.wait = deps.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
110
+ }
111
+ /**
112
+ * Swap `name`'s session to `tier`. Body-validated upstream; this method
113
+ * re-validates everything it relies on (defense in depth).
114
+ */
115
+ async swap(name, tier) {
116
+ const cfg = this.deps.getConfig();
117
+ // Exact-match registry lookup — by Session.name first, then by the
118
+ // registry's own tmuxSession value. Never partial, never globbed.
119
+ const running = this.deps.sessions.listRunningSessions();
120
+ const session = running.find(s => s.name === name) ?? running.find(s => s.tmuxSession === name);
121
+ if (!session) {
122
+ return this.refuse(name, tier, 'unknown-session');
123
+ }
124
+ const framework = (session.framework ?? 'claude-code');
125
+ if (SWAP_CAPABILITY[framework] !== 'mid-session') {
126
+ // §5.6 — declared capability honored by code, not prose.
127
+ return this.refuse(session.name, tier, 'launch-time-only-framework');
128
+ }
129
+ if (this.deps.protectedSessions().includes(session.tmuxSession)) {
130
+ return this.refuse(session.name, tier, 'protected-session');
131
+ }
132
+ // Server-derived id — the caller can only ever name a TIER.
133
+ const targetId = resolveTierModel(framework, tier, cfg, e => this.audit({ type: 'resolve-rejected', ...e }));
134
+ if (targetId == null) {
135
+ // Back-compat contract: no model configured ⇒ zero swaps, not an error.
136
+ this.audit({ type: 'noop', session: session.name, tier, reason: 'no-model-configured' });
137
+ return { status: 'noop', reason: 'no-model-configured' };
138
+ }
139
+ if (session.model === targetId) {
140
+ return { status: 'noop', reason: 'already-on-tier', model: targetId, confirmed: true };
141
+ }
142
+ // RESCUE DE-ESCALATION (Phase-5 review finding): a `tier:'default'` swap
143
+ // for a session CURRENTLY on an escalated id bypasses the enabled/dryRun
144
+ // gates. Those flags are the rollback levers — if they also refused the
145
+ // swap-back, an escalated session would be stranded on the ultra model in
146
+ // exactly the state the operator is trying to leave (the one refusal
147
+ // whose failure direction is MORE spend, inverting §3.5). Strictly
148
+ // cost-reducing, mirrors the governor exemption below; idle/protected/
149
+ // dwell/canary gates all still apply. Fleet installs stay inert: their
150
+ // sessions are never on an escalated id, so disabled still refuses.
151
+ const isRescueDeescalation = tier === 'default' && session.model != null && escalatedModelIds(cfg).has(session.model);
152
+ if (!cfg.enabled && !isRescueDeescalation) {
153
+ return this.refuse(session.name, tier, 'disabled');
154
+ }
155
+ // Dwell backstop (§5.5): never swap the same session twice within
156
+ // minTierDwellMs. Suppressed flaps are audited.
157
+ const last = this.lastSwapAt.get(session.id);
158
+ if (last != null && this.now() - last < cfg.costGuards.minTierDwellMs) {
159
+ this.audit({ type: 'flap-suppressed', session: session.name, tier, sinceLastSwapMs: this.now() - last });
160
+ return { status: 'refused', reason: 'dwell' };
161
+ }
162
+ // Idle + empty input line (round-2 Security-F6) — fail closed.
163
+ const tail = this.deps.sessions.captureMeaningfulTail(session.tmuxSession, 8);
164
+ if (!paneIdleWithEmptyInput(tail)) {
165
+ return this.refuse(session.name, tier, 'not-idle');
166
+ }
167
+ // §7/§8 cost gates — only escalations consume budget/lease; a swap back
168
+ // to default is always cost-free (it REDUCES spend).
169
+ const transition = `${session.model ?? 'default'}→${tier}`;
170
+ let admit = { allow: true };
171
+ if (tier === 'escalated') {
172
+ admit = this.deps.governor.admitEscalation({
173
+ instanceId: session.id,
174
+ accountId: session.subscriptionAccountId,
175
+ modelId: targetId,
176
+ transition,
177
+ dry: cfg.dryRun,
178
+ });
179
+ if (!admit.allow) {
180
+ return this.refuse(session.name, tier, `cost-guard:${admit.reason}`);
181
+ }
182
+ }
183
+ if (cfg.dryRun && !isRescueDeescalation) {
184
+ this.audit({
185
+ type: 'dry-run-would-swap',
186
+ session: session.name,
187
+ tier,
188
+ model: targetId,
189
+ freeWindow: admit.freeWindow,
190
+ });
191
+ return { status: 'dry-run', model: targetId };
192
+ }
193
+ if (isRescueDeescalation && (!cfg.enabled || cfg.dryRun)) {
194
+ // Loud trail: a real swap performed while the feature is off/dry is
195
+ // exactly the event an operator auditing a rollback wants to see.
196
+ this.audit({ type: 'rescue-deescalation', session: session.name, model: targetId, enabled: cfg.enabled, dryRun: cfg.dryRun });
197
+ }
198
+ // Inject: literal text + separate Enter via the hardened primitive. The
199
+ // ONLY pane-bound string is the server-derived, enum-validated id.
200
+ const sent = this.deps.sessions.sendInput(session.tmuxSession, `/model ${targetId}`);
201
+ if (!sent) {
202
+ return this.refuse(session.name, tier, 'inject-failed');
203
+ }
204
+ if (tier === 'escalated') {
205
+ // Fails toward counting — recorded at injection, before the canary.
206
+ this.deps.governor.recordInjection(session.id, transition);
207
+ }
208
+ this.audit({ type: 'injected', session: session.name, tier, model: targetId });
209
+ // Canary read-back — independent oracle, N attempts.
210
+ const attempts = this.deps.canaryAttempts ?? 5;
211
+ const interval = this.deps.canaryIntervalMs ?? 600;
212
+ for (let i = 0; i < attempts; i++) {
213
+ await this.wait(interval);
214
+ const readback = this.deps.sessions.captureMeaningfulTail(session.tmuxSession, 30);
215
+ if (paneConfirmsModel(readback, targetId)) {
216
+ session.model = targetId;
217
+ try {
218
+ this.deps.saveSession(session);
219
+ }
220
+ catch (err) {
221
+ this.audit({ type: 'save-session-failed', session: session.name, error: err.message });
222
+ }
223
+ this.lastSwapAt.set(session.id, this.now());
224
+ this.audit({ type: 'swap-confirmed', session: session.name, tier, model: targetId, attempts: i + 1 });
225
+ return { status: 'swapped', model: targetId, confirmed: true };
226
+ }
227
+ }
228
+ // NOT confirmed: do NOT mark reconciled, do NOT touch Session.model;
229
+ // behaviourally the session is treated as default. One Attention item.
230
+ this.lastSwapAt.set(session.id, this.now()); // still dwell — don't re-inject in a tight loop
231
+ this.audit({ type: 'swap-unconfirmed', session: session.name, tier, model: targetId, attempts });
232
+ try {
233
+ this.deps.attention?.({
234
+ id: `model-swap-unconfirmed-${session.id}-${tier}`,
235
+ title: `Model swap unconfirmed: ${session.name}`,
236
+ summary: `Injected /model ${targetId} into "${session.name}" but the independent ` +
237
+ `read-back did not confirm within ${attempts} attempts. The session is ` +
238
+ `treated as DEFAULT tier; the swap was still counted against the ` +
239
+ `escalation budget (fails toward counting).`,
240
+ category: 'model-tier-escalation',
241
+ priority: 'NORMAL',
242
+ });
243
+ }
244
+ catch {
245
+ // attention surface unavailable — the audit record stands
246
+ }
247
+ return { status: 'unconfirmed', model: targetId, confirmed: false };
248
+ }
249
+ // ── internals ──────────────────────────────────────────────────────────
250
+ refuse(sessionName, tier, reason) {
251
+ this.audit({ type: 'refused', session: sessionName, tier, reason });
252
+ return { status: 'refused', reason };
253
+ }
254
+ /** Structured-fields-only audit appender (Sec-F7) — same trail as the
255
+ * governor so one file tells the whole escalation story. */
256
+ audit(event) {
257
+ try {
258
+ fs.mkdirSync(path.dirname(this.auditPath), { recursive: true });
259
+ fs.appendFileSync(this.auditPath, JSON.stringify({ ts: new Date(this.now()).toISOString(), source: 'model-swap', ...event }) + '\n');
260
+ }
261
+ catch {
262
+ // best-effort — never throws into the swap path
263
+ }
264
+ }
265
+ }
266
+ //# sourceMappingURL=ModelSwapService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModelSwapService.js","sourceRoot":"","sources":["../../src/core/ModelSwapService.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ModelTierEscalation — §5.1 of docs/specs/FABLE-MODEL-ESCALATION-SPEC.md.
3
+ *
4
+ * The per-framework (framework, tier) → concrete-model-id resolver plus the
5
+ * closed model-id enumerations and per-adapter swap-capability declarations.
6
+ *
7
+ * Core contracts (all spec-cited, all fail-closed):
8
+ * - Reads ONLY trusted config (`models.tierEscalation`) — never the
9
+ * mode-state file. The mode-state can request a TIER; it can never
10
+ * supply a model ID (spec Sec-F3).
11
+ * - `escalated: null` (or an absent framework entry) resolves to the
12
+ * default ⇒ no swap ever — the backwards-compat contract for
13
+ * codex/gemini/pi until they ship an ultra model (spec §5.1).
14
+ * - Every id is validated by regex AND membership in the framework's
15
+ * closed `knownModelIds` enumeration before it can reach a launch arg
16
+ * or tmux send-keys (spec Sec-F1/F2 — keystroke-injection guard).
17
+ * - Worst-case failure of every path = `null` = the session stays on its
18
+ * default model (spec §3.5: a routing decision, never a block).
19
+ *
20
+ * Pure logic, no I/O. The governor (§7/§8) and swap service (§5.3) live in
21
+ * EscalationGovernor.ts / ModelSwapService.ts.
22
+ */
23
+ /** The frameworks the escalation policy knows. Closed enum — anything else
24
+ * resolves to null (spec §5.1 line 1). Kept as a literal union to avoid an
25
+ * import cycle with core/types.ts (matches the inline pattern used there). */
26
+ export type EscalationFramework = 'claude-code' | 'codex-cli' | 'gemini-cli' | 'pi-cli';
27
+ export declare const ESCALATION_FRAMEWORKS: readonly EscalationFramework[];
28
+ export type EscalationTier = 'default' | 'escalated';
29
+ /** §5.6 — machine-checkable per-adapter swap capability, not prose. */
30
+ export type SwapCapability = 'mid-session' | 'launch-time-only';
31
+ export interface TierEscalationFrameworkEntry {
32
+ /** `null` ⇒ use the account default (today's behavior). */
33
+ default: string | null;
34
+ /** `null` ⇒ no ultra model ⇒ escalation is a strict no-op (back-compat). */
35
+ escalated: string | null;
36
+ }
37
+ export interface TierEscalationTriggers {
38
+ /** Skill names whose entry signals an escalation work-mode (§4). */
39
+ skills: string[];
40
+ /** Trigger #1 also fires on project-initiative design state (§4). */
41
+ projectDesign: boolean;
42
+ /** Optional LLM intent check for ambiguous phrasing (§5.4). Default OFF. */
43
+ llmIntentCheck: boolean;
44
+ }
45
+ export interface TierEscalationCostGuards {
46
+ /** Map of model id → last free-window UTC date (inclusive). §8. */
47
+ respectFreeWindows: Record<string, string>;
48
+ /** Refuse escalation without a cached quota snapshot showing headroom (§7).
49
+ * Quota unavailable/errored ⇒ fail CLOSED to default. */
50
+ requireQuotaHeadroom: boolean;
51
+ /** §7 lease cap — admission control for concurrent escalated sessions. */
52
+ maxConcurrentEscalatedPerAccount: number;
53
+ /** §8 — load-bearing for Trigger #1 (short in-conversation swaps). */
54
+ maxEscalationsPerHour: number;
55
+ /** §8 — load-bearing for Trigger #2 (long spawned runs). null = disabled. */
56
+ dailyUltraTokenCap: number | null;
57
+ /** §5.5 — mode-state self-expiry. Expiry INVALIDATES (fresh trigger needed). */
58
+ maxEscalationTtlMs: number;
59
+ /** §5.5 asymmetric hysteresis — never swap twice within this window. */
60
+ minTierDwellMs: number;
61
+ /** §5.5 — de-escalate only after the condition is clear this many turns. */
62
+ minTierDwellTurns: number;
63
+ }
64
+ export interface TierEscalationConfig {
65
+ /** Fleet default OFF; dev agents (Echo/Codey) ship ENABLED (§10). */
66
+ enabled: boolean;
67
+ /** Log intended swaps without performing them. `enabled:false` wins. */
68
+ dryRun: boolean;
69
+ triggers: TierEscalationTriggers;
70
+ frameworks: Partial<Record<EscalationFramework, TierEscalationFrameworkEntry>>;
71
+ costGuards: TierEscalationCostGuards;
72
+ }
73
+ /** §9 schema defaults — exactly the converged spec's config block. */
74
+ export declare const DEFAULT_TIER_ESCALATION_CONFIG: TierEscalationConfig;
75
+ export declare const KNOWN_CLAUDE_MODEL_IDS: readonly ["claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "opus", "sonnet", "haiku"];
76
+ export declare const KNOWN_CODEX_MODEL_IDS: readonly ["gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5"];
77
+ export declare const KNOWN_MODEL_IDS: Record<EscalationFramework, readonly string[]>;
78
+ /**
79
+ * §5.6 — per-adapter swap capability. `claude-code` declares 'mid-session'
80
+ * but the §5.3 contract still verifies EVERY swap with the independent-oracle
81
+ * canary at runtime; the pre-enable proof is the gated live E2E
82
+ * (tests/e2e/model-swap-live-canary.test.ts). The other three are
83
+ * launch-time-only today (and strict no-ops until an escalated model is
84
+ * populated), so "documented, not silently dropped" is enforced by code.
85
+ */
86
+ export declare const SWAP_CAPABILITY: Record<EscalationFramework, SwapCapability>;
87
+ /** §5.1 injection-safe id shape. Anything else is audit+reject. */
88
+ export declare const MODEL_ID_RE: RegExp;
89
+ /** Reason codes for fail-closed resolutions — structured, audit-friendly. */
90
+ export type ResolveRejectReason = 'unknown-framework' | 'no-framework-entry' | 'null-id' | 'invalid-id-shape' | 'id-not-in-closed-enum';
91
+ export interface ResolveAuditEvent {
92
+ framework: string;
93
+ tier: EscalationTier;
94
+ reason: ResolveRejectReason;
95
+ /** Escaped + truncated — never the raw value verbatim past 80 chars (Sec-F7). */
96
+ rejectedId?: string;
97
+ }
98
+ /** Escape + truncate an untrusted id for audit records (spec Sec-F7). */
99
+ export declare function escapeIdForAudit(id: string): string;
100
+ /**
101
+ * §5.1 resolver — maps (framework, tier) → concrete model id | null,
102
+ * reading ONLY the trusted config. Every rejection fails closed to null
103
+ * (session stays on default) and reports a structured audit event.
104
+ */
105
+ export declare function resolveTierModel(framework: string, tier: EscalationTier, config: TierEscalationConfig | undefined, audit?: (event: ResolveAuditEvent) => void): string | null;
106
+ /**
107
+ * True when escalation could ever change behavior for this framework:
108
+ * an escalated id is configured AND resolves through the closed enum.
109
+ * Used by callers to keep the no-escalated-model case a true no-op.
110
+ */
111
+ export declare function hasEscalatedModel(framework: string, config: TierEscalationConfig | undefined): boolean;
112
+ /** The set of configured escalated ids across all frameworks — used by the
113
+ * §11 router-exclusion guard and the §8 ultra-cap monitor. */
114
+ export declare function escalatedModelIds(config: TierEscalationConfig | undefined): Set<string>;
115
+ /**
116
+ * §8 free-window semantics: compared as a UTC date, inclusive through the
117
+ * named day. Returns true while the window is open. After expiry the
118
+ * quota/budget guards apply unchanged — the window relaxes nothing
119
+ * structural (informational + drives the dev-agent dogfood window).
120
+ */
121
+ export declare function isWithinFreeWindow(modelId: string, guards: TierEscalationCostGuards, nowMs: number): boolean;
122
+ /**
123
+ * Merge an operator's (possibly partial / absent) config block over the §9
124
+ * defaults. Add-missing-only semantics live in PostUpdateMigrator; this is
125
+ * the READ-side normalizer so every consumer sees a complete shape.
126
+ */
127
+ export declare function normalizeTierEscalationConfig(raw: unknown): TierEscalationConfig;
128
+ //# sourceMappingURL=ModelTierEscalation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModelTierEscalation.d.ts","sourceRoot":"","sources":["../../src/core/ModelTierEscalation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,232 @@
1
+ /**
2
+ * ModelTierEscalation — §5.1 of docs/specs/FABLE-MODEL-ESCALATION-SPEC.md.
3
+ *
4
+ * The per-framework (framework, tier) → concrete-model-id resolver plus the
5
+ * closed model-id enumerations and per-adapter swap-capability declarations.
6
+ *
7
+ * Core contracts (all spec-cited, all fail-closed):
8
+ * - Reads ONLY trusted config (`models.tierEscalation`) — never the
9
+ * mode-state file. The mode-state can request a TIER; it can never
10
+ * supply a model ID (spec Sec-F3).
11
+ * - `escalated: null` (or an absent framework entry) resolves to the
12
+ * default ⇒ no swap ever — the backwards-compat contract for
13
+ * codex/gemini/pi until they ship an ultra model (spec §5.1).
14
+ * - Every id is validated by regex AND membership in the framework's
15
+ * closed `knownModelIds` enumeration before it can reach a launch arg
16
+ * or tmux send-keys (spec Sec-F1/F2 — keystroke-injection guard).
17
+ * - Worst-case failure of every path = `null` = the session stays on its
18
+ * default model (spec §3.5: a routing decision, never a block).
19
+ *
20
+ * Pure logic, no I/O. The governor (§7/§8) and swap service (§5.3) live in
21
+ * EscalationGovernor.ts / ModelSwapService.ts.
22
+ */
23
+ export const ESCALATION_FRAMEWORKS = [
24
+ 'claude-code',
25
+ 'codex-cli',
26
+ 'gemini-cli',
27
+ 'pi-cli',
28
+ ];
29
+ /** §9 schema defaults — exactly the converged spec's config block. */
30
+ export const DEFAULT_TIER_ESCALATION_CONFIG = {
31
+ enabled: false,
32
+ dryRun: true,
33
+ triggers: {
34
+ skills: ['build', 'autonomous', 'instar-dev', 'spec-converge'],
35
+ projectDesign: true,
36
+ llmIntentCheck: false,
37
+ },
38
+ frameworks: {
39
+ 'claude-code': { default: 'claude-opus-4-8', escalated: 'claude-fable-5' },
40
+ 'codex-cli': { default: null, escalated: null },
41
+ 'gemini-cli': { default: null, escalated: null },
42
+ 'pi-cli': { default: null, escalated: null },
43
+ },
44
+ costGuards: {
45
+ respectFreeWindows: { 'claude-fable-5': '2026-06-22' },
46
+ requireQuotaHeadroom: true,
47
+ maxConcurrentEscalatedPerAccount: 2,
48
+ maxEscalationsPerHour: 8,
49
+ dailyUltraTokenCap: null,
50
+ maxEscalationTtlMs: 21_600_000, // 6h
51
+ minTierDwellMs: 300_000, // 5min
52
+ minTierDwellTurns: 1,
53
+ },
54
+ };
55
+ /**
56
+ * Closed model-id enumerations per launch adapter (§5.2(c), net-new).
57
+ * A config value outside its framework's list NEVER reaches a launch arg or
58
+ * send-keys — resolveTierModel fails closed to null instead.
59
+ *
60
+ * claude-code: concrete ids the Claude CLI accepts via `--model`, plus the
61
+ * CLI tier aliases it documents (haiku/sonnet/opus). Includes the ultra
62
+ * model `claude-fable-5` — the first populated escalation target.
63
+ * codex-cli: mirror of CODEX_MODELS_SUBSCRIPTION (routes.ts spawn allowlist).
64
+ * gemini-cli: re-exported from the adapter's own closed list.
65
+ * pi-cli: CLOSED-EMPTY by design — pi model ids are `provider/id` patterns
66
+ * whose provider half is per-agent config; there is no universal closed
67
+ * enumeration, so the escalation policy refuses ALL pi ids (strict no-op,
68
+ * §5.6) until a real enumeration is populated. The `/` also fails the id
69
+ * regex — two independent fail-closed layers.
70
+ */
71
+ import { KNOWN_GEMINI_MODELS } from '../providers/adapters/gemini-cli/models.js';
72
+ export const KNOWN_CLAUDE_MODEL_IDS = [
73
+ 'claude-fable-5',
74
+ 'claude-opus-4-8',
75
+ 'claude-opus-4-7',
76
+ 'claude-opus-4-6',
77
+ 'claude-sonnet-4-6',
78
+ 'claude-haiku-4-5',
79
+ 'claude-haiku-4-5-20251001',
80
+ 'opus',
81
+ 'sonnet',
82
+ 'haiku',
83
+ ];
84
+ export const KNOWN_CODEX_MODEL_IDS = [
85
+ 'gpt-5.2',
86
+ 'gpt-5.3-codex',
87
+ 'gpt-5.4',
88
+ 'gpt-5.4-mini',
89
+ 'gpt-5.5',
90
+ ];
91
+ export const KNOWN_MODEL_IDS = {
92
+ 'claude-code': KNOWN_CLAUDE_MODEL_IDS,
93
+ 'codex-cli': KNOWN_CODEX_MODEL_IDS,
94
+ 'gemini-cli': KNOWN_GEMINI_MODELS,
95
+ 'pi-cli': [],
96
+ };
97
+ /**
98
+ * §5.6 — per-adapter swap capability. `claude-code` declares 'mid-session'
99
+ * but the §5.3 contract still verifies EVERY swap with the independent-oracle
100
+ * canary at runtime; the pre-enable proof is the gated live E2E
101
+ * (tests/e2e/model-swap-live-canary.test.ts). The other three are
102
+ * launch-time-only today (and strict no-ops until an escalated model is
103
+ * populated), so "documented, not silently dropped" is enforced by code.
104
+ */
105
+ export const SWAP_CAPABILITY = {
106
+ 'claude-code': 'mid-session',
107
+ 'codex-cli': 'launch-time-only',
108
+ 'gemini-cli': 'launch-time-only',
109
+ 'pi-cli': 'launch-time-only',
110
+ };
111
+ /** §5.1 injection-safe id shape. Anything else is audit+reject. */
112
+ export const MODEL_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
113
+ /** Escape + truncate an untrusted id for audit records (spec Sec-F7). */
114
+ export function escapeIdForAudit(id) {
115
+ const escaped = JSON.stringify(id).slice(1, -1); // escape control chars/quotes
116
+ return escaped.length > 80 ? `${escaped.slice(0, 80)}…(truncated)` : escaped;
117
+ }
118
+ /**
119
+ * §5.1 resolver — maps (framework, tier) → concrete model id | null,
120
+ * reading ONLY the trusted config. Every rejection fails closed to null
121
+ * (session stays on default) and reports a structured audit event.
122
+ */
123
+ export function resolveTierModel(framework, tier, config, audit) {
124
+ if (!ESCALATION_FRAMEWORKS.includes(framework)) {
125
+ audit?.({ framework, tier, reason: 'unknown-framework' });
126
+ return null;
127
+ }
128
+ const fw = framework;
129
+ const frameworks = config?.frameworks ?? {};
130
+ // Own-property lookup only — a polluted Object.prototype key can never
131
+ // smuggle an entry in (§5.1 "Object.create(null) / hasOwnProperty lookup").
132
+ const entry = Object.prototype.hasOwnProperty.call(frameworks, fw)
133
+ ? frameworks[fw]
134
+ : undefined;
135
+ if (!entry) {
136
+ audit?.({ framework, tier, reason: 'no-framework-entry' });
137
+ return null;
138
+ }
139
+ const id = tier === 'escalated' ? (entry.escalated ?? entry.default) : entry.default;
140
+ if (id == null) {
141
+ // Not an anomaly: `default: null` means "use the account default"
142
+ // (today's behavior) and `escalated: null` means strict no-op.
143
+ return null;
144
+ }
145
+ if (typeof id !== 'string' || !MODEL_ID_RE.test(id)) {
146
+ audit?.({ framework, tier, reason: 'invalid-id-shape', rejectedId: escapeIdForAudit(String(id)) });
147
+ return null;
148
+ }
149
+ if (!KNOWN_MODEL_IDS[fw].includes(id)) {
150
+ audit?.({ framework, tier, reason: 'id-not-in-closed-enum', rejectedId: escapeIdForAudit(id) });
151
+ return null;
152
+ }
153
+ return id;
154
+ }
155
+ /**
156
+ * True when escalation could ever change behavior for this framework:
157
+ * an escalated id is configured AND resolves through the closed enum.
158
+ * Used by callers to keep the no-escalated-model case a true no-op.
159
+ */
160
+ export function hasEscalatedModel(framework, config) {
161
+ const escalated = resolveTierModel(framework, 'escalated', config);
162
+ const dflt = resolveTierModel(framework, 'default', config);
163
+ return escalated != null && escalated !== dflt;
164
+ }
165
+ /** The set of configured escalated ids across all frameworks — used by the
166
+ * §11 router-exclusion guard and the §8 ultra-cap monitor. */
167
+ export function escalatedModelIds(config) {
168
+ const ids = new Set();
169
+ for (const fw of ESCALATION_FRAMEWORKS) {
170
+ const id = resolveTierModel(fw, 'escalated', config);
171
+ const dflt = resolveTierModel(fw, 'default', config);
172
+ if (id != null && id !== dflt)
173
+ ids.add(id);
174
+ }
175
+ return ids;
176
+ }
177
+ /**
178
+ * §8 free-window semantics: compared as a UTC date, inclusive through the
179
+ * named day. Returns true while the window is open. After expiry the
180
+ * quota/budget guards apply unchanged — the window relaxes nothing
181
+ * structural (informational + drives the dev-agent dogfood window).
182
+ */
183
+ export function isWithinFreeWindow(modelId, guards, nowMs) {
184
+ const windows = guards.respectFreeWindows ?? {};
185
+ if (!Object.prototype.hasOwnProperty.call(windows, modelId))
186
+ return false;
187
+ const dateStr = windows[modelId];
188
+ if (typeof dateStr !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr))
189
+ return false;
190
+ // Inclusive through the named UTC day: open until the start of the next day.
191
+ const endExclusive = Date.parse(`${dateStr}T00:00:00.000Z`) + 24 * 60 * 60 * 1000;
192
+ return Number.isFinite(endExclusive) && nowMs < endExclusive;
193
+ }
194
+ /**
195
+ * Merge an operator's (possibly partial / absent) config block over the §9
196
+ * defaults. Add-missing-only semantics live in PostUpdateMigrator; this is
197
+ * the READ-side normalizer so every consumer sees a complete shape.
198
+ */
199
+ export function normalizeTierEscalationConfig(raw) {
200
+ const d = DEFAULT_TIER_ESCALATION_CONFIG;
201
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
202
+ return {
203
+ ...d,
204
+ triggers: { ...d.triggers, skills: [...d.triggers.skills] },
205
+ frameworks: { ...d.frameworks },
206
+ costGuards: { ...d.costGuards, respectFreeWindows: { ...d.costGuards.respectFreeWindows } },
207
+ };
208
+ }
209
+ const r = raw;
210
+ return {
211
+ enabled: typeof r.enabled === 'boolean' ? r.enabled : d.enabled,
212
+ dryRun: typeof r.dryRun === 'boolean' ? r.dryRun : d.dryRun,
213
+ triggers: {
214
+ skills: Array.isArray(r.triggers?.skills)
215
+ ? r.triggers.skills.filter((s) => typeof s === 'string')
216
+ : [...d.triggers.skills],
217
+ projectDesign: typeof r.triggers?.projectDesign === 'boolean' ? r.triggers.projectDesign : d.triggers.projectDesign,
218
+ llmIntentCheck: typeof r.triggers?.llmIntentCheck === 'boolean' ? r.triggers.llmIntentCheck : d.triggers.llmIntentCheck,
219
+ },
220
+ frameworks: { ...d.frameworks, ...(r.frameworks && typeof r.frameworks === 'object' ? r.frameworks : {}) },
221
+ costGuards: {
222
+ ...d.costGuards,
223
+ ...(r.costGuards && typeof r.costGuards === 'object' ? r.costGuards : {}),
224
+ respectFreeWindows: {
225
+ ...(r.costGuards?.respectFreeWindows && typeof r.costGuards.respectFreeWindows === 'object'
226
+ ? r.costGuards.respectFreeWindows
227
+ : d.costGuards.respectFreeWindows),
228
+ },
229
+ },
230
+ };
231
+ }
232
+ //# sourceMappingURL=ModelTierEscalation.js.map