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,111 @@
1
+ /**
2
+ * src/query/api/recommendations-ledger.ts — W4 Impact Ledger read path.
3
+ *
4
+ * `listLedger()` joins non-PROPOSED recommendations to their
5
+ * `recommendation_effects` rows and returns the realized-vs-modeled ledger:
6
+ *
7
+ * - effects[].after_value is the OBSERVED signal (probe/history delta);
8
+ * modeled_savings_u_per_wk is the raw projection; the two are NEVER summed.
9
+ * - modeled_cap_weighted_u_per_wk = modeled × cap_read_coeff (server-side),
10
+ * the display figure for CONTEXT recs; raw modeled also returned.
11
+ * - confounded_window = true when another rec's adopted_at lies within
12
+ * ±86400s — the spend rollup cannot be isolated, so the UI banners it.
13
+ *
14
+ * Split from recommendations.ts per W4 design §10 Q3 to keep each module small.
15
+ * Claim kind: EXPERIMENTAL.
16
+ */
17
+ import { resolveCapReadCoeff } from "../cap-weighted.js";
18
+ import { getQueryDb } from "../db-context.js";
19
+ import { buildResponse } from "../envelope.js";
20
+ const CONFOUND_WINDOW_MS = 86_400_000; // ±1 day
21
+ function qualificationFor(row, detectorId, targetMetric) {
22
+ const isD2 = detectorId === "D2" || targetMetric.startsWith("CACHE_READ_TOKENS_PER_WK");
23
+ const isD8 = detectorId === "D8" || targetMetric.startsWith("cache_read_to_creation_ratio");
24
+ // D1's direct file-size snapshot is a point-in-time measurement; n=1 is expected
25
+ // because history only appends on distinct file versions. The sparse-data guard
26
+ // applies to turns/session-window measurements (D2, D8), where n is a sample count.
27
+ if ((isD2 || isD8) && row.before_n !== null && row.before_n < 3)
28
+ return "NOT_ENOUGH_DATA";
29
+ // Turns-based D2 floor signal is weak → EXPERIMENTAL label (design §2b honesty rail).
30
+ if (isD2) {
31
+ return "EXPERIMENTAL";
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * Read the realized-vs-modeled ledger for all adopted-or-later recs.
37
+ * When `scope` is given, only that workspace's rows and global (NULL-scope)
38
+ * rows are returned. Claim kind: EXPERIMENTAL.
39
+ */
40
+ export function listLedger(scope) {
41
+ const db = getQueryDb();
42
+ const coeff = resolveCapReadCoeff(db);
43
+ const recRows = (scope !== undefined
44
+ ? db
45
+ .prepare(`SELECT rec_id, detector_id, lever, adopted_at, state, target_metric,
46
+ modeled_savings_u_per_wk
47
+ FROM recommendations
48
+ WHERE (scope_workspace_id = ? OR scope_workspace_id IS NULL)
49
+ AND state IN ('ADOPTED','MEASURING','MEASURED_EFFECTIVE','MEASURED_NO_EFFECT')
50
+ AND adopted_at IS NOT NULL
51
+ ORDER BY adopted_at DESC, rec_id ASC`)
52
+ .all(scope)
53
+ : db
54
+ .prepare(`SELECT rec_id, detector_id, lever, adopted_at, state, target_metric,
55
+ modeled_savings_u_per_wk
56
+ FROM recommendations
57
+ WHERE state IN ('ADOPTED','MEASURING','MEASURED_EFFECTIVE','MEASURED_NO_EFFECT')
58
+ AND adopted_at IS NOT NULL
59
+ ORDER BY adopted_at DESC, rec_id ASC`)
60
+ .all());
61
+ const effectRows = db
62
+ .prepare(`SELECT rec_id, measured_at, before_from, before_to, after_from, after_to,
63
+ before_value, after_value, before_n, after_n, delta_pct, verdict
64
+ FROM recommendation_effects`)
65
+ .all();
66
+ const effectsByRec = new Map();
67
+ for (const e of effectRows) {
68
+ const list = effectsByRec.get(e.rec_id);
69
+ if (list !== undefined)
70
+ list.push(e);
71
+ else
72
+ effectsByRec.set(e.rec_id, [e]);
73
+ }
74
+ // Confounded-window flag: another ADOPTED+ rec within ±86400s of this adoption.
75
+ const adoptedTimes = recRows.map((r) => ({
76
+ recId: r.rec_id,
77
+ ms: r.adopted_at !== null ? Date.parse(r.adopted_at) : Number.NaN,
78
+ }));
79
+ const entries = recRows.map((r) => {
80
+ const self = adoptedTimes.find((a) => a.recId === r.rec_id);
81
+ const confounded = self !== undefined &&
82
+ !Number.isNaN(self.ms) &&
83
+ adoptedTimes.some((o) => o.recId !== r.rec_id &&
84
+ !Number.isNaN(o.ms) &&
85
+ Math.abs(o.ms - self.ms) <= CONFOUND_WINDOW_MS);
86
+ const effects = (effectsByRec.get(r.rec_id) ?? []).map((e) => ({
87
+ ...e,
88
+ qualification: qualificationFor(e, r.detector_id ?? "", r.target_metric),
89
+ }));
90
+ return {
91
+ rec_id: r.rec_id,
92
+ detector_id: r.detector_id ?? "unknown",
93
+ lever: r.lever,
94
+ adopted_at: r.adopted_at,
95
+ state: r.state,
96
+ target_metric: r.target_metric,
97
+ modeled_savings_u_per_wk: r.modeled_savings_u_per_wk,
98
+ // Cap-weighted modeled is a DISPLAY figure only — computed server-side,
99
+ // never summed with the realized after_value / delta_pct fields.
100
+ modeled_cap_weighted_u_per_wk: r.modeled_savings_u_per_wk === null ? null : r.modeled_savings_u_per_wk * coeff,
101
+ effects,
102
+ confounded_window: confounded,
103
+ };
104
+ });
105
+ const data = { entries, cap_read_coeff: coeff };
106
+ return buildResponse(data, {
107
+ claim_kind: "EXPERIMENTAL",
108
+ n: entries.length,
109
+ drilldown_ids: scope !== undefined ? { workspace_id: scope } : {},
110
+ });
111
+ }
@@ -0,0 +1,514 @@
1
+ /**
2
+ * src/query/api/recommendations.ts — LocalQueryAPI: DetectorEngine read seam.
3
+ *
4
+ * Reads the `recommendations` table (written by the post-ingest DetectorEngine),
5
+ * groups rows by lifecycle state, and attaches the live per-detector status
6
+ * strip (computed from the same detector registry — the non-firing statuses that
7
+ * cannot be persisted rows). Claim kind: EXPERIMENTAL (methodology under
8
+ * validation). No SQL leaves this layer; the UI issues none.
9
+ *
10
+ * FR-REC-103: modeled savings are NEVER summed into an "achieved" total here.
11
+ */
12
+ import { getDetectorStatuses } from "../../detector/index.js";
13
+ import { AFTER_WINDOW_DAYS, isWarningClass, snapshotBeforeValue, } from "../../detector/measurement.js";
14
+ import { getQueryDb } from "../db-context.js";
15
+ import { buildResponse } from "../envelope.js";
16
+ /** Modeled values below $1/week are useful as evidence, but too small for a top-level card. */
17
+ export const ACTIVE_RECOMMENDATION_DE_MINIMIS_FLOOR_U = 1_000_000;
18
+ export const MINOR_ITEMS_GROUP_ID = "MINOR_ITEMS";
19
+ const DETECTOR_GROUP_LABELS = {
20
+ D1: "CLAUDE.md / memory",
21
+ D2: "Session hygiene",
22
+ D4: "Model routing",
23
+ D5: "Limit warning",
24
+ D6: "Tool-result bloat",
25
+ D7: "Retry / redundant-read",
26
+ D8: "Cache misses",
27
+ D9: "Background sessions",
28
+ D10: "Tool catalog",
29
+ };
30
+ function groupLabel(detectorId) {
31
+ return DETECTOR_GROUP_LABELS[detectorId] ?? detectorId;
32
+ }
33
+ function recommendationSessionIds(rec) {
34
+ const ids = new Set();
35
+ const sessionId = rec.evidence.session_id;
36
+ if (typeof sessionId === "string" && sessionId.length > 0)
37
+ ids.add(sessionId);
38
+ const sessionIds = rec.evidence.session_ids;
39
+ if (Array.isArray(sessionIds)) {
40
+ for (const id of sessionIds) {
41
+ if (typeof id === "string" && id.length > 0)
42
+ ids.add(id);
43
+ }
44
+ }
45
+ return [...ids];
46
+ }
47
+ function groupSessionCount(recs) {
48
+ const sessionIds = new Set();
49
+ for (const rec of recs) {
50
+ for (const sessionId of recommendationSessionIds(rec))
51
+ sessionIds.add(sessionId);
52
+ }
53
+ return sessionIds.size;
54
+ }
55
+ function groupSavings(recs) {
56
+ return recs.reduce((total, rec) => total + (rec.modeled_savings_u_per_wk ?? 0), 0);
57
+ }
58
+ /**
59
+ * Derive the display groups from the complete active list without mutating it.
60
+ *
61
+ * Groups are created in first-seen order, which preserves the API's detector-family
62
+ * ordering. Modeled recommendations below the de-minimis floor are collected into one
63
+ * final expandable group; directional/null-savings recommendations remain visible in
64
+ * their detector group because they are not proven to be minor.
65
+ */
66
+ export function deriveActiveGroups(active) {
67
+ const grouped = new Map();
68
+ const minorItems = [];
69
+ for (const rec of active) {
70
+ if (rec.modeled_savings_u_per_wk !== null &&
71
+ rec.modeled_savings_u_per_wk < ACTIVE_RECOMMENDATION_DE_MINIMIS_FLOOR_U) {
72
+ minorItems.push(rec);
73
+ continue;
74
+ }
75
+ const members = grouped.get(rec.detector_id);
76
+ if (members === undefined)
77
+ grouped.set(rec.detector_id, [rec]);
78
+ else
79
+ members.push(rec);
80
+ }
81
+ const groups = [...grouped].map(([detector_id, recs]) => ({
82
+ detector_id,
83
+ label: groupLabel(detector_id),
84
+ recs,
85
+ session_count: groupSessionCount(recs),
86
+ total_savings_u_per_wk: groupSavings(recs),
87
+ }));
88
+ if (minorItems.length > 0) {
89
+ groups.push({
90
+ detector_id: MINOR_ITEMS_GROUP_ID,
91
+ label: "Minor items",
92
+ recs: minorItems,
93
+ session_count: groupSessionCount(minorItems),
94
+ total_savings_u_per_wk: groupSavings(minorItems),
95
+ });
96
+ }
97
+ return groups;
98
+ }
99
+ function parseFormula(raw) {
100
+ try {
101
+ const v = JSON.parse(raw);
102
+ if (v && typeof v === "object")
103
+ return v;
104
+ }
105
+ catch {
106
+ // fall through
107
+ }
108
+ return { model: "unknown", inputs: {} };
109
+ }
110
+ /**
111
+ * Coerce one raw step item (from evidence.steps) into a BoundedStep.
112
+ * Per spec:
113
+ * - matching known shape: keep kind, clamp fields
114
+ * - unknown/missing kind: generic with String(item).slice(0,500)
115
+ * - if a description was truncated: append " [truncated]"
116
+ */
117
+ function coerceStep(item) {
118
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
119
+ const obj = item;
120
+ const { kind } = obj;
121
+ if (kind === "trim") {
122
+ const tgt = obj.target;
123
+ if (tgt === "CLAUDE_MD" || tgt === "MEMORY_MD") {
124
+ const step = { kind: "trim", target: tgt };
125
+ if (typeof obj.max_lines === "number")
126
+ step.max_lines = obj.max_lines;
127
+ return step;
128
+ }
129
+ // Invalid target: coerce to generic, preserving available info (never "[object Object]").
130
+ const itemStr = JSON.stringify(item);
131
+ const truncated = itemStr.length > 500;
132
+ return {
133
+ kind: "generic",
134
+ description: truncated ? `${itemStr.slice(0, 500)} [truncated]` : itemStr,
135
+ };
136
+ }
137
+ if (kind === "disable_plugin") {
138
+ const plugin_id = typeof obj.plugin_id === "string" ? obj.plugin_id.slice(0, 64) : "unknown";
139
+ return { kind: "disable_plugin", plugin_id };
140
+ }
141
+ if (kind === "route_model") {
142
+ const from = typeof obj.from === "string" ? obj.from.slice(0, 64) : "unknown";
143
+ const to = typeof obj.to === "string" ? obj.to.slice(0, 64) : "unknown";
144
+ return { kind: "route_model", from, to };
145
+ }
146
+ if (kind === "session_boundary") {
147
+ return { kind: "session_boundary" };
148
+ }
149
+ if (kind === "generic") {
150
+ const raw = typeof obj.description === "string" ? obj.description : String(item);
151
+ const truncated = raw.length > 500;
152
+ return { kind: "generic", description: truncated ? `${raw.slice(0, 500)} [truncated]` : raw };
153
+ }
154
+ }
155
+ // Unknown kind or non-object → generic
156
+ const raw = String(item);
157
+ const truncated = raw.length > 500;
158
+ return { kind: "generic", description: truncated ? `${raw.slice(0, 500)} [truncated]` : raw };
159
+ }
160
+ function parseEvidence(raw) {
161
+ try {
162
+ const v = JSON.parse(raw);
163
+ if (v && typeof v === "object" && !Array.isArray(v))
164
+ return v;
165
+ }
166
+ catch {
167
+ // fall through
168
+ }
169
+ return {};
170
+ }
171
+ /**
172
+ * Derive rate-limit headroom for a CONTEXT rec.
173
+ * Formula: delta_context_tokens × turns_per_week. For a global rec, turns_per_week is
174
+ * ALREADY the cross-workspace aggregate (the full per-turn re-read footprint across every
175
+ * workspace), so it is NOT multiplied again by workspace_multiplier — that value is
176
+ * display-only (the "×N workspaces" badge). Multiplying here would double-count global
177
+ * headroom by N and disagree with the detector's own µUSD figure.
178
+ * Null when evidence lacks the required fields or for non-CONTEXT categories.
179
+ */
180
+ function deriveHeadroom(category, evidence, sessionsPerWeek) {
181
+ if (category !== "CONTEXT")
182
+ return null;
183
+ const delta = typeof evidence.delta_context_tokens === "number" ? evidence.delta_context_tokens : null;
184
+ const tpw = typeof evidence.turns_per_week === "number" ? evidence.turns_per_week : null;
185
+ if (delta === null || tpw === null)
186
+ return null;
187
+ const tokens_per_wk_freed = delta * tpw;
188
+ const tokens_per_session_freed = sessionsPerWeek !== null && sessionsPerWeek > 0 ? tokens_per_wk_freed / sessionsPerWeek : null;
189
+ return { tokens_per_wk_freed, tokens_per_session_freed };
190
+ }
191
+ function toCard(r, sessionsPerWeek) {
192
+ const evidence = parseEvidence(r.evidence_json);
193
+ const stepsRaw = evidence.steps;
194
+ const steps = Array.isArray(stepsRaw) && stepsRaw.length > 0
195
+ ? stepsRaw.map(coerceStep)
196
+ : [
197
+ {
198
+ kind: "generic",
199
+ description: r.lever.length > 500 ? `${r.lever.slice(0, 500)} [truncated]` : r.lever,
200
+ },
201
+ ];
202
+ const workspace_multiplier = typeof evidence.workspace_multiplier === "number" ? evidence.workspace_multiplier : null;
203
+ const title = typeof evidence.title === "string" ? evidence.title : r.lever;
204
+ return {
205
+ rec_id: r.rec_id,
206
+ detector_id: r.detector_id ?? "",
207
+ category: r.category,
208
+ scope_workspace_id: r.scope_workspace_id,
209
+ lever: r.lever,
210
+ title,
211
+ modeled_savings_u_per_wk: r.modeled_savings_u_per_wk,
212
+ run_cost_u: r.run_cost_u,
213
+ modeled_formula: parseFormula(r.modeled_formula_json),
214
+ evidence,
215
+ target_metric: r.target_metric,
216
+ state: r.state,
217
+ created_at: r.created_at,
218
+ dismissed_until: r.dismissed_until,
219
+ headroom: deriveHeadroom(r.category, evidence, sessionsPerWeek),
220
+ sessions_per_week: sessionsPerWeek,
221
+ steps,
222
+ cross_workspace: r.scope_workspace_id === null,
223
+ workspace_multiplier,
224
+ file_ref: typeof evidence.file_ref === "string" ? evidence.file_ref : null,
225
+ };
226
+ }
227
+ /**
228
+ * Article priority rank from taxonomy §5 (blog-dashboard-taxonomy-IA.md).
229
+ * Lower number = higher priority in the active list.
230
+ * Taxonomy R3: replace alphabetical category sort with article priority order.
231
+ * D5 (LIMIT_BURN_FORECAST) is a warning-class detector — floats last via
232
+ * the category='LIMIT' guard in articlePriority().
233
+ */
234
+ const DETECTOR_PRIORITY = {
235
+ D8: 1, // Cache misses: idle-resume of big contexts (A1 §07 rank 1, flagship)
236
+ D2: 2, // Oversized context and marathon sessions
237
+ D6: 3, // Tool-result bloat
238
+ D7: 4, // Retry loops and redundant reads
239
+ D4: 5, // Model over-use (advisory-gated)
240
+ D9: 6, // Background and idle sessions
241
+ D10: 7, // Too many connected tools, plugins, and skills (A1 §07 rank 7)
242
+ D1: 8, // Bloated CLAUDE.md / memory — secondary lever (taxonomy R9: smallest lever)
243
+ };
244
+ /** Priority rank for a card. LIMIT-category always sorts last (R3: "LIMIT warnings last"). */
245
+ function articlePriority(card) {
246
+ if (card.category === "LIMIT")
247
+ return 9999;
248
+ return DETECTOR_PRIORITY[card.detector_id] ?? 99;
249
+ }
250
+ function confidenceRank(rec) {
251
+ const kind = rec.modeled_formula.kind;
252
+ if (kind === "WARNING")
253
+ return 0; // WARNING
254
+ if (kind === "ADVISORY" || rec.detector_id === "D4")
255
+ return 1; // ADVISORY
256
+ if (kind === "DIRECTIONAL")
257
+ return 3; // explicit DIRECTIONAL (even with savings)
258
+ if (rec.modeled_savings_u_per_wk !== null)
259
+ return 2; // MODELED SAVINGS
260
+ return 3; // DIRECTIONAL default
261
+ }
262
+ /**
263
+ * Sort active recs by article priority (taxonomy §5 + R3):
264
+ *
265
+ * 1. articlePriority() ASC — D8 first (cache misses, #1 waste lever), D1 last
266
+ * among detected waste sources (secondary lever, #8 in article ranking).
267
+ * 2. confidenceRank() ASC — WARNING, ADVISORY, MODELED SAVINGS, DIRECTIONAL.
268
+ * Detector priority normally gives each detector a unique rank, so confidence only
269
+ * re-orders cards sharing an articlePriority bucket: the same detector, the
270
+ * category='LIMIT' 9999 bucket, or the unknown-detector 99 bucket.
271
+ * 3. modeled_savings_u_per_wk DESC within the same priority and confidence group.
272
+ * Savings-approximated tiebreak (API prices; subscription-cap
273
+ * coefficients for writes are not yet validated). Both D1 and D8
274
+ * savings are computed via the same pricing_snapshots table:
275
+ * D1: delta_ctx × turns × cache_read_price (~0.1× weight)
276
+ * D8: churn_write × write_price × avoidance (1.25–2× weight per tier)
277
+ * Using savings as the tiebreak within a priority group is directionally
278
+ * correct because both use the same pricing table.
279
+ * unverified coeff: write weights 1.25×/2× are API prices; Anthropic has
280
+ * not published subscription-cap coefficients for writes.
281
+ * 4. rec_id ASC for determinism.
282
+ *
283
+ * headroom.tokens_per_wk_freed is a DISPLAY field only (raw cache-read tokens,
284
+ * ~0.1× weight) — it is NOT used as a sort key because it cannot be compared
285
+ * reliably across detector types that produce different token flavors.
286
+ */
287
+ function byArticlePriority(a, b) {
288
+ const pa = articlePriority(a);
289
+ const pb = articlePriority(b);
290
+ if (pa !== pb)
291
+ return pa - pb;
292
+ const ca = confidenceRank(a);
293
+ const cb = confidenceRank(b);
294
+ if (ca !== cb)
295
+ return ca - cb;
296
+ // Within the same priority group: cap-weighted savings DESC (null = -1).
297
+ const sa = a.modeled_savings_u_per_wk ?? -1;
298
+ const sb = b.modeled_savings_u_per_wk ?? -1;
299
+ if (sb !== sa)
300
+ return sb - sa;
301
+ return a.rec_id < b.rec_id ? -1 : a.rec_id > b.rec_id ? 1 : 0;
302
+ }
303
+ const ADOPTED_STATES = new Set([
304
+ "ADOPTED",
305
+ "MEASURING",
306
+ "MEASURED_EFFECTIVE",
307
+ "MEASURED_NO_EFFECT",
308
+ ]);
309
+ const DISMISS_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
310
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
311
+ /**
312
+ * Dismiss a PROPOSED recommendation.
313
+ * By default uses a 30-day cool-down. Pass `dismissedUntilOverride` (ISO string)
314
+ * to set a custom wake date (e.g. for an explicit snooze duration from the UI).
315
+ * When the `dismissed_until` date passes, `listRecommendations` auto-restores the
316
+ * card to the active list without a DB state change.
317
+ * nowMs is injectable for deterministic tests; defaults to Date.now().
318
+ */
319
+ export function dismissRecommendation(rec_id, nowMs = Date.now(), dismissedUntilOverride) {
320
+ const db = getQueryDb();
321
+ const dismissedUntil = dismissedUntilOverride ?? new Date(nowMs + DISMISS_COOLDOWN_MS).toISOString();
322
+ const result = db
323
+ .prepare(`UPDATE recommendations SET state='DISMISSED', dismissed_until=?
324
+ WHERE rec_id=? AND state='PROPOSED'`)
325
+ .run(dismissedUntil, rec_id);
326
+ if (result.changes === 0) {
327
+ throw new Error(`rec ${rec_id} not found or not in PROPOSED state`);
328
+ }
329
+ return buildResponse({ ok: true }, { claim_kind: "EXPERIMENTAL", n: 1, drilldown_ids: {} });
330
+ }
331
+ /**
332
+ * Adopt a PROPOSED recommendation — marks state → ADOPTED and records adopted_at,
333
+ * and atomically snapshots the pre-adoption baseline into `recommendation_effects`
334
+ * (W4). The snapshot reads the REALIZED signal (context_inventory_history for D1;
335
+ * session floor context for D2; routing adherence for D4) — never a modeled figure.
336
+ * When no baseline signal exists, the effect row is still written with
337
+ * before_value = NULL and MEASURING will attempt the delta at close time.
338
+ * nowMs is injectable for deterministic tests; defaults to Date.now().
339
+ */
340
+ export function adoptRecommendation(rec_id, nowMs = Date.now()) {
341
+ const db = getQueryDb();
342
+ const adoptedAt = new Date(nowMs).toISOString();
343
+ const afterTo = new Date(nowMs + AFTER_WINDOW_DAYS * MS_PER_DAY).toISOString();
344
+ // Read the rec BEFORE the transaction so snapshotBeforeValue sees stable input.
345
+ const rec = db
346
+ .prepare(`SELECT rec_id, detector_id, category, scope_workspace_id, evidence_json, target_metric, state
347
+ FROM recommendations WHERE rec_id = ?`)
348
+ .get(rec_id);
349
+ const tx = db.transaction(() => {
350
+ const result = db
351
+ .prepare(`UPDATE recommendations SET state='ADOPTED', adopted_at=?
352
+ WHERE rec_id=? AND state='PROPOSED'`)
353
+ .run(adoptedAt, rec_id);
354
+ if (result.changes === 0) {
355
+ throw new Error(`rec ${rec_id} not found or not in PROPOSED state`);
356
+ }
357
+ // W4 baseline snapshot. Warning-class recs (D5/LIMIT) get NO effect row
358
+ // (design §2d); everything else gets one, even with a null before_value.
359
+ if (rec !== undefined && !isWarningClass(rec)) {
360
+ const beforeValue = snapshotBeforeValue(db, rec, new Date(nowMs));
361
+ const beforeFrom = beforeValue?.from_ts ?? adoptedAt;
362
+ db.prepare(`INSERT INTO recommendation_effects
363
+ (rec_id, measured_at, before_from, before_to, after_from, after_to,
364
+ before_value, before_n, verdict)
365
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`).run(rec_id, adoptedAt, // measured_at = adoption timestamp (adoption-cycle grain; verdict written on close)
366
+ beforeFrom, adoptedAt, // before_to closes at adoption
367
+ adoptedAt, // after_from opens at adoption
368
+ afterTo, // after_to = measuring deadline (adopted + AFTER_WINDOW_DAYS)
369
+ beforeValue?.value ?? null, beforeValue?.n ?? null);
370
+ }
371
+ });
372
+ tx();
373
+ return buildResponse({ ok: true }, { claim_kind: "EXPERIMENTAL", n: 1, drilldown_ids: {} });
374
+ }
375
+ /**
376
+ * Build a hardened seeded prompt for "Analyze with Claude" from a recommendation card.
377
+ * Re-exported from src/query/api/rec-prompt.ts which is browser-safe (pure function, no
378
+ * Node.js imports). Keeping this export here satisfies the single-source-of-truth contract:
379
+ * the function is accessible from recommendations.ts while the implementation lives in a
380
+ * browser-compatible module.
381
+ */
382
+ export { buildSeededPrompt } from "./rec-prompt.js";
383
+ export function getRecommendationCard(rec_id) {
384
+ const db = getQueryDb();
385
+ const row = db
386
+ .prepare(`SELECT r.rec_id, r.detector_id, r.category, r.scope_workspace_id, r.lever,
387
+ r.modeled_savings_u_per_wk, ar.cost_equiv_u AS run_cost_u,
388
+ r.modeled_formula_json, r.evidence_json, r.target_metric, r.state,
389
+ r.created_at, r.dismissed_until
390
+ FROM recommendations r
391
+ LEFT JOIN analysis_runs ar ON ar.run_id = r.analysis_run_id
392
+ WHERE r.rec_id = ?`)
393
+ .get(rec_id);
394
+ if (row === undefined)
395
+ return null;
396
+ return toCard(row, null);
397
+ }
398
+ const detectorStatusCache = new WeakMap();
399
+ let detectorStatusComputeCount = 0;
400
+ /** Test-only: total number of live detector-strip assemblies (cache misses). */
401
+ export function __detectorStatusComputeCount() {
402
+ return detectorStatusComputeCount;
403
+ }
404
+ function ingestGenerationKey(db) {
405
+ const t = db.prepare("SELECT COUNT(*) AS c, IFNULL(MAX(rowid), 0) AS m FROM turns").get();
406
+ const s = db.prepare("SELECT COUNT(*) AS c, IFNULL(MAX(rowid), 0) AS m FROM sessions").get();
407
+ const ci = db.prepare("SELECT COUNT(*) AS c FROM context_inventory").get();
408
+ return `t${t.c}:${t.m}|s${s.c}:${s.m}|ci${ci.c}`;
409
+ }
410
+ function cachedDetectorStatuses(db) {
411
+ const key = ingestGenerationKey(db);
412
+ const hit = detectorStatusCache.get(db);
413
+ if (hit !== undefined && hit.key === key)
414
+ return hit.detectors;
415
+ const detectors = getDetectorStatuses(db);
416
+ detectorStatusCache.set(db, { key, detectors });
417
+ detectorStatusComputeCount += 1;
418
+ return detectors;
419
+ }
420
+ /**
421
+ * Read `recommendations` grouped by lifecycle plus the live per-detector status.
422
+ * When `scope` is given, only that workspace's rows and global (NULL-scope) rows
423
+ * are returned. Claim kind: EXPERIMENTAL.
424
+ */
425
+ export function listRecommendations(scope) {
426
+ const db = getQueryDb();
427
+ const rows = (scope !== undefined
428
+ ? db
429
+ .prepare(`SELECT r.rec_id, r.detector_id, r.category, r.scope_workspace_id, r.lever,
430
+ r.modeled_savings_u_per_wk, ar.cost_equiv_u AS run_cost_u,
431
+ r.modeled_formula_json, r.evidence_json, r.target_metric, r.state,
432
+ r.created_at, r.dismissed_until
433
+ FROM recommendations r
434
+ LEFT JOIN analysis_runs ar ON ar.run_id = r.analysis_run_id
435
+ WHERE r.scope_workspace_id = ? OR r.scope_workspace_id IS NULL
436
+ ORDER BY r.created_at DESC, r.rec_id ASC`)
437
+ .all(scope)
438
+ : db
439
+ .prepare(`SELECT r.rec_id, r.detector_id, r.category, r.scope_workspace_id, r.lever,
440
+ r.modeled_savings_u_per_wk, ar.cost_equiv_u AS run_cost_u,
441
+ r.modeled_formula_json, r.evidence_json, r.target_metric, r.state,
442
+ r.created_at, r.dismissed_until
443
+ FROM recommendations r
444
+ LEFT JOIN analysis_runs ar ON ar.run_id = r.analysis_run_id
445
+ ORDER BY r.created_at DESC, r.rec_id ASC`)
446
+ .all());
447
+ // Derive sessions_per_week per scope: a global rec divides by all-workspace sessions
448
+ // (matching its cross-workspace turns_per_week basis); a per-workspace rec divides by
449
+ // that workspace's own sessions. ISO cutoff bound as a param — last_turn_at is stored
450
+ // via toISOString() ('T' separator), so a SQLite datetime('now') string (space
451
+ // separator) mis-compares on the boundary day; spend.ts/overview.ts use this same
452
+ // bound-param pattern.
453
+ const cutoffIso = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
454
+ const sessionRows = db
455
+ .prepare(`SELECT workspace_id AS workspace_id, COUNT(*) AS cnt
456
+ FROM sessions WHERE last_turn_at >= ? GROUP BY workspace_id`)
457
+ .all(cutoffIso);
458
+ const sessionsByWorkspace = new Map();
459
+ let totalSessions = 0;
460
+ for (const s of sessionRows) {
461
+ sessionsByWorkspace.set(s.workspace_id, s.cnt);
462
+ totalSessions += s.cnt;
463
+ }
464
+ const active = [];
465
+ const limit_warnings = [];
466
+ const adopted = [];
467
+ const dismissed = [];
468
+ const nowIso = new Date(Date.now()).toISOString();
469
+ for (const r of rows) {
470
+ const sessionsPerWeek = r.scope_workspace_id === null
471
+ ? totalSessions
472
+ : (sessionsByWorkspace.get(r.scope_workspace_id) ?? 0);
473
+ const card = toCard(r, sessionsPerWeek);
474
+ if (card.state === "PROPOSED") {
475
+ // D5/LIMIT recs are time-sensitive warnings surfaced as an alert strip
476
+ // above the ranked waste-source list (taxonomy §7 IA §4 §2.1).
477
+ if (card.category === "LIMIT")
478
+ limit_warnings.push(card);
479
+ else
480
+ active.push(card);
481
+ }
482
+ else if (card.state === "DISMISSED") {
483
+ // Auto-expire: if the cool-down (dismissed_until) has passed, restore to proposed.
484
+ // This makes snooze reversible — the card re-proposes after its wake date without
485
+ // a DB state change. dismissed_until=null means a permanent dismiss (no auto-return).
486
+ if (card.dismissed_until !== null && card.dismissed_until <= nowIso) {
487
+ if (card.category === "LIMIT")
488
+ limit_warnings.push(card);
489
+ else
490
+ active.push(card);
491
+ }
492
+ else {
493
+ dismissed.push(card);
494
+ }
495
+ }
496
+ else if (ADOPTED_STATES.has(card.state))
497
+ adopted.push(card);
498
+ }
499
+ active.sort(byArticlePriority);
500
+ const detectors = cachedDetectorStatuses(db);
501
+ const data = {
502
+ active,
503
+ active_groups: deriveActiveGroups(active),
504
+ limit_warnings,
505
+ adopted,
506
+ dismissed,
507
+ detectors,
508
+ };
509
+ return buildResponse(data, {
510
+ claim_kind: "EXPERIMENTAL",
511
+ n: active.length,
512
+ drilldown_ids: scope !== undefined ? { workspace_id: scope } : {},
513
+ });
514
+ }