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,138 @@
1
+ /**
2
+ * EscalationGovernor — §7 (subscription envelope + lease) and §8 (cost
3
+ * guards) of docs/specs/FABLE-MODEL-ESCALATION-SPEC.md.
4
+ *
5
+ * Admission control for model-tier escalation. Every refusal means "the
6
+ * session stays on its default model" — the governor can never block a
7
+ * message, tool call, or session (§3.5).
8
+ *
9
+ * Invariants implemented here:
10
+ * - **Lease, not bare headroom read** (round-1 Adversarial-H2): concurrent
11
+ * escalation onto one account goes through a reservation with
12
+ * `maxConcurrentEscalatedPerAccount`.
13
+ * - **Crash-safe lease** (round-2 Security-N3 / Adversarial-NEW-1): leases
14
+ * carry a TTL, are keyed on the session-instance id (the spawn-generated
15
+ * INSTAR_SESSION_ID — unforgeable from outside, monotonic per spawn), are
16
+ * released on session-end (SessionManager 'sessionReaped' — wired at
17
+ * boot), and a lease whose holder is no longer live is reclaimable.
18
+ * Expiry is evaluated lazily on read — no dedicated poller.
19
+ * - **Quota fail-closed** (round-1 Adversarial-H3): `requireQuotaHeadroom`
20
+ * reads the subscription pool's CACHED snapshot via an injected provider
21
+ * (never a live poll on the hot path); unavailable/errored ⇒ refuse.
22
+ * - **Once-per-episode counting** (round-3 Adversarial-NEW-5): an
23
+ * escalation counts against `maxEscalationsPerHour` exactly once per
24
+ * (instance-id, tier-transition) episode; canary retries and per-turn
25
+ * re-derivations never multiply the count. **Accounting fails toward
26
+ * counting** (round-2 Adversarial-NEW-2): `recordInjection` is called at
27
+ * injection time, regardless of read-back outcome.
28
+ * - **Free windows** (§8): UTC-date, inclusive through the named day;
29
+ * informational only — crossing the expiry emits ONE audit note (no
30
+ * silent cost cliff). The window relaxes nothing structural.
31
+ * - **Daily ultra-token cap as admission control** (§8): refuses NEW
32
+ * escalations once today's ultra spend (injected provider) crosses the
33
+ * cap. Mid-run visibility is UltraSessionCapMonitor's job.
34
+ *
35
+ * State is file-backed under `<stateDir>/state/model-tier-escalation/`
36
+ * (atomic tmp+rename writes; single-writer — the server process). The
37
+ * audit trail is an append-only JSONL with structured fields only — never
38
+ * raw operator text (round-2 Security-F7).
39
+ */
40
+ import { type TierEscalationConfig } from './ModelTierEscalation.js';
41
+ /** Conservative "has headroom" bound: a cached window utilization at/over
42
+ * this percentage is treated as capped for escalation purposes. */
43
+ export declare const QUOTA_HEADROOM_MAX_UTILIZATION_PCT = 90;
44
+ /** Shape-compatible with SubscriptionPool's AccountQuotaSnapshot — narrowed
45
+ * to what admission needs so the provider is easy to inject/test. */
46
+ export interface QuotaSnapshotLike {
47
+ fiveHour?: {
48
+ utilizationPct: number;
49
+ resetsAt?: string;
50
+ };
51
+ sevenDay?: {
52
+ utilizationPct: number;
53
+ resetsAt?: string;
54
+ };
55
+ measuredAt?: string;
56
+ }
57
+ export interface EscalationLease {
58
+ instanceId: string;
59
+ accountId: string;
60
+ acquiredAt: number;
61
+ ttlMs: number;
62
+ }
63
+ export type AdmitRefusalReason = 'disabled' | 'no-instance-id' | 'lease-capacity' | 'hourly-budget-exhausted' | 'quota-unavailable' | 'quota-capped' | 'daily-cap-exhausted';
64
+ export interface AdmitResult {
65
+ allow: boolean;
66
+ reason?: AdmitRefusalReason;
67
+ /** True when the model is inside a configured free window (informational). */
68
+ freeWindow?: boolean;
69
+ }
70
+ export interface EscalationGovernorDeps {
71
+ /** The agent state dir (`.instar`). */
72
+ stateDir: string;
73
+ /** Live config getter — re-read per call so operator flips apply. */
74
+ getConfig: () => TierEscalationConfig;
75
+ /**
76
+ * CACHED quota snapshot for the account (never a live poll — §7 /
77
+ * round-1 Scalability-M7). Return null when no snapshot is available;
78
+ * with `requireQuotaHeadroom:true` that REFUSES (fail closed).
79
+ */
80
+ quotaSnapshot?: (accountId: string) => QuotaSnapshotLike | null;
81
+ /**
82
+ * Today's (UTC) ultra-model token total, from the token ledger. Used for
83
+ * the §8 daily-cap admission check. Return null when unavailable — with a
84
+ * cap configured, unavailable ⇒ refuse (fail closed).
85
+ */
86
+ ultraTokensTodayUtc?: () => number | null;
87
+ /** Liveness probe for lease reclaim — false ⇒ the holder crashed/ended. */
88
+ isHolderLive?: (instanceId: string) => boolean;
89
+ /** Injectable clock for tests. */
90
+ now?: () => number;
91
+ }
92
+ export declare class EscalationGovernor {
93
+ private readonly deps;
94
+ private readonly dir;
95
+ private readonly statePath;
96
+ private readonly auditPath;
97
+ private readonly now;
98
+ constructor(deps: EscalationGovernorDeps);
99
+ /**
100
+ * §7/§8 admission decision for escalating `instanceId` on `accountId`.
101
+ * Pure cost/capacity routing — a refusal means "stay on default".
102
+ * Acquiring is idempotent: an instance already holding a lease is
103
+ * re-admitted without consuming additional capacity.
104
+ */
105
+ admitEscalation(input: {
106
+ instanceId: string;
107
+ accountId?: string;
108
+ modelId: string;
109
+ /** e.g. 'default→escalated' — the episode key half (§8 once-per-episode). */
110
+ transition: string;
111
+ /** dryRun evaluation: run every check but acquire NOTHING — a dry-run
112
+ * swap must never consume lease capacity (§9 dryRun semantics). */
113
+ dry?: boolean;
114
+ }): AdmitResult;
115
+ /**
116
+ * Count the escalation against the hourly budget — called AT INJECTION
117
+ * time (fails toward counting, round-2 Adversarial-NEW-2), idempotent per
118
+ * (instance, transition) episode (round-3 Adversarial-NEW-5).
119
+ * Returns true when this call newly counted the episode.
120
+ */
121
+ recordInjection(instanceId: string, transition: string): boolean;
122
+ /** Release the instance's lease (wired to SessionManager 'sessionReaped'
123
+ * at boot — the same close event that retires the session, §7). */
124
+ releaseLease(instanceId: string): void;
125
+ /** Current live leases (post-reclaim) — observability/testing surface. */
126
+ activeLeases(): EscalationLease[];
127
+ private episodeKey;
128
+ /** Drop expired leases and leases whose holder is no longer live. Lazy —
129
+ * runs on every read; no poller (§7). Returns true when anything changed. */
130
+ private reclaimStale;
131
+ private noteFreeWindowExpiryOnce;
132
+ private loadState;
133
+ private saveState;
134
+ /** Structured-fields-only audit appender (Sec-F7: no raw operator text). */
135
+ private audit;
136
+ private refuse;
137
+ }
138
+ //# sourceMappingURL=EscalationGovernor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EscalationGovernor.d.ts","sourceRoot":"","sources":["../../src/core/EscalationGovernor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAIH,OAAO,EAEL,KAAK,oBAAoB,EAC1B,MAAM,0BAA0B,CAAC;AAElC;oEACoE;AACpE,eAAO,MAAM,kCAAkC,KAAK,CAAC;AAErD;sEACsE;AACtE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzD,QAAQ,CAAC,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;AAUD,MAAM,MAAM,kBAAkB,GAC1B,UAAU,GACV,gBAAgB,GAChB,gBAAgB,GAChB,yBAAyB,GACzB,mBAAmB,GACnB,cAAc,GACd,qBAAqB,CAAC;AAE1B,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,8EAA8E;IAC9E,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,SAAS,EAAE,MAAM,oBAAoB,CAAC;IACtC;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,iBAAiB,GAAG,IAAI,CAAC;IAChE;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAC1C,2EAA2E;IAC3E,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;IAC/C,kCAAkC;IAClC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAID,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyB;IAC9C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAEvB,IAAI,EAAE,sBAAsB;IAQxC;;;;;OAKG;IACH,eAAe,CAAC,KAAK,EAAE;QACrB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,6EAA6E;QAC7E,UAAU,EAAE,MAAM,CAAC;QACnB;4EACoE;QACpE,GAAG,CAAC,EAAE,OAAO,CAAC;KACf,GAAG,WAAW;IAsHf;;;;;OAKG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO;IAYhE;wEACoE;IACpE,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAUtC,0EAA0E;IAC1E,YAAY,IAAI,eAAe,EAAE;IASjC,OAAO,CAAC,UAAU;IAIlB;kFAC8E;IAC9E,OAAO,CAAC,YAAY;IAyBpB,OAAO,CAAC,wBAAwB;IAmBhC,OAAO,CAAC,SAAS;IAgBjB,OAAO,CAAC,SAAS;IAajB,4EAA4E;IAC5E,OAAO,CAAC,KAAK;IAYb,OAAO,CAAC,MAAM;CAcf"}
@@ -0,0 +1,308 @@
1
+ /**
2
+ * EscalationGovernor — §7 (subscription envelope + lease) and §8 (cost
3
+ * guards) of docs/specs/FABLE-MODEL-ESCALATION-SPEC.md.
4
+ *
5
+ * Admission control for model-tier escalation. Every refusal means "the
6
+ * session stays on its default model" — the governor can never block a
7
+ * message, tool call, or session (§3.5).
8
+ *
9
+ * Invariants implemented here:
10
+ * - **Lease, not bare headroom read** (round-1 Adversarial-H2): concurrent
11
+ * escalation onto one account goes through a reservation with
12
+ * `maxConcurrentEscalatedPerAccount`.
13
+ * - **Crash-safe lease** (round-2 Security-N3 / Adversarial-NEW-1): leases
14
+ * carry a TTL, are keyed on the session-instance id (the spawn-generated
15
+ * INSTAR_SESSION_ID — unforgeable from outside, monotonic per spawn), are
16
+ * released on session-end (SessionManager 'sessionReaped' — wired at
17
+ * boot), and a lease whose holder is no longer live is reclaimable.
18
+ * Expiry is evaluated lazily on read — no dedicated poller.
19
+ * - **Quota fail-closed** (round-1 Adversarial-H3): `requireQuotaHeadroom`
20
+ * reads the subscription pool's CACHED snapshot via an injected provider
21
+ * (never a live poll on the hot path); unavailable/errored ⇒ refuse.
22
+ * - **Once-per-episode counting** (round-3 Adversarial-NEW-5): an
23
+ * escalation counts against `maxEscalationsPerHour` exactly once per
24
+ * (instance-id, tier-transition) episode; canary retries and per-turn
25
+ * re-derivations never multiply the count. **Accounting fails toward
26
+ * counting** (round-2 Adversarial-NEW-2): `recordInjection` is called at
27
+ * injection time, regardless of read-back outcome.
28
+ * - **Free windows** (§8): UTC-date, inclusive through the named day;
29
+ * informational only — crossing the expiry emits ONE audit note (no
30
+ * silent cost cliff). The window relaxes nothing structural.
31
+ * - **Daily ultra-token cap as admission control** (§8): refuses NEW
32
+ * escalations once today's ultra spend (injected provider) crosses the
33
+ * cap. Mid-run visibility is UltraSessionCapMonitor's job.
34
+ *
35
+ * State is file-backed under `<stateDir>/state/model-tier-escalation/`
36
+ * (atomic tmp+rename writes; single-writer — the server process). The
37
+ * audit trail is an append-only JSONL with structured fields only — never
38
+ * raw operator text (round-2 Security-F7).
39
+ */
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import { isWithinFreeWindow, } from './ModelTierEscalation.js';
43
+ /** Conservative "has headroom" bound: a cached window utilization at/over
44
+ * this percentage is treated as capped for escalation purposes. */
45
+ export const QUOTA_HEADROOM_MAX_UTILIZATION_PCT = 90;
46
+ const EPISODE_RETENTION_MS = 24 * 60 * 60 * 1000;
47
+ export class EscalationGovernor {
48
+ deps;
49
+ dir;
50
+ statePath;
51
+ auditPath;
52
+ now;
53
+ constructor(deps) {
54
+ this.deps = deps;
55
+ this.dir = path.join(deps.stateDir, 'state', 'model-tier-escalation');
56
+ this.statePath = path.join(this.dir, 'governor.json');
57
+ this.auditPath = path.join(this.dir, 'audit.jsonl');
58
+ this.now = deps.now ?? (() => Date.now());
59
+ }
60
+ /**
61
+ * §7/§8 admission decision for escalating `instanceId` on `accountId`.
62
+ * Pure cost/capacity routing — a refusal means "stay on default".
63
+ * Acquiring is idempotent: an instance already holding a lease is
64
+ * re-admitted without consuming additional capacity.
65
+ */
66
+ admitEscalation(input) {
67
+ const cfg = this.deps.getConfig();
68
+ if (!cfg.enabled) {
69
+ return this.refuse(input, 'disabled');
70
+ }
71
+ if (!input.instanceId) {
72
+ return this.refuse(input, 'no-instance-id');
73
+ }
74
+ const accountId = input.accountId ?? 'default';
75
+ const state = this.loadState();
76
+ this.reclaimStale(state);
77
+ const guards = cfg.costGuards;
78
+ // Free-window bookkeeping (informational; never gates) — §8 "no silent
79
+ // cost cliff": the first admission attempt after a window expires emits
80
+ // one audit note.
81
+ const freeWindow = isWithinFreeWindow(input.modelId, guards, this.now());
82
+ this.noteFreeWindowExpiryOnce(state, input.modelId, guards, freeWindow);
83
+ // 1) Lease capacity (per-account, idempotent for the same instance).
84
+ const holding = state.leases.find(l => l.instanceId === input.instanceId);
85
+ if (!holding) {
86
+ const accountLeases = state.leases.filter(l => l.accountId === accountId);
87
+ if (accountLeases.length >= guards.maxConcurrentEscalatedPerAccount) {
88
+ this.saveState(state); // persist any reclaim/expiry-note work
89
+ return this.refuse(input, 'lease-capacity');
90
+ }
91
+ }
92
+ // 2) Hourly escalation budget (§8 — load-bearing for Trigger #1).
93
+ // The episode for THIS (instance, transition) does not double-count.
94
+ const episodeKey = this.episodeKey(input.instanceId, input.transition);
95
+ const hourAgo = this.now() - 3_600_000;
96
+ const countedThisHour = Object.entries(state.episodes).filter(([key, at]) => at >= hourAgo && key !== episodeKey).length;
97
+ const alreadyCounted = Object.prototype.hasOwnProperty.call(state.episodes, episodeKey);
98
+ if (!alreadyCounted && countedThisHour >= guards.maxEscalationsPerHour) {
99
+ this.saveState(state);
100
+ return this.refuse(input, 'hourly-budget-exhausted');
101
+ }
102
+ // 3) Quota headroom from the CACHED snapshot — unavailable ⇒ fail closed.
103
+ if (guards.requireQuotaHeadroom) {
104
+ let snapshot = null;
105
+ try {
106
+ snapshot = this.deps.quotaSnapshot?.(accountId) ?? null;
107
+ }
108
+ catch {
109
+ // @silent-fallback-ok — fail-closed conversion, not a swallow: an
110
+ // erroring probe reads as "unavailable" and the very next branch
111
+ // refuses with the AUDITED structured reason 'quota-unavailable'.
112
+ snapshot = null;
113
+ }
114
+ if (!snapshot) {
115
+ this.saveState(state);
116
+ return this.refuse(input, 'quota-unavailable');
117
+ }
118
+ const capped = [snapshot.fiveHour, snapshot.sevenDay].some(w => w && w.utilizationPct >= QUOTA_HEADROOM_MAX_UTILIZATION_PCT);
119
+ if (capped) {
120
+ this.saveState(state);
121
+ return this.refuse(input, 'quota-capped');
122
+ }
123
+ }
124
+ // 4) Daily ultra-token cap as ADMISSION control (§8). Cap configured but
125
+ // spend unreadable ⇒ fail closed.
126
+ if (guards.dailyUltraTokenCap != null) {
127
+ let spent = null;
128
+ try {
129
+ spent = this.deps.ultraTokensTodayUtc?.() ?? null;
130
+ }
131
+ catch {
132
+ // @silent-fallback-ok — fail-closed conversion, not a swallow: an
133
+ // unreadable ledger reads as null and the next branch refuses with
134
+ // the AUDITED structured reason 'daily-cap-exhausted'.
135
+ spent = null;
136
+ }
137
+ if (spent == null || spent >= guards.dailyUltraTokenCap) {
138
+ this.saveState(state);
139
+ return this.refuse(input, 'daily-cap-exhausted');
140
+ }
141
+ }
142
+ if (input.dry) {
143
+ this.saveState(state); // persist reclaim/expiry-note work only
144
+ this.audit({
145
+ type: 'dry-admit',
146
+ instanceId: input.instanceId,
147
+ accountId,
148
+ modelId: input.modelId,
149
+ transition: input.transition,
150
+ freeWindow,
151
+ });
152
+ return { allow: true, freeWindow };
153
+ }
154
+ // Admit: acquire/refresh the lease (TTL = maxEscalationTtlMs).
155
+ const lease = {
156
+ instanceId: input.instanceId,
157
+ accountId,
158
+ acquiredAt: this.now(),
159
+ ttlMs: guards.maxEscalationTtlMs,
160
+ };
161
+ state.leases = state.leases.filter(l => l.instanceId !== input.instanceId);
162
+ state.leases.push(lease);
163
+ this.saveState(state);
164
+ this.audit({
165
+ type: 'admit',
166
+ instanceId: input.instanceId,
167
+ accountId,
168
+ modelId: input.modelId,
169
+ transition: input.transition,
170
+ freeWindow,
171
+ });
172
+ return { allow: true, freeWindow };
173
+ }
174
+ /**
175
+ * Count the escalation against the hourly budget — called AT INJECTION
176
+ * time (fails toward counting, round-2 Adversarial-NEW-2), idempotent per
177
+ * (instance, transition) episode (round-3 Adversarial-NEW-5).
178
+ * Returns true when this call newly counted the episode.
179
+ */
180
+ recordInjection(instanceId, transition) {
181
+ const state = this.loadState();
182
+ const key = this.episodeKey(instanceId, transition);
183
+ if (Object.prototype.hasOwnProperty.call(state.episodes, key)) {
184
+ return false; // already counted this episode — retries don't multiply
185
+ }
186
+ state.episodes[key] = this.now();
187
+ this.saveState(state);
188
+ this.audit({ type: 'injection-counted', instanceId, transition });
189
+ return true;
190
+ }
191
+ /** Release the instance's lease (wired to SessionManager 'sessionReaped'
192
+ * at boot — the same close event that retires the session, §7). */
193
+ releaseLease(instanceId) {
194
+ const state = this.loadState();
195
+ const before = state.leases.length;
196
+ state.leases = state.leases.filter(l => l.instanceId !== instanceId);
197
+ if (state.leases.length !== before) {
198
+ this.saveState(state);
199
+ this.audit({ type: 'lease-released', instanceId });
200
+ }
201
+ }
202
+ /** Current live leases (post-reclaim) — observability/testing surface. */
203
+ activeLeases() {
204
+ const state = this.loadState();
205
+ const changed = this.reclaimStale(state);
206
+ if (changed)
207
+ this.saveState(state);
208
+ return [...state.leases];
209
+ }
210
+ // ── internals ──────────────────────────────────────────────────────────
211
+ episodeKey(instanceId, transition) {
212
+ return `${instanceId}::${transition}`;
213
+ }
214
+ /** Drop expired leases and leases whose holder is no longer live. Lazy —
215
+ * runs on every read; no poller (§7). Returns true when anything changed. */
216
+ reclaimStale(state) {
217
+ const now = this.now();
218
+ const before = state.leases.length;
219
+ state.leases = state.leases.filter(l => {
220
+ if (now - l.acquiredAt >= l.ttlMs) {
221
+ this.audit({ type: 'lease-reclaimed', instanceId: l.instanceId, why: 'ttl-expired' });
222
+ return false;
223
+ }
224
+ try {
225
+ if (this.deps.isHolderLive && !this.deps.isHolderLive(l.instanceId)) {
226
+ this.audit({ type: 'lease-reclaimed', instanceId: l.instanceId, why: 'holder-not-live' });
227
+ return false;
228
+ }
229
+ }
230
+ catch {
231
+ // liveness probe errored — keep the lease (TTL still bounds it)
232
+ }
233
+ return true;
234
+ });
235
+ // Prune stale episodes while we're here (same lazy pass).
236
+ for (const [key, at] of Object.entries(state.episodes)) {
237
+ if (now - at > EPISODE_RETENTION_MS)
238
+ delete state.episodes[key];
239
+ }
240
+ return state.leases.length !== before;
241
+ }
242
+ noteFreeWindowExpiryOnce(state, modelId, guards, currentlyInWindow) {
243
+ const windows = guards.respectFreeWindows ?? {};
244
+ if (!Object.prototype.hasOwnProperty.call(windows, modelId))
245
+ return;
246
+ if (currentlyInWindow)
247
+ return;
248
+ if (state.freeWindowExpiryNoted[modelId] === windows[modelId])
249
+ return;
250
+ state.freeWindowExpiryNoted[modelId] = windows[modelId];
251
+ this.audit({
252
+ type: 'free-window-expired',
253
+ modelId,
254
+ window: windows[modelId],
255
+ note: 'free window crossed — quota/budget guards apply unchanged',
256
+ });
257
+ }
258
+ loadState() {
259
+ try {
260
+ const raw = JSON.parse(fs.readFileSync(this.statePath, 'utf-8'));
261
+ return {
262
+ leases: Array.isArray(raw.leases) ? raw.leases.filter(l => l && typeof l.instanceId === 'string') : [],
263
+ episodes: raw.episodes && typeof raw.episodes === 'object' ? raw.episodes : {},
264
+ freeWindowExpiryNoted: raw.freeWindowExpiryNoted && typeof raw.freeWindowExpiryNoted === 'object'
265
+ ? raw.freeWindowExpiryNoted
266
+ : {},
267
+ };
268
+ }
269
+ catch {
270
+ return { leases: [], episodes: {}, freeWindowExpiryNoted: {} };
271
+ }
272
+ }
273
+ saveState(state) {
274
+ try {
275
+ fs.mkdirSync(this.dir, { recursive: true });
276
+ const tmp = `${this.statePath}.tmp`;
277
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
278
+ fs.renameSync(tmp, this.statePath);
279
+ }
280
+ catch (err) {
281
+ // State-write failure must never block the caller — but it must be
282
+ // loud: a governor that can't persist is a governor that can't bound.
283
+ console.warn(`[escalation-governor] state write failed: ${err.message}`);
284
+ }
285
+ }
286
+ /** Structured-fields-only audit appender (Sec-F7: no raw operator text). */
287
+ audit(event) {
288
+ try {
289
+ fs.mkdirSync(this.dir, { recursive: true });
290
+ fs.appendFileSync(this.auditPath, JSON.stringify({ ts: new Date(this.now()).toISOString(), ...event }) + '\n');
291
+ }
292
+ catch {
293
+ // best-effort — never throws into the routing path
294
+ }
295
+ }
296
+ refuse(input, reason) {
297
+ this.audit({
298
+ type: 'refuse',
299
+ reason,
300
+ instanceId: input.instanceId,
301
+ accountId: input.accountId ?? 'default',
302
+ modelId: input.modelId,
303
+ transition: input.transition,
304
+ });
305
+ return { allow: false, reason };
306
+ }
307
+ }
308
+ //# sourceMappingURL=EscalationGovernor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EscalationGovernor.js","sourceRoot":"","sources":["../../src/core/EscalationGovernor.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,116 @@
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 type { Session } from './types.js';
35
+ import { type EscalationTier, type TierEscalationConfig } from './ModelTierEscalation.js';
36
+ import type { EscalationGovernor } from './EscalationGovernor.js';
37
+ /** The narrow SessionManager surface the swap service is allowed to touch. */
38
+ export interface SwapSessionFacade {
39
+ listRunningSessions(): Session[];
40
+ captureMeaningfulTail(tmuxSession: string, lines: number): string | null;
41
+ sendInput(tmuxSession: string, input: string): boolean;
42
+ }
43
+ export interface AttentionItemLike {
44
+ id: string;
45
+ title: string;
46
+ summary: string;
47
+ category: string;
48
+ priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
49
+ }
50
+ export interface ModelSwapServiceDeps {
51
+ stateDir: string;
52
+ sessions: SwapSessionFacade;
53
+ /** Persist a mutated Session record (StateManager.saveSession). */
54
+ saveSession: (session: Session) => void;
55
+ protectedSessions: () => string[];
56
+ getConfig: () => TierEscalationConfig;
57
+ governor: Pick<EscalationGovernor, 'admitEscalation' | 'recordInjection'>;
58
+ /** Raise an Attention item (TelegramAdapter.createAttentionItem). Optional —
59
+ * absence degrades to audit-only, never throws. */
60
+ attention?: (item: AttentionItemLike) => unknown;
61
+ /** Canary attempts/cadence — injectable for tests. */
62
+ canaryAttempts?: number;
63
+ canaryIntervalMs?: number;
64
+ wait?: (ms: number) => Promise<void>;
65
+ now?: () => number;
66
+ }
67
+ export type SwapStatus = 'swapped' | 'unconfirmed' | 'dry-run' | 'noop' | 'refused';
68
+ export interface SwapResult {
69
+ status: SwapStatus;
70
+ reason?: string;
71
+ /** The server-derived target model id (when one resolved). */
72
+ model?: string;
73
+ /** True only when the independent oracle confirmed the live model. */
74
+ confirmed?: boolean;
75
+ }
76
+ /**
77
+ * Does the pane tail show a prompt-ready idle state WITH an empty input
78
+ * line? Fail-closed: anything ambiguous returns false and the swap is
79
+ * refused as retryable (round-2 Security-F6).
80
+ */
81
+ export declare function paneIdleWithEmptyInput(tail: string | null): boolean;
82
+ /**
83
+ * Independent-oracle parse (§5.3 canary): does the pane acknowledge that
84
+ * the model is now `modelId`, on a line that is NOT the echo of our own
85
+ * injected `/model …` input? Conservative by design: an unrecognized format
86
+ * reads as NOT confirmed (the spec's honest-degrade direction).
87
+ *
88
+ * The REAL CLI acks with the model's DISPLAY NAME, not the id (live-canary
89
+ * finding, 2026-06-09): `/model claude-fable-5` → "Set model to Fable 5 and
90
+ * saved as your default for new sessions". We accept the exact id OR the
91
+ * display form derived from the id (closed-enum ids only ever reach here,
92
+ * so the derivation is over a known, validated vocabulary): family token
93
+ * capitalized + up to two leading version components dot-joined —
94
+ * claude-fable-5 → "Fable 5", claude-opus-4-8 → "Opus 4.8".
95
+ */
96
+ export declare function paneConfirmsModel(tail: string | null, modelId: string): boolean;
97
+ export declare class ModelSwapService {
98
+ private readonly deps;
99
+ private readonly auditPath;
100
+ private readonly now;
101
+ private readonly wait;
102
+ /** In-memory per-session last-swap timestamps (dwell backstop). Reset on
103
+ * restart — the safe direction (a restart may swap immediately). */
104
+ private readonly lastSwapAt;
105
+ constructor(deps: ModelSwapServiceDeps);
106
+ /**
107
+ * Swap `name`'s session to `tier`. Body-validated upstream; this method
108
+ * re-validates everything it relies on (defense in depth).
109
+ */
110
+ swap(name: string, tier: EscalationTier): Promise<SwapResult>;
111
+ private refuse;
112
+ /** Structured-fields-only audit appender (Sec-F7) — same trail as the
113
+ * governor so one file tells the whole escalation story. */
114
+ private audit;
115
+ }
116
+ //# sourceMappingURL=ModelSwapService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModelSwapService.d.ts","sourceRoot":"","sources":["../../src/core/ModelSwapService.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,oBAAoB,EAC1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAe,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE/E,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC,mBAAmB,IAAI,OAAO,EAAE,CAAC;IACjC,qBAAqB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACzE,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACxD;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;CAChD;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,mEAAmE;IACnE,WAAW,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,iBAAiB,EAAE,MAAM,MAAM,EAAE,CAAC;IAClC,SAAS,EAAE,MAAM,oBAAoB,CAAC;IACtC,QAAQ,EAAE,IAAI,CAAC,kBAAkB,EAAE,iBAAiB,GAAG,iBAAiB,CAAC,CAAC;IAC1E;wDACoD;IACpD,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAC;IACjD,sDAAsD;IACtD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,UAAU,GAClB,SAAS,GACT,aAAa,GACb,SAAS,GACT,MAAM,GACN,SAAS,CAAC;AAEd,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAiBnE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAe/E;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAuB;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAgC;IACrD;yEACqE;IACrE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;gBAE5C,IAAI,EAAE,oBAAoB;IAOtC;;;OAGG;IACG,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IA0JnE,OAAO,CAAC,MAAM;IAKd;iEAC6D;IAC7D,OAAO,CAAC,KAAK;CAWd"}