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,201 @@
1
+ /**
2
+ * src/detector/detectors/d8_cache_write_churn.ts — D8 CACHE_WRITE_CHURN (flagship).
3
+ *
4
+ * Fires PER-SESSION when a session repeatedly pays for a full cache re-write on a
5
+ * resume turn — a cache-creation spike after an idle gap that outlived the cache
6
+ * TTL, with little cache_read on that turn (a full-price re-write of the whole
7
+ * window rather than a warm read). These are the dominant reducible cap driver
8
+ * (research 2026-08-25); the meter is cap-weighted, never a raw-token headline.
9
+ *
10
+ * A "churn event" is a resume turn where ALL hold (UNVALIDATED defaults, labeled):
11
+ * - creation = cache_write_5m + cache_write_1h + cache_write_other ≥ 50k
12
+ * - idle_gap_min from the previous turn in the session > effective TTL
13
+ * (> 5 when the 5m tier dominates, > 60 when the 1h tier dominates)
14
+ * - cache_read < 0.2 × creation on that turn (a re-write, not a warm read)
15
+ *
16
+ * Fires the session rec when churn events ≥ 3/wk OR their creation tokens are
17
+ * ≥ 15% of the session's cap-weighted total (Data Model §2A).
18
+ *
19
+ * TTL-regime facet: when ≥ 80% of churn creation is 5m-tier, annotate regime=5m
20
+ * and extend the lever with the 1h-cache suggestion (no separate rec/id).
21
+ *
22
+ * scopeKey: "D8|<session_id>".
23
+ */
24
+ import { LIST_PRICES, modelTier } from "../../ingest/pricing.js";
25
+ import { capWeightedTokens } from "../../query/cap-weighted.js";
26
+ import { D8_AVOIDANCE_FRACTION, D8_CREATION_SHARE, D8_CREATION_SPIKE_TOKENS, D8_LOW_READ_RATIO, D8_MIN_EVENTS, D8_REGIME_5M_SHARE, D8_TTL_1H_GAP_MIN, D8_TTL_5M_GAP_MIN, d8Savings, } from "../savings.js";
27
+ /** Resolve [cw5m, cw1h] write prices ($/MTok) for a turn, snapshot first, list-price fallback. */
28
+ function writePrices(unitPricesJson, model) {
29
+ if (unitPricesJson) {
30
+ try {
31
+ const arr = JSON.parse(unitPricesJson);
32
+ if (typeof arr[3] === "number" && typeof arr[4] === "number")
33
+ return [arr[3], arr[4]];
34
+ }
35
+ catch {
36
+ // fall through to list price
37
+ }
38
+ }
39
+ const tier = modelTier(model);
40
+ const lp = tier ? LIST_PRICES[tier] : undefined;
41
+ return lp ? [lp[3], lp[4]] : [0, 0];
42
+ }
43
+ export const d8Detector = {
44
+ id: "D8",
45
+ name: "CACHE_WRITE_CHURN",
46
+ evaluate(db, ctx) {
47
+ // Per-session cap-weighted totals (denominator for the creation-share gate).
48
+ const capRows = capWeightedTokens(db, {
49
+ fromIso: ctx.fromIso,
50
+ toIso: ctx.toIso,
51
+ groupBy: "session_id",
52
+ });
53
+ const capBySession = new Map(capRows.map((r) => [r.group ?? "", r]));
54
+ // All reconciled turns in the window, ordered by session then time (for gaps).
55
+ const turns = db
56
+ .prepare(`SELECT t.session_id, t.workspace_id, t.ts, t.model,
57
+ t.cache_read_tokens, t.cache_write_5m, t.cache_write_1h, t.cache_write_other,
58
+ ps.unit_prices_json AS unit_prices_json
59
+ FROM turns t
60
+ LEFT JOIN pricing_snapshots ps ON ps.snapshot_id = t.pricing_snapshot_id
61
+ WHERE t.ts >= ? AND t.ts < ? AND t.provisional = 0
62
+ ORDER BY t.session_id ASC, t.ts ASC`)
63
+ .all(ctx.fromIso, ctx.toIso);
64
+ // Group churn events by session (single pass; gap = delta from prev turn in session).
65
+ const eventsBySession = new Map();
66
+ let prevSession = null;
67
+ let prevTsMs = 0;
68
+ let prevModel = null;
69
+ for (const t of turns) {
70
+ const tsMs = new Date(t.ts).getTime();
71
+ const hasPrev = prevSession === t.session_id;
72
+ const creation = t.cache_write_5m + t.cache_write_1h + t.cache_write_other;
73
+ if (hasPrev && creation >= D8_CREATION_SPIKE_TOKENS) {
74
+ const idleGapMin = (tsMs - prevTsMs) / 60_000;
75
+ const fiveMinDominant = t.cache_write_5m > t.cache_write_1h;
76
+ const ttlGap = fiveMinDominant ? D8_TTL_5M_GAP_MIN : D8_TTL_1H_GAP_MIN;
77
+ const lowRead = t.cache_read_tokens < D8_LOW_READ_RATIO * creation;
78
+ if (idleGapMin > ttlGap && lowRead) {
79
+ const [p5m, p1h] = writePrices(t.unit_prices_json, t.model);
80
+ const writeU = t.cache_write_5m * p5m + t.cache_write_1h * p1h + t.cache_write_other * p5m;
81
+ const entry = eventsBySession.get(t.session_id) ?? {
82
+ workspace_id: t.workspace_id,
83
+ events: [],
84
+ };
85
+ entry.events.push({
86
+ ts: t.ts,
87
+ idle_gap_min: Number(idleGapMin.toFixed(2)),
88
+ cache_write_5m: t.cache_write_5m,
89
+ cache_write_1h: t.cache_write_1h,
90
+ cache_write_other: t.cache_write_other,
91
+ cache_read_tokens: t.cache_read_tokens,
92
+ creation,
93
+ write_u: writeU,
94
+ cause_facets: {
95
+ idle_gap: true,
96
+ model_switch: modelTier(prevModel) !== modelTier(t.model),
97
+ session_reopen: "UNOBSERVABLE",
98
+ prefix_config_change: "UNOBSERVABLE",
99
+ dynamic_content: "UNOBSERVABLE",
100
+ },
101
+ });
102
+ eventsBySession.set(t.session_id, entry);
103
+ }
104
+ }
105
+ prevSession = t.session_id;
106
+ prevTsMs = tsMs;
107
+ prevModel = t.model;
108
+ }
109
+ const fired = [];
110
+ for (const [sessionId, { workspace_id, events }] of eventsBySession) {
111
+ const totalCreation = events.reduce((s, e) => s + e.creation, 0);
112
+ const cap = capBySession.get(sessionId);
113
+ const sessionCapTotal = cap?.cap_weighted_tokens ?? 0;
114
+ const creationShare = sessionCapTotal > 0 ? totalCreation / sessionCapTotal : 0;
115
+ const meetsCount = events.length >= D8_MIN_EVENTS;
116
+ const meetsShare = creationShare >= D8_CREATION_SHARE;
117
+ if (!meetsCount && !meetsShare)
118
+ continue;
119
+ // Blended write price ($/MTok) weighted by creation across the churn events.
120
+ const totalWriteU = events.reduce((s, e) => s + e.write_u, 0);
121
+ const blendedWritePrice = totalCreation > 0 ? totalWriteU / totalCreation : 0;
122
+ const { savingsU, formula } = d8Savings(totalCreation, blendedWritePrice, D8_AVOIDANCE_FRACTION);
123
+ if (savingsU <= 0)
124
+ continue;
125
+ // TTL-regime facet: is this session dominated by 5m-tier creation?
126
+ const fiveMinCreation = events.reduce((s, e) => s + e.cache_write_5m, 0);
127
+ const regime5m = totalCreation > 0 && fiveMinCreation / totalCreation >= D8_REGIME_5M_SHARE;
128
+ const baseLever = "Use /clear (or resume-from-summary) before idling past the cache TTL, and batch prefix/CLAUDE.md edits to a session boundary so they don't invalidate the warm cache mid-session.";
129
+ const lever = regime5m
130
+ ? `${baseLever} This session's creation is mostly 5m-tier — enable the 1h cache regime (ENABLE_PROMPT_CACHING_1H) where long pauses are unavoidable.`
131
+ : baseLever;
132
+ fired.push({
133
+ scopeKey: `D8|${sessionId}`,
134
+ category: "CACHE",
135
+ scope_workspace_id: workspace_id,
136
+ lever,
137
+ target_metric: "cache_read_to_creation_ratio",
138
+ modeled_savings_u_per_wk: savingsU,
139
+ modeled_formula: formula,
140
+ evidence: {
141
+ title: `Reduce cache-write churn: ${events.length} re-write${events.length === 1 ? "" : "s"} in session`,
142
+ session_id: sessionId,
143
+ workspace_id,
144
+ churn_event_count: events.length,
145
+ total_churn_creation_tokens: totalCreation,
146
+ session_cap_weighted_tokens: sessionCapTotal,
147
+ creation_share: Number(creationShare.toFixed(4)),
148
+ modeled_savings_basis: "LIST_EQUIV",
149
+ billed_cost_claim: "UNAVAILABLE",
150
+ cache_read_to_creation_ratio: cap?.cache_read_to_creation_ratio ?? null,
151
+ regime: regime5m ? "5m" : "mixed",
152
+ cause_facets: {
153
+ idle_gap: true,
154
+ model_switch: events.some((e) => e.cause_facets.model_switch),
155
+ session_reopen: "UNOBSERVABLE",
156
+ prefix_config_change: "UNOBSERVABLE",
157
+ dynamic_content: "UNOBSERVABLE",
158
+ },
159
+ creation_spike_threshold: D8_CREATION_SPIKE_TOKENS,
160
+ ttl_5m_gap_min_threshold: D8_TTL_5M_GAP_MIN,
161
+ ttl_1h_gap_min_threshold: D8_TTL_1H_GAP_MIN,
162
+ low_read_ratio_threshold: D8_LOW_READ_RATIO,
163
+ min_events_threshold: D8_MIN_EVENTS,
164
+ creation_share_threshold: D8_CREATION_SHARE,
165
+ avoidance_fraction: D8_AVOIDANCE_FRACTION,
166
+ thresholds_unvalidated: true,
167
+ events: events.map((e) => ({
168
+ ts: e.ts,
169
+ idle_gap_min: e.idle_gap_min,
170
+ cache_write_5m: e.cache_write_5m,
171
+ cache_write_1h: e.cache_write_1h,
172
+ cache_write_other: e.cache_write_other,
173
+ cache_read_tokens: e.cache_read_tokens,
174
+ creation_tokens: e.creation,
175
+ cause_facets: e.cause_facets,
176
+ })),
177
+ steps: [
178
+ "Open the session and find the resume turns with a large cache-creation spike after an idle gap",
179
+ "Before stepping away, /clear or resume from a summary so the next turn reads a warm cache instead of re-writing it",
180
+ "Batch CLAUDE.md / prefix edits to a session boundary so they don't invalidate the cache mid-session",
181
+ regime5m
182
+ ? "This session is 5m-tier dominant — enable the 1h cache regime where long pauses are unavoidable"
183
+ : "Where long pauses are unavoidable, prefer the 1h cache regime",
184
+ ],
185
+ },
186
+ });
187
+ }
188
+ if (fired.length === 0) {
189
+ return {
190
+ fired: [],
191
+ status: "INACTIVE",
192
+ note: "no session met the cache-write-churn threshold",
193
+ };
194
+ }
195
+ return {
196
+ fired,
197
+ status: "ACTIVE",
198
+ note: `${fired.length} session(s) with cache-write churn`,
199
+ };
200
+ },
201
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * src/detector/detectors/d9_idle_background_session.ts — D9 IDLE_BACKGROUND_SESSION.
3
+ *
4
+ * Fires PER-WORKSPACE when background / subagent fan-out (is_sidechain turns)
5
+ * dominates the cap-weighted volume — work that may not convert to forward
6
+ * progress. Built on the buildable is_sidechain share proxy; user_turn_count is
7
+ * now ingested per migration 009, so the zero-user-turn facet is available.
8
+ * Savings are marked DIRECTIONAL — fan-out is a real quality/throughput
9
+ * tradeoff, not pure waste (research B), and parent-child linkage remains
10
+ * heuristic (issue #32175). No crisp $ headline is emitted.
11
+ *
12
+ * Trigger (UNVALIDATED defaults, labeled): a workspace where is_sidechain=1 turns
13
+ * contribute ≥ 25% of cap-weighted tokens AND absolute sidechain cap-weighted
14
+ * tokens ≥ 100k in the trailing week.
15
+ *
16
+ * scopeKey: "D9|<workspace_id>".
17
+ */
18
+ import { capWeightExprSql, capWeightedTokens, resolveCapReadCoeff, } from "../../query/cap-weighted.js";
19
+ import { D9_SIDECHAIN_ABS_CAP_TOKENS, D9_SIDECHAIN_SHARE, D9_UNPRODUCTIVE_FRACTION, d9Formula, } from "../savings.js";
20
+ export const d9Detector = {
21
+ id: "D9",
22
+ name: "IDLE_BACKGROUND_SESSION",
23
+ evaluate(db, ctx) {
24
+ const coeff = resolveCapReadCoeff(db);
25
+ // Cap-weighted total per workspace (denominator for the share gate).
26
+ const totals = capWeightedTokens(db, {
27
+ fromIso: ctx.fromIso,
28
+ toIso: ctx.toIso,
29
+ groupBy: "workspace_id",
30
+ coeff,
31
+ });
32
+ const totalByWorkspace = new Map(totals.map((r) => [r.group ?? "", r.cap_weighted_tokens]));
33
+ // Sidechain-only cap-weighted tokens per workspace (reuses the shared meter expression).
34
+ const sidechainRows = db
35
+ .prepare(`SELECT t.workspace_id AS workspace_id,
36
+ SUM(${capWeightExprSql("t", coeff)}) AS sidechain_cap_raw,
37
+ SUM(CASE WHEN s.user_turn_count = 0
38
+ THEN ${capWeightExprSql("t", coeff)} ELSE 0 END) AS zero_utc_cap_raw,
39
+ COUNT(DISTINCT CASE WHEN s.user_turn_count = 0 THEN t.session_id END) AS zero_utc_sessions,
40
+ COUNT(*) AS sidechain_turns
41
+ FROM turns t
42
+ JOIN sessions s ON t.session_id = s.session_id
43
+ WHERE t.ts >= ? AND t.ts < ? AND t.provisional = 0 AND t.is_sidechain = 1
44
+ GROUP BY t.workspace_id`)
45
+ .all(ctx.fromIso, ctx.toIso);
46
+ const fired = [];
47
+ for (const row of sidechainRows) {
48
+ const sidechainCap = Math.round(row.sidechain_cap_raw ?? 0);
49
+ const totalCap = totalByWorkspace.get(row.workspace_id) ?? 0;
50
+ const share = totalCap > 0 ? sidechainCap / totalCap : 0;
51
+ const zeroUtcCap = Math.round(row.zero_utc_cap_raw ?? 0);
52
+ const zeroUtcShare = sidechainCap > 0 ? zeroUtcCap / sidechainCap : 0;
53
+ if (share < D9_SIDECHAIN_SHARE || sidechainCap < D9_SIDECHAIN_ABS_CAP_TOKENS)
54
+ continue;
55
+ fired.push({
56
+ scopeKey: `D9|${row.workspace_id}`,
57
+ category: "SESSION_HYGIENE",
58
+ scope_workspace_id: row.workspace_id,
59
+ lever: "Tighten subagent briefs and cap fan-out in plan mode; kill idle background sessions and /clear idle terminals. Directional — much fan-out is justified; review before cutting.",
60
+ target_metric: "sidechain_cap_weighted_share",
61
+ // Directional: no crisp $ headline (research B). Estimate lives in the formula inputs.
62
+ modeled_savings_u_per_wk: null,
63
+ modeled_formula: d9Formula(sidechainCap, D9_UNPRODUCTIVE_FRACTION),
64
+ evidence: {
65
+ title: `Review background fan-out: ${Math.round(share * 100)}% cap-weighted sidechain`,
66
+ workspace_id: row.workspace_id,
67
+ sidechain_cap_weighted_tokens: sidechainCap,
68
+ total_cap_weighted_tokens: totalCap,
69
+ sidechain_share: Number(share.toFixed(4)),
70
+ sidechain_turn_count: row.sidechain_turns,
71
+ zero_user_turn_sidechain_cap: zeroUtcCap,
72
+ zero_user_turn_sidechain_share: Number(zeroUtcShare.toFixed(4)),
73
+ zero_user_turn_session_count: row.zero_utc_sessions,
74
+ sidechain_share_threshold: D9_SIDECHAIN_SHARE,
75
+ sidechain_abs_cap_tokens_threshold: D9_SIDECHAIN_ABS_CAP_TOKENS,
76
+ unproductive_fraction: D9_UNPRODUCTIVE_FRACTION,
77
+ thresholds_unvalidated: true,
78
+ directional: true,
79
+ linkage_note: "user_turn_count is now ingested per migration 009, so the zero-user-turn facet is available; parent-child subagent linkage remains heuristic (issue #32175)",
80
+ steps: [
81
+ "Review the highest-volume background/subagent sessions in this workspace",
82
+ "Tighten subagent briefs so fan-out returns tight, scoped results",
83
+ "Cap fan-out in plan mode; kill idle background sessions and /clear idle terminals",
84
+ ],
85
+ },
86
+ });
87
+ }
88
+ if (fired.length === 0) {
89
+ return {
90
+ fired: [],
91
+ status: "INACTIVE",
92
+ note: "no workspace met the sidechain-share threshold",
93
+ };
94
+ }
95
+ return {
96
+ fired,
97
+ status: "ACTIVE",
98
+ note: `${fired.length} workspace(s) with background-heavy fan-out`,
99
+ };
100
+ },
101
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * src/detector/engine.ts — deterministic orchestration + upsert.
3
+ *
4
+ * runDetectors(db, ctx) evaluates every registered detector under the injected
5
+ * clock and upserts fired triggers into `recommendations`. Determinism (§2.3):
6
+ * rec_id is a pure function of detector + scope + a stable evidence-window key
7
+ * (never a random uuid); created_at is the injected `now`; the ON CONFLICT path
8
+ * refreshes only derived fields and preserves lifecycle columns (state/adopted_at/
9
+ * dismissed_until), so a DISMISSED rec in cool-down is never resurfaced.
10
+ */
11
+ import * as crypto from "node:crypto";
12
+ import { DETECTORS, UNEVALUATED_DETECTORS } from "./registry.js";
13
+ const WINDOW_DAYS = 7;
14
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
15
+ /** Build the trailing-7d context anchored at the injected `now`. */
16
+ export function buildContext(now) {
17
+ const toIso = now.toISOString();
18
+ const fromIso = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY).toISOString();
19
+ return { now, fromIso, toIso };
20
+ }
21
+ /** Deterministic rec_id: rec-<detector_id>-<scope>-<sha1(scopeKey)[:16]>. */
22
+ function recId(detectorId, scopeWorkspaceId, scopeKey) {
23
+ const scope = scopeWorkspaceId ?? "global";
24
+ const hash = crypto.createHash("sha1").update(scopeKey).digest("hex").slice(0, 16);
25
+ return `rec-${detectorId}-${scope}-${hash}`;
26
+ }
27
+ const UPSERT_SQL = `
28
+ INSERT INTO recommendations
29
+ (rec_id, provenance, detector_id, analysis_run_id, category, scope_workspace_id,
30
+ lever, modeled_savings_u_per_wk, modeled_formula_json, evidence_json, target_metric,
31
+ state, created_at, adopted_at, dismissed_until)
32
+ VALUES (?, 'RULE', ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'PROPOSED', ?, NULL, NULL)
33
+ ON CONFLICT(rec_id) DO UPDATE SET
34
+ modeled_savings_u_per_wk = excluded.modeled_savings_u_per_wk,
35
+ modeled_formula_json = excluded.modeled_formula_json,
36
+ evidence_json = excluded.evidence_json,
37
+ target_metric = excluded.target_metric
38
+ `;
39
+ function upsertFired(db, detectorId, fired, nowIso) {
40
+ db.prepare(UPSERT_SQL).run(recId(detectorId, fired.scope_workspace_id, fired.scopeKey), detectorId, fired.category, fired.scope_workspace_id, fired.lever, fired.modeled_savings_u_per_wk, JSON.stringify(fired.modeled_formula), JSON.stringify(fired.evidence), fired.target_metric, nowIso);
41
+ }
42
+ /**
43
+ * Evaluate all registered detectors and upsert fired rows. Returns the live
44
+ * status list (used for the detectors[] strip). Read-only w.r.t. ingest tables;
45
+ * writes only `recommendations`.
46
+ */
47
+ export function runDetectors(db, ctx) {
48
+ const nowIso = ctx.now.toISOString();
49
+ const statuses = [];
50
+ const tx = db.transaction(() => {
51
+ for (const detector of DETECTORS) {
52
+ const outcome = detector.evaluate(db, ctx);
53
+ for (const fired of outcome.fired) {
54
+ upsertFired(db, detector.id, fired, nowIso);
55
+ }
56
+ statuses.push({
57
+ detector_id: detector.id,
58
+ name: detector.name,
59
+ status: outcome.status,
60
+ note: outcome.note,
61
+ });
62
+ }
63
+ });
64
+ tx();
65
+ for (const d of UNEVALUATED_DETECTORS) {
66
+ statuses.push({ detector_id: d.id, name: d.name, status: d.status, note: d.note });
67
+ }
68
+ return statuses;
69
+ }
70
+ /**
71
+ * Evaluate detector statuses WITHOUT writing (read path for the query layer's
72
+ * detectors[] strip). Mirrors runDetectors but performs no upsert.
73
+ */
74
+ export function detectorStatuses(db, ctx) {
75
+ const statuses = DETECTORS.map((detector) => {
76
+ const outcome = detector.evaluate(db, ctx);
77
+ return {
78
+ detector_id: detector.id,
79
+ name: detector.name,
80
+ status: outcome.status,
81
+ note: outcome.note,
82
+ };
83
+ });
84
+ for (const d of UNEVALUATED_DETECTORS) {
85
+ statuses.push({ detector_id: d.id, name: d.name, status: d.status, note: d.note });
86
+ }
87
+ return statuses;
88
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * src/detector/index.ts — DetectorEngine public surface.
3
+ *
4
+ * runDetectors(db, { now }) is the post-ingest entrypoint (wired via the ingest
5
+ * post-ingest hook). It MUST receive the ingestor's injected clock so rec_id /
6
+ * created_at windows are deterministic (NFR-107 / Review F4) — never new Date().
7
+ */
8
+ import { buildContext, detectorStatuses, runDetectors as runDetectorsCore } from "./engine.js";
9
+ export { buildContext } from "./engine.js";
10
+ /** Evaluate + upsert fired recommendations. Returns the live detector status list. */
11
+ export function runDetectors(db, opts = {}) {
12
+ return runDetectorsCore(db, buildContext(opts.now ?? new Date()));
13
+ }
14
+ /** Live detector statuses without writing — for the query layer's detectors[] strip. */
15
+ export function getDetectorStatuses(db, opts = {}) {
16
+ return detectorStatuses(db, buildContext(opts.now ?? new Date()));
17
+ }