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,78 @@
1
+ /**
2
+ * src/query/api/reports.ts — deterministic weekly report artifacts.
3
+ *
4
+ * Reports are bounded snapshots assembled from existing aggregate query APIs.
5
+ * They never include transcript content or raw event data and are safe to run
6
+ * unattended because they do not invoke an LLM or an external CLI.
7
+ */
8
+ import { setQueryDb } from "../db-context.js";
9
+ import { getSuccessRate } from "./outcomes.js";
10
+ import { getGlobalOverview } from "./overview.js";
11
+ import { listRecommendations } from "./recommendations.js";
12
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
13
+ export function isoWeekBounds(now) {
14
+ const daysSinceMonday = (now.getUTCDay() + 6) % 7;
15
+ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - daysSinceMonday));
16
+ return {
17
+ period_start: start.toISOString(),
18
+ period_end: new Date(start.getTime() + 7 * MS_PER_DAY).toISOString(),
19
+ };
20
+ }
21
+ /** Generate or return the idempotent weekly report for the ISO week containing `now`. */
22
+ export function generateWeeklyReport(db, now = new Date()) {
23
+ setQueryDb(db);
24
+ const { period_start, period_end } = isoWeekBounds(new Date(now.getTime() - 7 * MS_PER_DAY));
25
+ const spendResponse = getGlobalOverview({ from: period_start, to: period_end });
26
+ const recommendationsResponse = listRecommendations();
27
+ const outcomesResponse = getSuccessRate();
28
+ const spend = spendResponse.data;
29
+ const recommendations = recommendationsResponse.data;
30
+ const outcomes = outcomesResponse.data;
31
+ const openRecommendations = [
32
+ ...(recommendations?.active ?? []),
33
+ ...(recommendations?.limit_warnings ?? []),
34
+ ]
35
+ .sort((a, b) => {
36
+ const aSavings = a.modeled_savings_u_per_wk ?? Number.NEGATIVE_INFINITY;
37
+ const bSavings = b.modeled_savings_u_per_wk ?? Number.NEGATIVE_INFINITY;
38
+ if (aSavings !== bSavings)
39
+ return bSavings - aSavings;
40
+ return a.rec_id.localeCompare(b.rec_id);
41
+ })
42
+ .slice(0, 5)
43
+ .map((rec) => ({
44
+ rec_id: rec.rec_id,
45
+ modeled_savings_u_per_wk: rec.modeled_savings_u_per_wk,
46
+ }));
47
+ const content = {
48
+ spend: {
49
+ cost_equiv_u: spend?.cost_equiv_u ?? 0,
50
+ turns: spend?.turns ?? 0,
51
+ turns_total: spend?.turns_total ?? 0,
52
+ unpriced_turns: spend?.unpriced_turns ?? 0,
53
+ },
54
+ top_recommendations: openRecommendations,
55
+ outcomes: {
56
+ terminal_n: outcomes?.terminal_n ?? 0,
57
+ success_rate: outcomes?.success_rate ?? null,
58
+ clean_success_n: outcomes?.clean_success_n ?? 0,
59
+ with_deferrals_n: outcomes?.with_deferrals_n ?? 0,
60
+ no_ci_success_n: outcomes?.no_ci_success_n ?? 0,
61
+ linkage_rate: outcomes?.linkage_rate ?? null,
62
+ },
63
+ };
64
+ const reportId = `weekly-${period_start}`;
65
+ db.prepare(`INSERT OR IGNORE INTO reports
66
+ (report_id, kind, period_start, period_end, generated_at, content_json)
67
+ VALUES (?, 'weekly', ?, ?, ?, ?)`).run(reportId, period_start, period_end, now.toISOString(), JSON.stringify(content));
68
+ const report = db.prepare("SELECT * FROM reports WHERE report_id = ?").get(reportId);
69
+ if (report === undefined)
70
+ throw new Error(`weekly report ${reportId} was not stored`);
71
+ return report;
72
+ }
73
+ export function listReports(db) {
74
+ return db.prepare("SELECT * FROM reports ORDER BY generated_at DESC").all();
75
+ }
76
+ export function getReport(db, id) {
77
+ return db.prepare("SELECT * FROM reports WHERE report_id = ?").get(id);
78
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * src/query/api/self-churn.ts — query-only L2b self-churn metrics.
3
+ *
4
+ * Self-churn measures later changes to session-authored lines using structural
5
+ * commit data only; no source text, paths, or diff content leave this surface.
6
+ */
7
+ import { buildResponse } from "../envelope.js";
8
+ /**
9
+ * Derive 14-day self-churn for sessions with turns in [from, to).
10
+ *
11
+ * A LEFT JOIN keeps in-window sessions that have no session_churn row yet (new
12
+ * sessions accumulate before the churn collector runs); the DB aggregates in one
13
+ * pass. Commit SHAs come from a second scan over the measured sessions only —
14
+ * one JSON array per session unioned into a set.
15
+ */
16
+ export function getSelfChurn(db, opts) {
17
+ const workspaceFilter = opts.workspaceId === null ? "" : " AND t.workspace_id = ?";
18
+ const params = opts.workspaceId === null ? [opts.from, opts.to] : [opts.from, opts.to, opts.workspaceId];
19
+ const inWindow = `WITH in_window_turns AS (
20
+ SELECT t.session_id, t.workspace_id
21
+ FROM turns t
22
+ WHERE t.ts >= ? AND t.ts < ?${workspaceFilter}
23
+ ),
24
+ in_window_sessions AS (
25
+ SELECT DISTINCT session_id, workspace_id
26
+ FROM in_window_turns
27
+ )`;
28
+ const totals = db
29
+ .prepare(`${inWindow}
30
+ SELECT COUNT(*) AS session_count,
31
+ COALESCE(SUM(CASE WHEN sc.status != 'NO_REPO' AND sc.authored_lines > 0 THEN 1 ELSE 0 END), 0)
32
+ AS measured_session_count,
33
+ COALESCE(SUM(sc.authored_lines), 0) AS authored_lines_total,
34
+ COALESCE(SUM(sc.churned_lines), 0) AS churned_lines_total
35
+ FROM in_window_sessions iws
36
+ LEFT JOIN session_churn sc USING (session_id)`)
37
+ .get(...params);
38
+ const shaRows = db
39
+ .prepare(`${inWindow}
40
+ SELECT sc.commit_shas
41
+ FROM in_window_sessions iws
42
+ JOIN session_churn sc USING (session_id)
43
+ WHERE sc.status != 'NO_REPO' AND sc.authored_lines > 0`)
44
+ .all(...params);
45
+ const commitShas = new Set();
46
+ for (const row of shaRows) {
47
+ const shas = JSON.parse(row.commit_shas);
48
+ if (Array.isArray(shas)) {
49
+ for (const sha of shas) {
50
+ if (typeof sha === "string")
51
+ commitShas.add(sha);
52
+ }
53
+ }
54
+ }
55
+ const data = {
56
+ ...totals,
57
+ churn_ratio: totals.authored_lines_total === 0
58
+ ? null
59
+ : totals.churned_lines_total / totals.authored_lines_total,
60
+ commit_shas: [...commitShas].sort(),
61
+ from: opts.from,
62
+ to: opts.to,
63
+ workspace_id: opts.workspaceId,
64
+ };
65
+ return buildResponse(data, {
66
+ claim_kind: "OBS_PROXY",
67
+ n: data.measured_session_count,
68
+ window: { from: opts.from, to: opts.to },
69
+ qualification: {
70
+ provisional_excluded: true,
71
+ unpriced_turns: 0,
72
+ claim_kinds_count: 1,
73
+ note: "14-day self-churn of session-authored commits (structural proxy).",
74
+ },
75
+ ...(opts.workspaceId === null ? {} : { drilldown_ids: { workspace_id: opts.workspaceId } }),
76
+ });
77
+ }
@@ -0,0 +1,109 @@
1
+ import { isoWeekBounds } from "./reports.js";
2
+ const WINDOW_DAYS = 90;
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ export function getSessionSpendPercentile(db, sessionId) {
5
+ const target = db
6
+ .prepare(`SELECT workspace_id, cost_equiv_u, last_turn_at
7
+ FROM sessions
8
+ WHERE session_id = ?`)
9
+ .get(sessionId);
10
+ if (target === undefined || target.cost_equiv_u === null || target.last_turn_at === null) {
11
+ return { percentile: null, n: 0, window_days: WINDOW_DAYS };
12
+ }
13
+ const windowStart = new Date(Date.parse(target.last_turn_at) - WINDOW_DAYS * DAY_MS).toISOString();
14
+ const population = db
15
+ .prepare(`SELECT COUNT(*) AS population_count,
16
+ COALESCE(SUM(CASE WHEN cost_equiv_u <= ? THEN 1 ELSE 0 END), 0) AS at_or_below_count
17
+ FROM sessions
18
+ WHERE workspace_id = ?
19
+ AND last_turn_at >= ?
20
+ AND last_turn_at <= ?
21
+ AND cost_equiv_u IS NOT NULL`)
22
+ .get(target.cost_equiv_u, target.workspace_id, windowStart, target.last_turn_at);
23
+ if (population.population_count < 20) {
24
+ return { percentile: null, n: population.population_count, window_days: WINDOW_DAYS };
25
+ }
26
+ // Ties count at or below; the UI renders "top X%" as 1 - percentile.
27
+ return {
28
+ percentile: population.at_or_below_count / population.population_count,
29
+ n: population.population_count,
30
+ window_days: WINDOW_DAYS,
31
+ };
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Per-week self-percentiles — this (current, partial) ISO week vs the 8 full
35
+ // weeks immediately before it, for spend and cache-write share.
36
+ //
37
+ // "Your own history" is the honest peer group: the percentile ranks this week
38
+ // against the trailing full weeks (<= ties, mirroring the per-session form).
39
+ // Deterministic — the caller passes an explicit `now`; every window boundary is
40
+ // derived from it plus fixed offsets, never a scattered Date.now().
41
+ // ---------------------------------------------------------------------------
42
+ const TRAILING_WEEKS = 8;
43
+ /**
44
+ * A rank needs a few reference weeks to mean anything; below this the percentile
45
+ * is withheld (null) rather than reported as noise off one or two weeks.
46
+ */
47
+ const MIN_TRAILING_WEEKS_WITH_DATA = 4;
48
+ const WEEK_MS = 7 * DAY_MS;
49
+ function median(values) {
50
+ if (values.length === 0)
51
+ return null;
52
+ const sorted = [...values].sort((a, b) => a - b);
53
+ const mid = Math.floor(sorted.length / 2);
54
+ const lo = sorted[mid - 1];
55
+ const hi = sorted[mid];
56
+ if (sorted.length % 2 === 0) {
57
+ return lo !== undefined && hi !== undefined ? (lo + hi) / 2 : (hi ?? lo ?? null);
58
+ }
59
+ return hi ?? null;
60
+ }
61
+ /** Rank `thisValue` against `trailing` values with data — <= ties, mirroring the per-session form. */
62
+ function weeklyMetric(thisValue, trailing) {
63
+ const withData = trailing.filter((v) => v !== null);
64
+ const n = withData.length;
65
+ const trailing_median = median(withData);
66
+ if (thisValue === null || n < MIN_TRAILING_WEEKS_WITH_DATA) {
67
+ return { this_week: thisValue, percentile: null, trailing_median, n };
68
+ }
69
+ const atOrBelow = withData.filter((v) => v <= thisValue).length;
70
+ return { this_week: thisValue, percentile: atOrBelow / n, trailing_median, n };
71
+ }
72
+ export function getWeeklySelfPercentile(db, now) {
73
+ const stmt = db.prepare(`SELECT COUNT(*) AS turns,
74
+ COALESCE(SUM(cost_equiv_u), 0) AS spend_u,
75
+ COALESCE(SUM(cache_write_5m + cache_write_1h
76
+ + cache_write_other), 0) AS cache_write,
77
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read
78
+ FROM turns
79
+ WHERE ts >= ? AND ts < ? AND provisional = 0`);
80
+ const weekMetrics = (start, end) => {
81
+ const row = stmt.get(start, end);
82
+ const cacheTotal = row.cache_write + row.cache_read;
83
+ return {
84
+ turns: row.turns,
85
+ spend_u: row.spend_u,
86
+ cache_write_share: cacheTotal === 0 ? null : row.cache_write / cacheTotal,
87
+ };
88
+ };
89
+ const { period_start, period_end } = isoWeekBounds(now);
90
+ const currentStartMs = Date.parse(period_start);
91
+ const current = weekMetrics(period_start, period_end);
92
+ // The 8 contiguous full weeks immediately before the current week. period_start
93
+ // is a Monday, so each trailing boundary is a whole-week shift from it.
94
+ const trailing = [];
95
+ for (let k = 1; k <= TRAILING_WEEKS; k += 1) {
96
+ const start = new Date(currentStartMs - k * WEEK_MS).toISOString();
97
+ const end = new Date(currentStartMs - (k - 1) * WEEK_MS).toISOString();
98
+ trailing.push(weekMetrics(start, end));
99
+ }
100
+ const spend = weeklyMetric(current.turns > 0 ? current.spend_u : null, trailing.map((w) => (w.turns > 0 ? w.spend_u : null)));
101
+ const cache_write_share = weeklyMetric(current.cache_write_share, trailing.map((w) => w.cache_write_share));
102
+ return {
103
+ week_start: period_start,
104
+ trailing_weeks: TRAILING_WEEKS,
105
+ min_weeks_with_data: MIN_TRAILING_WEEKS_WITH_DATA,
106
+ spend,
107
+ cache_write_share,
108
+ };
109
+ }
@@ -0,0 +1,153 @@
1
+ /** Per-session, privacy-safe detector attribution. */
2
+ import { buildResponse } from "../envelope.js";
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ const DETECTOR_ORDER = { D6: 0, D8: 1, D7: 2, D2: 3 };
5
+ const DRIVER_CONFIG = {
6
+ D2: {
7
+ label: "SESSION_LONG_FULL_CONTEXT",
8
+ fields: [],
9
+ routing: "hook",
10
+ },
11
+ D6: {
12
+ label: "TOOL_RESULT_BLOAT",
13
+ fields: ["tool_result_bytes", "bloat_share", "attributed_tool", "turn_count"],
14
+ shareField: "bloat_share",
15
+ routing: "rec_card",
16
+ },
17
+ D7: {
18
+ label: "LOOP_RETRY_WASTE",
19
+ fields: [
20
+ "loop_flagged_turn_count",
21
+ "repeat_excess_event_count",
22
+ "total_turn_count",
23
+ "loop_flagged_turn_share",
24
+ ],
25
+ shareField: "loop_flagged_turn_share",
26
+ routing: "hook",
27
+ },
28
+ D8: {
29
+ label: "CACHE_WRITE_CHURN",
30
+ fields: ["churn_event_count", "total_churn_creation_tokens", "creation_share", "regime"],
31
+ shareField: "creation_share",
32
+ routing: "hook",
33
+ },
34
+ };
35
+ function parseEvidence(evidenceJson) {
36
+ try {
37
+ const value = JSON.parse(evidenceJson);
38
+ return value !== null && typeof value === "object" && !Array.isArray(value)
39
+ ? value
40
+ : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function isSessionAttribution(evidence, sessionId) {
47
+ return (evidence.session_id === sessionId ||
48
+ (Array.isArray(evidence.session_ids) && evidence.session_ids.includes(sessionId)));
49
+ }
50
+ function whitelistedMeasured(detectorId, evidence) {
51
+ if (detectorId === "D2")
52
+ return { in_long_context_group: true };
53
+ const measured = {};
54
+ for (const field of DRIVER_CONFIG[detectorId].fields) {
55
+ const value = evidence[field];
56
+ if ((typeof value === "number" && Number.isFinite(value)) ||
57
+ typeof value === "string" ||
58
+ typeof value === "boolean") {
59
+ measured[field] = value;
60
+ }
61
+ }
62
+ return measured;
63
+ }
64
+ function boundedShare(value) {
65
+ return typeof value === "number" && Number.isFinite(value)
66
+ ? Math.min(1, Math.max(0, value))
67
+ : null;
68
+ }
69
+ function percentile(db, session) {
70
+ if (session.last_turn_at === null)
71
+ return 0;
72
+ const referenceMs = Date.parse(session.last_turn_at);
73
+ if (!Number.isFinite(referenceMs))
74
+ return 0;
75
+ const windowStart = new Date(referenceMs - 30 * DAY_MS).toISOString();
76
+ const row = db
77
+ .prepare(`SELECT COUNT(*) AS population_count,
78
+ COALESCE(SUM(CASE WHEN cost_equiv_u <= ? THEN 1 ELSE 0 END), 0) AS at_or_below_count
79
+ FROM sessions
80
+ WHERE last_turn_at >= ? AND last_turn_at <= ?`)
81
+ .get(session.cost_equiv_u, windowStart, session.last_turn_at);
82
+ if (row.population_count === 0)
83
+ return 0;
84
+ return Math.round((1000 * row.at_or_below_count) / row.population_count) / 10;
85
+ }
86
+ /**
87
+ * Return session-attributed detector drivers without exposing transcript content.
88
+ */
89
+ export function getSessionDrivers(db, sessionId) {
90
+ const session = db
91
+ .prepare(`SELECT workspace_id, cost_equiv_u, last_turn_at
92
+ FROM sessions
93
+ WHERE session_id = ?`)
94
+ .get(sessionId);
95
+ if (session === undefined) {
96
+ return buildResponse(null, {
97
+ claim_kind: "N_A",
98
+ n: 0,
99
+ drilldown_ids: { session_id: sessionId },
100
+ });
101
+ }
102
+ const recommendations = db
103
+ .prepare(`SELECT rec_id, detector_id, modeled_savings_u_per_wk, evidence_json
104
+ FROM recommendations
105
+ WHERE state = 'PROPOSED'
106
+ AND detector_id IN ('D2', 'D6', 'D7', 'D8')
107
+ AND (scope_workspace_id = ? OR scope_workspace_id IS NULL)`)
108
+ .all(session.workspace_id);
109
+ const drivers = [];
110
+ for (const recommendation of recommendations) {
111
+ const evidence = parseEvidence(recommendation.evidence_json);
112
+ if (evidence === null || !isSessionAttribution(evidence, sessionId))
113
+ continue;
114
+ const config = DRIVER_CONFIG[recommendation.detector_id];
115
+ const share = boundedShare(config.shareField === undefined ? undefined : evidence[config.shareField]);
116
+ drivers.push({
117
+ detector_id: recommendation.detector_id,
118
+ label: config.label,
119
+ measured: whitelistedMeasured(recommendation.detector_id, evidence),
120
+ share,
121
+ rec_id: recommendation.rec_id,
122
+ routing: config.routing,
123
+ ...(recommendation.modeled_savings_u_per_wk === null
124
+ ? {}
125
+ : { approx_usd: recommendation.modeled_savings_u_per_wk / 1_000_000 }),
126
+ });
127
+ }
128
+ drivers.sort((a, b) => {
129
+ const savingsDiff = (b.approx_usd ?? 0) - (a.approx_usd ?? 0);
130
+ if (savingsDiff !== 0)
131
+ return savingsDiff;
132
+ const shareDiff = (b.share ?? 0) - (a.share ?? 0);
133
+ if (shareDiff !== 0)
134
+ return shareDiff;
135
+ return DETECTOR_ORDER[a.detector_id] - DETECTOR_ORDER[b.detector_id];
136
+ });
137
+ return buildResponse({
138
+ session_id: sessionId,
139
+ workspace_id: session.workspace_id,
140
+ percentile: percentile(db, session),
141
+ drivers,
142
+ }, {
143
+ claim_kind: "OBS_PROXY",
144
+ n: drivers.length,
145
+ drilldown_ids: { session_id: sessionId, workspace_id: session.workspace_id },
146
+ qualification: {
147
+ provisional_excluded: false,
148
+ unpriced_turns: 0,
149
+ claim_kinds_count: 1,
150
+ note: "Per-driver figures are observed proxies; never summed.",
151
+ },
152
+ });
153
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * src/query/api/settings.ts — LocalQueryAPI: settings method stubs.
3
+ *
4
+ * WP4 owns this file. These signatures and return types are FROZEN by WP0.
5
+ * Do NOT change method signatures without a plan decision.
6
+ *
7
+ * All methods return stub responses. WP4 implements the real bodies,
8
+ * wiring to the user_config table and WorkspaceRegistry.
9
+ */
10
+ import { getQueryDb } from "../db-context.js";
11
+ import { buildResponse } from "../envelope.js";
12
+ import { applySettingsUpdate as _applySettingsUpdate, calibrateLimit as _calibrateLimit, resetDatabase as _resetDatabase, getSettingsData, } from "../settings-store.js";
13
+ import { calibrateBytesPerToken as _calibrateBytesPerToken } from "../../detector/calibration.js";
14
+ // ---------------------------------------------------------------------------
15
+ // Method stubs — WP4 fills these
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Return the current daemon settings.
19
+ * Reads user_config + WorkspaceRegistry; parser_health from the injected Health instance.
20
+ */
21
+ export function getSettings() {
22
+ const db = getQueryDb();
23
+ const data = getSettingsData(db);
24
+ return buildResponse(data, {
25
+ claim_kind: "N_A",
26
+ n: 1,
27
+ });
28
+ }
29
+ /**
30
+ * Apply a partial settings update and persist to user_config.
31
+ * Validates scan_roots paths before writing. Throws on invalid input.
32
+ *
33
+ * This is the write path; it requires the CSRF same-origin gate (enforced in http.ts).
34
+ */
35
+ export function updateSettings(update) {
36
+ const db = getQueryDb();
37
+ const data = _applySettingsUpdate(db, update);
38
+ return buildResponse(data, {
39
+ claim_kind: "N_A",
40
+ n: 1,
41
+ });
42
+ }
43
+ /**
44
+ * Wipe all ingested data tables and return fresh settings.
45
+ * Preserves user_config and schema_migrations.
46
+ */
47
+ export function resetDatabase() {
48
+ const db = getQueryDb();
49
+ const data = _resetDatabase(db);
50
+ return buildResponse(data, {
51
+ claim_kind: "N_A",
52
+ n: 1,
53
+ });
54
+ }
55
+ /**
56
+ * Auto-calibrate `:limit_tokens` from the user's local oauth/usage utilization.
57
+ * Implements ADR-111: limit_tokens ≈ tokens_in_window / seven_day.utilization.
58
+ *
59
+ * Always returns 200; ok:false means calibration failed gracefully (429, low
60
+ * utilization) — the client shows the reason and falls back to manual entry.
61
+ * This is a write path; it requires the CSRF same-origin gate (enforced in http.ts).
62
+ */
63
+ export async function calibrateLimit() {
64
+ const db = getQueryDb();
65
+ const result = await _calibrateLimit(db);
66
+ return buildResponse(result, {
67
+ claim_kind: "N_A",
68
+ n: 1,
69
+ });
70
+ }
71
+ /**
72
+ * Calibrate the bytes-per-token ratio via Anthropic's count_tokens API (R12).
73
+ *
74
+ * Requires opt-in to be enabled (user_config.bytes_per_token_calibration_enabled="true").
75
+ * Returns ok:false when disabled or when <30 successful samples could be collected.
76
+ * This is a write path; it requires the CSRF same-origin gate (enforced in http.ts).
77
+ */
78
+ export async function calibrateBytesPerToken() {
79
+ const db = getQueryDb();
80
+ const result = await _calibrateBytesPerToken(db);
81
+ return buildResponse(result, {
82
+ claim_kind: "N_A",
83
+ n: 1,
84
+ });
85
+ }