agentwrangler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * src/detector/detectors/d4_model_mismatch.ts — D4 MODEL_MISMATCH.
3
+ *
4
+ * Fires PER-WORKSPACE when a sustained fraction of Opus turns in the trailing
5
+ * week match the "high-context, low-output" pattern — turns where Opus is reading
6
+ * a large context but producing little output, a signal that Sonnet could serve
7
+ * those turns at substantially lower cost.
8
+ *
9
+ * Conservative thresholds (all UNVALIDATED defaults, labeled in every rec):
10
+ * min_opus_turns: 5 — cold-start guard; avoids firing on sparse data
11
+ * mismatch_fraction: 40% — sustained pattern, not isolated Opus use
12
+ * min_context_tokens: 50,000 — well above trivial lookups; below Opus-4-8 avg 155K
13
+ * max_output_tokens: 500 — short answer from large context ≈ lookup style
14
+ *
15
+ * False-positive guard: only turns where BOTH conditions hold (large context AND
16
+ * small output) are flagged. Opus turns with large output, or turns below the
17
+ * context threshold, are never counted as mismatch — preserving legitimate
18
+ * architecture/reasoning/security use of Opus.
19
+ *
20
+ * Savings model: per-turn cost differential (Opus − Sonnet) on mismatch turns,
21
+ * scaled by mismatch_turns_per_week × reduction_fraction (0.50 UNVALIDATED).
22
+ * Prices are derived from pricing_snapshots, not hard-coded. Falls back to
23
+ * LIST_PRICES when no snapshot is present for a tier.
24
+ *
25
+ * scopeKey: "D4|<workspace_id>" — one rec per workspace per window.
26
+ */
27
+ import { LIST_PRICES } from "../../ingest/pricing.js";
28
+ import { D4_MISMATCH_MAX_OUTPUT_TOKENS, D4_MISMATCH_MIN_CONTEXT_TOKENS, D4_MISMATCH_MIN_FRACTION, D4_OPUS_MIN_TURNS, D4_REDUCTION_FRACTION, d4Savings, } from "../savings.js";
29
+ /**
30
+ * Fallback prices from LIST_PRICES when pricing_snapshots is missing a tier.
31
+ * Sonnet uses the sonnet-4 tier as the conservative routing target — its higher
32
+ * base rate ($3/$15 vs sonnet-5's $2/$10) yields the smaller Opus−Sonnet
33
+ * differential, so the modeled routing savings are never overstated (BM0).
34
+ */
35
+ const OPUS_INPUT_FALLBACK = LIST_PRICES.opus?.[0] ?? 5;
36
+ const OPUS_OUTPUT_FALLBACK = LIST_PRICES.opus?.[1] ?? 25;
37
+ const SONNET_INPUT_FALLBACK = LIST_PRICES["sonnet-4"]?.[0] ?? 3;
38
+ const SONNET_OUTPUT_FALLBACK = LIST_PRICES["sonnet-4"]?.[1] ?? 15;
39
+ /** Read a fresh, well-formed /usage snapshot once; fail open on any problem. */
40
+ function getPerModelSnapshot(db, now) {
41
+ try {
42
+ const row = db
43
+ .prepare("SELECT value FROM user_config WHERE key = 'per_model_snapshot'")
44
+ .get();
45
+ if (!row || typeof row.value !== "string")
46
+ return null;
47
+ const snapshot = JSON.parse(row.value);
48
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
49
+ return null;
50
+ const candidate = snapshot;
51
+ if (typeof candidate.captured_at !== "string" ||
52
+ typeof candidate.seven_day_util !== "number" ||
53
+ !Number.isFinite(candidate.seven_day_util) ||
54
+ typeof candidate.five_hour_util !== "number" ||
55
+ !Number.isFinite(candidate.five_hour_util) ||
56
+ !Array.isArray(candidate.per_model)) {
57
+ return null;
58
+ }
59
+ const capturedAtMs = Date.parse(candidate.captured_at);
60
+ if (!Number.isFinite(capturedAtMs) || now.getTime() - capturedAtMs > 24 * 60 * 60 * 1000) {
61
+ return null;
62
+ }
63
+ const perModel = [];
64
+ for (const entry of candidate.per_model) {
65
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
66
+ return null;
67
+ const model = entry;
68
+ if (typeof model.model !== "string" ||
69
+ typeof model.utilization !== "number" ||
70
+ !Number.isFinite(model.utilization)) {
71
+ return null;
72
+ }
73
+ perModel.push({ model: model.model, utilization: model.utilization });
74
+ }
75
+ return {
76
+ captured_at: candidate.captured_at,
77
+ seven_day_util: candidate.seven_day_util,
78
+ five_hour_util: candidate.five_hour_util,
79
+ per_model: perModel,
80
+ };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /** Parse a unit_prices_json array and return [input, output, cacheRead] prices. */
87
+ function parsePrices(json) {
88
+ if (!json)
89
+ return [0, 0];
90
+ try {
91
+ const arr = JSON.parse(json);
92
+ return [arr[0] ?? 0, arr[1] ?? 0];
93
+ }
94
+ catch {
95
+ return [0, 0];
96
+ }
97
+ }
98
+ /** Get the freshest pricing snapshot for a given model tier, or null. */
99
+ function getTierPrices(db, tier) {
100
+ const row = db
101
+ .prepare(`SELECT unit_prices_json FROM pricing_snapshots
102
+ WHERE model_tier = ?
103
+ ORDER BY stale_after DESC
104
+ LIMIT 1`)
105
+ .get(tier);
106
+ if (!row)
107
+ return null;
108
+ return parsePrices(row.unit_prices_json);
109
+ }
110
+ export const d4Detector = {
111
+ id: "D4",
112
+ name: "MODEL_MISMATCH",
113
+ evaluate(db, ctx) {
114
+ // Cap attribution is advisory metadata only; any read or parse failure fails open.
115
+ const perModelSnapshot = getPerModelSnapshot(db, ctx.now);
116
+ // All non-global workspaces with at least one turn in the window.
117
+ const workspaces = db
118
+ .prepare(`SELECT DISTINCT workspace_id
119
+ FROM turns
120
+ WHERE ts >= ? AND ts < ?
121
+ AND model LIKE '%opus%'
122
+ AND provisional = 0`)
123
+ .all(ctx.fromIso, ctx.toIso);
124
+ if (workspaces.length === 0) {
125
+ return {
126
+ fired: [],
127
+ status: "INACTIVE",
128
+ note: "no Opus turns in window",
129
+ };
130
+ }
131
+ // Resolve prices once (shared across all workspace evaluations).
132
+ const opusPrices = getTierPrices(db, "opus");
133
+ const sonnetPrices = getTierPrices(db, "sonnet-4");
134
+ const opusInputPrice = opusPrices?.[0] ?? OPUS_INPUT_FALLBACK;
135
+ const opusOutputPrice = opusPrices?.[1] ?? OPUS_OUTPUT_FALLBACK;
136
+ const sonnetInputPrice = sonnetPrices?.[0] ?? SONNET_INPUT_FALLBACK;
137
+ const sonnetOutputPrice = sonnetPrices?.[1] ?? SONNET_OUTPUT_FALLBACK;
138
+ const fired = [];
139
+ for (const { workspace_id } of workspaces) {
140
+ // Count ALL Opus turns for this workspace in the window (denominator for fraction).
141
+ const totalOpus = db
142
+ .prepare(`SELECT COUNT(*) AS n FROM turns
143
+ WHERE workspace_id = ?
144
+ AND model LIKE '%opus%'
145
+ AND ts >= ? AND ts < ?
146
+ AND provisional = 0`)
147
+ .get(workspace_id, ctx.fromIso, ctx.toIso).n;
148
+ // Cold-start guard: skip workspaces with too few Opus turns.
149
+ if (totalOpus < D4_OPUS_MIN_TURNS)
150
+ continue;
151
+ // Count mismatch turns (high context AND low output).
152
+ const mismatch = db
153
+ .prepare(`SELECT
154
+ COUNT(*) AS mismatch_count,
155
+ AVG(input_tokens) AS avg_input_tokens,
156
+ AVG(output_tokens) AS avg_output_tokens
157
+ FROM turns
158
+ WHERE workspace_id = ?
159
+ AND model LIKE '%opus%'
160
+ AND context_tokens >= ?
161
+ AND output_tokens <= ?
162
+ AND ts >= ? AND ts < ?
163
+ AND provisional = 0`)
164
+ .get(workspace_id, D4_MISMATCH_MIN_CONTEXT_TOKENS, D4_MISMATCH_MAX_OUTPUT_TOKENS, ctx.fromIso, ctx.toIso);
165
+ const mismatchCount = mismatch.mismatch_count ?? 0;
166
+ if (mismatchCount === 0)
167
+ continue;
168
+ const mismatchFraction = mismatchCount / totalOpus;
169
+ if (mismatchFraction < D4_MISMATCH_MIN_FRACTION)
170
+ continue;
171
+ const avgInputTokens = Math.round(mismatch.avg_input_tokens ?? 0);
172
+ const avgOutputTokens = Math.round(mismatch.avg_output_tokens ?? 0);
173
+ const { savingsU, formula } = d4Savings(avgInputTokens, avgOutputTokens, mismatchCount, opusInputPrice, opusOutputPrice, sonnetInputPrice, sonnetOutputPrice, D4_REDUCTION_FRACTION);
174
+ // Skip zero-savings recs (e.g., prices not loaded). The computed figure is
175
+ // retained as DIAGNOSTIC evidence only (see advisory gate below).
176
+ if (savingsU <= 0)
177
+ continue;
178
+ // This is evidence for an already-fired D4 recommendation, not another
179
+ // qualification rule. Keep its scope and time window aligned with D4.
180
+ const sidechainPremiumTurns = db
181
+ .prepare(`SELECT COUNT(*) AS n FROM turns
182
+ WHERE workspace_id = ?
183
+ AND model LIKE '%opus%'
184
+ AND is_sidechain = 1
185
+ AND ts >= ? AND ts < ?
186
+ AND provisional = 0`)
187
+ .get(workspace_id, ctx.fromIso, ctx.toIso).n;
188
+ const mismatchPct = Math.round(mismatchFraction * 100);
189
+ const sonnetEntries = perModelSnapshot?.per_model.filter((entry) => entry.model.toLowerCase().includes("sonnet"));
190
+ const bindingSonnet = sonnetEntries?.find((entry) => entry.utilization >= (perModelSnapshot?.seven_day_util ?? Number.POSITIVE_INFINITY));
191
+ const capEvidence = bindingSonnet && perModelSnapshot
192
+ ? {
193
+ withheld: true,
194
+ withheld_reason: `Sonnet weekly cap is the binding constraint (Sonnet util ${bindingSonnet.utilization} >= all-models ${perModelSnapshot.seven_day_util}) — routing Opus->Sonnet would worsen it`,
195
+ title: `[withheld] Route Opus→Sonnet: ${mismatchPct}% of turns are high-context low-output`,
196
+ }
197
+ : perModelSnapshot && sonnetEntries && sonnetEntries.length > 0
198
+ ? { cap_attribution: "all_models_or_opus_binds" }
199
+ : undefined;
200
+ fired.push({
201
+ scopeKey: `D4|${workspace_id}`,
202
+ category: "MODEL",
203
+ scope_workspace_id: workspace_id,
204
+ // Advisory gate (W0.3): which cap binds is NOT inferable from JSONL. Conditional lever.
205
+ lever: "If your all-models / Opus / 5h cap is the one binding — check /usage — these high-context low-output Opus turns are Sonnet-movable. This does NOT help, and can hurt, if your Sonnet-specific weekly cap is the binding constraint.",
206
+ target_metric: "model_mix_opus_fraction",
207
+ // Advisory gate: suppress the crisp $/wk headline until live /usage cap-attribution exists.
208
+ modeled_savings_u_per_wk: null,
209
+ // Retain the computation as a DIAGNOSTIC formula (kind ADVISORY), not a headline.
210
+ // Destructure out result_usd_per_wk so the advisory formula carries no crisp $/wk figure.
211
+ modeled_formula: (({ result_usd_per_wk: _, ...rest }) => ({ ...rest, kind: "ADVISORY" }))(formula),
212
+ evidence: {
213
+ title: `Route Opus→Sonnet: ${mismatchPct}% of turns are high-context low-output`,
214
+ workspace_id,
215
+ total_opus_turns_per_week: totalOpus,
216
+ mismatch_turns_per_week: mismatchCount,
217
+ sidechain_premium_turns: sidechainPremiumTurns,
218
+ mismatch_fraction: Number(mismatchFraction.toFixed(4)),
219
+ min_context_tokens_threshold: D4_MISMATCH_MIN_CONTEXT_TOKENS,
220
+ max_output_tokens_threshold: D4_MISMATCH_MAX_OUTPUT_TOKENS,
221
+ mismatch_fraction_threshold: D4_MISMATCH_MIN_FRACTION,
222
+ min_opus_turns_threshold: D4_OPUS_MIN_TURNS,
223
+ avg_input_tokens: avgInputTokens,
224
+ avg_output_tokens: avgOutputTokens,
225
+ opus_input_price_usd_per_mtok: opusInputPrice,
226
+ opus_output_price_usd_per_mtok: opusOutputPrice,
227
+ sonnet_input_price_usd_per_mtok: sonnetInputPrice,
228
+ sonnet_output_price_usd_per_mtok: sonnetOutputPrice,
229
+ reduction_fraction: D4_REDUCTION_FRACTION,
230
+ // Advisory framing: crisp savings suppressed; computed figure is diagnostic only.
231
+ advisory: true,
232
+ requires_usage_cap_data: true,
233
+ diagnostic_savings_u_per_wk_if_all_models_cap_binds: savingsU,
234
+ advisory_note: "Routing savings are only real if the all-models/Opus/5h cap is binding — check /usage. No crisp $/wk is emitted from transcripts alone.",
235
+ steps: [
236
+ "Check /usage to see which cap (all-models/Opus/5h vs Sonnet weekly) is actually binding",
237
+ "Only if the all-models/Opus cap binds: review the highest-Opus sessions in this workspace",
238
+ "Identify turns where the task is lookup, summarization, or formatting (not deep reasoning)",
239
+ "Route those turns to Sonnet; re-check this workspace's Opus fraction after 7 days",
240
+ ],
241
+ ...capEvidence,
242
+ },
243
+ });
244
+ }
245
+ if (fired.length === 0) {
246
+ return {
247
+ fired: [],
248
+ status: "INACTIVE",
249
+ note: `no workspace met the mismatch threshold (≥${D4_OPUS_MIN_TURNS} Opus turns, ≥${Math.round(D4_MISMATCH_MIN_FRACTION * 100)}% high-context-low-output)`,
250
+ };
251
+ }
252
+ return {
253
+ fired,
254
+ status: "ACTIVE",
255
+ note: `${fired.length} workspace(s) with sustained Opus mismatch pattern`,
256
+ };
257
+ },
258
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * src/detector/detectors/d5_limit_burn_forecast.ts — D5 LIMIT_BURN_FORECAST.
3
+ *
4
+ * Warning-class detector (no savings model). Uses the existing forecastFromDb
5
+ * (ADR-107 §D-5) under the injected clock. Burn figures are CAP-WEIGHTED
6
+ * (cache reads × COEFF, unverified — see src/query/cap-weighted.ts); the
7
+ * caveat travels in the fired evidence via `token_metric`. Fires (global scope,
8
+ * LIMIT category) when the forecast state is WARNING or EXCEEDED. state OFF
9
+ * (no :limit_tokens) → degraded burn-trend row when history exists, otherwise
10
+ * BLOCKED; OK / NO_BURN / COLD_START → INACTIVE.
11
+ */
12
+ import { capWeightExprSql } from "../../query/cap-weighted.js";
13
+ import { forecastFromDb } from "../../query/forecast.js";
14
+ import { d5Formula } from "../savings.js";
15
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
16
+ const RECENT_BURN_WINDOW_DAYS = 1;
17
+ const BASELINE_BURN_WINDOW_DAYS = 7;
18
+ const CALIBRATION_NUDGE = "Calibrate to get a real limit";
19
+ function aggregateCapWeightedBurn(db, fromMs, toMs, coeff) {
20
+ const row = db
21
+ .prepare(`SELECT COALESCE(SUM(${capWeightExprSql("turns", coeff)}), 0) AS tokens,
22
+ COUNT(*) AS turns
23
+ FROM turns
24
+ WHERE ts >= ? AND ts < ?`)
25
+ .get(new Date(fromMs).toISOString(), new Date(toMs).toISOString());
26
+ return { tokens: row.tokens, turns: row.turns };
27
+ }
28
+ /** Compare the latest calendar day with the user's own preceding seven-day baseline. */
29
+ function degradedBurnTrend(db, now, coeff) {
30
+ const recentToMs = now.getTime();
31
+ const recentFromMs = recentToMs - RECENT_BURN_WINDOW_DAYS * MS_PER_DAY;
32
+ const baselineFromMs = recentFromMs - BASELINE_BURN_WINDOW_DAYS * MS_PER_DAY;
33
+ const recent = aggregateCapWeightedBurn(db, recentFromMs, recentToMs, coeff);
34
+ const baseline = aggregateCapWeightedBurn(db, baselineFromMs, recentFromMs, coeff);
35
+ if (recent.turns === 0 && baseline.turns === 0)
36
+ return null;
37
+ const recentTokensPerDay = recent.tokens / RECENT_BURN_WINDOW_DAYS;
38
+ const baselineTokensPerDay = baseline.tokens / BASELINE_BURN_WINDOW_DAYS;
39
+ const deltaTokensPerDay = recentTokensPerDay - baselineTokensPerDay;
40
+ const direction = deltaTokensPerDay > 0 ? "RISING" : deltaTokensPerDay < 0 ? "FALLING" : "FLAT";
41
+ return {
42
+ direction,
43
+ recentTokens: recent.tokens,
44
+ recentTokensPerDay,
45
+ baselineTokens: baseline.tokens,
46
+ baselineTokensPerDay,
47
+ deltaTokensPerDay,
48
+ };
49
+ }
50
+ function directionLabel(direction) {
51
+ switch (direction) {
52
+ case "RISING":
53
+ return "rising";
54
+ case "FALLING":
55
+ return "falling";
56
+ case "FLAT":
57
+ return "flat";
58
+ }
59
+ }
60
+ export const d5Detector = {
61
+ id: "D5",
62
+ name: "LIMIT_BURN_FORECAST",
63
+ evaluate(db, ctx) {
64
+ const forecast = forecastFromDb(db, { now: ctx.now });
65
+ if (forecast.state === "OFF") {
66
+ const trend = degradedBurnTrend(db, ctx.now, forecast.cap_read_coeff);
67
+ if (trend === null) {
68
+ return { fired: [], status: "BLOCKED", note: "Weekly token limit is not configured" };
69
+ }
70
+ const fired = {
71
+ scopeKey: "D5|global|LIMIT_BURN_FORECAST",
72
+ category: "LIMIT",
73
+ scope_workspace_id: null,
74
+ lever: `${CALIBRATION_NUDGE}, then use this burn trend to guide workload.`,
75
+ target_metric: "burn_trend",
76
+ modeled_savings_u_per_wk: null,
77
+ modeled_formula: d5Formula(),
78
+ evidence: {
79
+ title: `Burn trend ${directionLabel(trend.direction)}: ${CALIBRATION_NUDGE}`,
80
+ state: "DEGRADED",
81
+ forecast_state: forecast.state,
82
+ tokens_used: forecast.tokens_used,
83
+ limit_tokens: null,
84
+ projected_exhaustion_jd: null,
85
+ token_metric: forecast.token_metric,
86
+ cap_weighted: forecast.cap_weighted,
87
+ burn_trend: trend.direction,
88
+ recent_cap_weighted_tokens: trend.recentTokens,
89
+ recent_cap_weighted_tokens_per_day: trend.recentTokensPerDay,
90
+ baseline_cap_weighted_tokens: trend.baselineTokens,
91
+ baseline_cap_weighted_tokens_per_day: trend.baselineTokensPerDay,
92
+ baseline_window_days: BASELINE_BURN_WINDOW_DAYS,
93
+ trend_delta_cap_weighted_tokens_per_day: trend.deltaTokensPerDay,
94
+ calibration_nudge: CALIBRATION_NUDGE,
95
+ },
96
+ };
97
+ return {
98
+ fired: [fired],
99
+ status: "ACTIVE",
100
+ note: `degraded burn trend ${directionLabel(trend.direction)}; calibrate to get a real limit`,
101
+ };
102
+ }
103
+ if (forecast.state === "WARNING" || forecast.state === "EXCEEDED") {
104
+ const stateLabel = forecast.state === "EXCEEDED" ? "EXCEEDED" : "warning";
105
+ const fired = {
106
+ scopeKey: "D5|global|LIMIT_BURN_FORECAST",
107
+ category: "LIMIT",
108
+ scope_workspace_id: null,
109
+ lever: "Scope work to the top-burn workspace/sessions before the weekly reset; reduce burn rate.",
110
+ target_metric: "forecast_margin",
111
+ modeled_savings_u_per_wk: null,
112
+ modeled_formula: d5Formula(),
113
+ evidence: {
114
+ title: `Rate-limit ${stateLabel}: reduce burn before weekly reset`,
115
+ state: forecast.state,
116
+ tokens_used: forecast.tokens_used,
117
+ limit_tokens: forecast.limit_tokens,
118
+ projected_exhaustion_jd: forecast.projected_exhaustion_jd,
119
+ token_metric: forecast.token_metric,
120
+ cap_weighted: true,
121
+ // Legacy limit-scale honesty flag (review P1): true when the stored
122
+ // limit was calibrated under the old full-weight meter, so the fired
123
+ // evidence carries the caveat instead of silently trusting the number.
124
+ limit_scale_legacy: forecast.limit_scale_legacy,
125
+ ...(forecast.limit_scale_note !== null
126
+ ? { limit_scale_note: forecast.limit_scale_note }
127
+ : {}),
128
+ },
129
+ };
130
+ return { fired: [fired], status: "ACTIVE", note: `burn forecast ${forecast.state}` };
131
+ }
132
+ return {
133
+ fired: [],
134
+ status: "INACTIVE",
135
+ note: `burn forecast ${forecast.state} (no exhaustion projected within the warn window)`,
136
+ };
137
+ },
138
+ };