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,104 @@
1
+ /**
2
+ * src/query/api/cost-per-success.ts — observed cost-per-delivery proxies.
3
+ *
4
+ * The merged-PR ratio intentionally uses lifecycle state='MERGED' only;
5
+ * checks_conclusion is not required.
6
+ */
7
+ import { buildResponse } from "../envelope.js";
8
+ /**
9
+ * Derive state-based delivery proxy metrics for the half-open [from, to) window.
10
+ */
11
+ export function getCostPerSuccess(db, workspaceId, from, to) {
12
+ const workItemFilter = workspaceId === null ? "" : " AND wi.workspace_id = ?";
13
+ const sessionFilter = workspaceId === null ? "" : " AND s.workspace_id = ?";
14
+ const params = workspaceId === null
15
+ ? [from, to, from, to, from, to]
16
+ : [
17
+ from,
18
+ to,
19
+ workspaceId,
20
+ from,
21
+ to,
22
+ workspaceId,
23
+ workspaceId,
24
+ from,
25
+ to,
26
+ workspaceId,
27
+ workspaceId,
28
+ ];
29
+ const row = db
30
+ .prepare(`WITH merged_work_items AS (
31
+ SELECT wi.work_item_id
32
+ FROM work_items wi
33
+ WHERE wi.state = 'MERGED'
34
+ AND wi.merged_at >= ? AND wi.merged_at < ?${workItemFilter}
35
+ ),
36
+ closed_unmerged_work_items AS (
37
+ SELECT wi.work_item_id
38
+ FROM work_items wi
39
+ WHERE wi.state = 'CLOSED'
40
+ AND wi.closed_at >= ? AND wi.closed_at < ?${workItemFilter}
41
+ ),
42
+ merged_session_costs AS (
43
+ SELECT DISTINCT s.session_id, s.cost_equiv_u
44
+ FROM sessions s
45
+ JOIN session_work_links swl ON swl.session_id = s.session_id
46
+ JOIN merged_work_items mwi ON mwi.work_item_id = swl.work_item_id
47
+ WHERE 1 = 1${sessionFilter}
48
+ ),
49
+ in_window_sessions AS (
50
+ SELECT s.session_id, s.cost_equiv_u
51
+ FROM sessions s
52
+ WHERE s.first_turn_at >= ? AND s.first_turn_at < ?${sessionFilter}
53
+ ),
54
+ commit_sessions AS (
55
+ SELECT iws.session_id, iws.cost_equiv_u
56
+ FROM in_window_sessions iws
57
+ WHERE EXISTS (
58
+ SELECT 1
59
+ FROM tool_events te
60
+ WHERE te.session_id = iws.session_id
61
+ AND te.commit_sha IS NOT NULL
62
+ )
63
+ )
64
+ SELECT
65
+ (SELECT COUNT(*) FROM merged_work_items) AS merged_pr_count,
66
+ (SELECT COUNT(*) FROM closed_unmerged_work_items) AS closed_unmerged_count,
67
+ CASE WHEN (SELECT COUNT(*) FROM merged_work_items) = 0 THEN NULL
68
+ ELSE (SELECT COALESCE(SUM(cost_equiv_u), 0) FROM merged_session_costs) * 1.0
69
+ / (SELECT COUNT(*) FROM merged_work_items)
70
+ END AS cost_per_merged_pr_u,
71
+ (SELECT COUNT(*) FROM commit_sessions) AS commit_session_count,
72
+ CASE WHEN (SELECT COUNT(*) FROM commit_sessions) = 0 THEN NULL
73
+ ELSE (SELECT COALESCE(SUM(cost_equiv_u), 0) FROM commit_sessions) * 1.0
74
+ / (SELECT COUNT(*) FROM commit_sessions)
75
+ END AS cost_per_commit_session_u,
76
+ CASE WHEN (SELECT COUNT(*) FROM in_window_sessions) = 0 THEN NULL
77
+ ELSE (SELECT COUNT(*)
78
+ FROM in_window_sessions iws
79
+ WHERE EXISTS (
80
+ SELECT 1
81
+ FROM session_work_links swl
82
+ JOIN work_items wi ON wi.work_item_id = swl.work_item_id
83
+ WHERE swl.session_id = iws.session_id${workItemFilter}
84
+ )) * 100.0 / (SELECT COUNT(*) FROM in_window_sessions)
85
+ END AS linkage_coverage_pct`)
86
+ .get(...params);
87
+ const data = {
88
+ ...row,
89
+ n: row.merged_pr_count,
90
+ window: { from, to },
91
+ };
92
+ return buildResponse(data, {
93
+ claim_kind: "OBS_PROXY",
94
+ n: data.merged_pr_count,
95
+ window: { from, to },
96
+ qualification: {
97
+ provisional_excluded: false,
98
+ unpriced_turns: 0,
99
+ claim_kinds_count: 1,
100
+ note: "Directional (OBS_PROXY): survivorship bias (heavy-spend sessions that never open a PR are invisible); reviewer-dependence (merge is a human decision, not a quality guarantee); linkage-coverage cap (only linkage_coverage_pct% of in-window sessions are linked to a PR, so unlinked spend is excluded). cost_per_merged_pr_u uses lifecycle attribution: each merged PR carries the full cost of every linked session whenever it ran, so narrowing the window changes the PR population, not the per-PR cost.",
101
+ },
102
+ ...(workspaceId === null ? {} : { drilldown_ids: { workspace_id: workspaceId } }),
103
+ });
104
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * src/query/api/delivery.ts — query-only L2a delivery metrics.
3
+ *
4
+ * Delivery is inferred from the presence of a harvested commit SHA; SHA values
5
+ * never leave the database through this query surface.
6
+ */
7
+ import { buildResponse } from "../envelope.js";
8
+ /**
9
+ * Derive delivery proxy metrics for sessions with turns in [from, to).
10
+ *
11
+ * Reconciled turns only contribute to money figures. Sessions without tool
12
+ * events still count in the total and total-spend denominator, but are neither
13
+ * commit nor abandoned sessions.
14
+ */
15
+ export function getDeliveryMetrics(db, opts) {
16
+ const workspaceFilter = opts.workspaceId === null ? "" : " AND t.workspace_id = ?";
17
+ const params = opts.workspaceId === null ? [opts.from, opts.to] : [opts.from, opts.to, opts.workspaceId];
18
+ const row = db
19
+ .prepare(`WITH in_window_turns AS (
20
+ SELECT t.session_id, t.workspace_id, t.cost_equiv_u, t.provisional
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
+ session_flags AS (
29
+ SELECT iws.session_id,
30
+ EXISTS (
31
+ SELECT 1
32
+ FROM tool_events te
33
+ WHERE te.session_id = iws.session_id
34
+ AND te.commit_sha IS NOT NULL
35
+ ) AS is_commit_session,
36
+ EXISTS (
37
+ SELECT 1
38
+ FROM tool_events te
39
+ WHERE te.session_id = iws.session_id
40
+ AND te.tool_name IN ('Bash', 'Write', 'Edit', 'NotebookEdit')
41
+ )
42
+ AND NOT EXISTS (
43
+ SELECT 1
44
+ FROM tool_events te
45
+ WHERE te.session_id = iws.session_id
46
+ AND te.commit_sha IS NOT NULL
47
+ ) AS is_abandoned_session
48
+ FROM in_window_sessions iws
49
+ ),
50
+ session_spend AS (
51
+ SELECT session_id,
52
+ COALESCE(SUM(CASE WHEN provisional = 0 THEN cost_equiv_u END), 0) AS spend_u
53
+ FROM in_window_turns
54
+ GROUP BY session_id
55
+ )
56
+ SELECT COALESCE(SUM(sf.is_commit_session), 0) AS commit_session_count,
57
+ COUNT(sf.session_id) AS total_session_count,
58
+ CASE WHEN COUNT(sf.session_id) = 0 THEN NULL
59
+ ELSE SUM(sf.is_commit_session) * 1.0 / COUNT(sf.session_id)
60
+ END AS commit_session_rate,
61
+ CASE WHEN SUM(sf.is_commit_session) = 0 THEN NULL
62
+ ELSE SUM(CASE WHEN sf.is_commit_session THEN ss.spend_u ELSE 0 END) * 1.0
63
+ / SUM(sf.is_commit_session)
64
+ END AS spend_per_commit_session_u,
65
+ COALESCE(SUM(CASE WHEN sf.is_abandoned_session THEN ss.spend_u ELSE 0 END), 0)
66
+ AS abandoned_spend_u,
67
+ CASE WHEN COALESCE(SUM(ss.spend_u), 0) = 0 THEN NULL
68
+ ELSE SUM(CASE WHEN sf.is_abandoned_session THEN ss.spend_u ELSE 0 END) * 1.0
69
+ / SUM(ss.spend_u)
70
+ END AS abandoned_spend_share
71
+ FROM session_flags sf
72
+ JOIN session_spend ss USING (session_id)`)
73
+ .get(...params);
74
+ const data = {
75
+ ...row,
76
+ from: opts.from,
77
+ to: opts.to,
78
+ workspace_id: opts.workspaceId,
79
+ };
80
+ return buildResponse(data, {
81
+ claim_kind: "OBS_PROXY",
82
+ n: data.commit_session_count,
83
+ window: { from: opts.from, to: opts.to },
84
+ qualification: {
85
+ provisional_excluded: true,
86
+ unpriced_turns: 0,
87
+ claim_kinds_count: 1,
88
+ note: "Commit SHA presence is an observed delivery proxy.",
89
+ },
90
+ ...(opts.workspaceId === null ? {} : { drilldown_ids: { workspace_id: opts.workspaceId } }),
91
+ });
92
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * src/query/api/effectiveness.ts — EF1 delivery-path and abandoned-spend metrics.
3
+ *
4
+ * These functions surface per-session delivery signals (turns to first commit,
5
+ * deep-abandoned classification) and split the RV9a abandoned spend by session
6
+ * depth. SEC-101: only counts, durations, booleans, and microUSD integers are
7
+ * returned — no transcript content, commit SHAs, or tool names.
8
+ */
9
+ import { buildResponse } from "../envelope.js";
10
+ // ---------------------------------------------------------------------------
11
+ // computeSessionDelivery
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Compute EF1 delivery signals for one session.
15
+ *
16
+ * firstCommitTs is the MIN(ts) from tool_events where commit_sha IS NOT NULL.
17
+ * turns_to_first_commit counts non-sidechain turns (is_sidechain=0) with ts <= firstCommitTs.
18
+ * deep_abandoned is true only for RECONCILED sessions with >= 10 user turns and no commit.
19
+ */
20
+ export function computeSessionDelivery(db, sessionId) {
21
+ const commitRow = db
22
+ .prepare(`SELECT MIN(ts) AS first_commit_ts
23
+ FROM tool_events
24
+ WHERE session_id = ? AND commit_sha IS NOT NULL`)
25
+ .get(sessionId);
26
+ const firstCommitTs = commitRow.first_commit_ts;
27
+ let turns_to_first_commit = null;
28
+ if (firstCommitTs !== null) {
29
+ const countRow = db
30
+ .prepare(`SELECT COUNT(*) AS n
31
+ FROM turns
32
+ WHERE session_id = ? AND is_sidechain = 0 AND ts <= ?`)
33
+ .get(sessionId, firstCommitTs);
34
+ turns_to_first_commit = countRow.n;
35
+ }
36
+ const sessRow = db
37
+ .prepare("SELECT user_turn_count, state FROM sessions WHERE session_id = ?")
38
+ .get(sessionId);
39
+ const user_turn_count = sessRow?.user_turn_count ?? 0;
40
+ const state = sessRow?.state ?? "";
41
+ const deep_abandoned = user_turn_count >= 10 && firstCommitTs === null && state === "RECONCILED";
42
+ return { turns_to_first_commit, deep_abandoned };
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // getAbandonedSpendSplit
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Split the RV9a abandoned spend into deep (>= 10 user turns) and early (< 10).
49
+ *
50
+ * Uses the exact same abandoned-session definition and window as
51
+ * getDeliveryMetrics so that deep + early === getDeliveryMetrics(...).abandoned_spend_u.
52
+ * Provisional turns are excluded from spend exactly as delivery does.
53
+ */
54
+ export function getAbandonedSpendSplit(db, opts) {
55
+ const workspaceFilter = opts.workspaceId === null ? "" : " AND t.workspace_id = ?";
56
+ const params = opts.workspaceId === null ? [opts.from, opts.to] : [opts.from, opts.to, opts.workspaceId];
57
+ const row = db
58
+ .prepare(`WITH in_window_turns AS (
59
+ SELECT t.session_id, t.workspace_id, t.cost_equiv_u, t.provisional
60
+ FROM turns t
61
+ WHERE t.ts >= ? AND t.ts < ?${workspaceFilter}
62
+ ),
63
+ in_window_sessions AS (
64
+ SELECT DISTINCT session_id, workspace_id
65
+ FROM in_window_turns
66
+ ),
67
+ session_flags AS (
68
+ SELECT iws.session_id,
69
+ EXISTS (
70
+ SELECT 1
71
+ FROM tool_events te
72
+ WHERE te.session_id = iws.session_id
73
+ AND te.commit_sha IS NOT NULL
74
+ ) AS is_commit_session,
75
+ EXISTS (
76
+ SELECT 1
77
+ FROM tool_events te
78
+ WHERE te.session_id = iws.session_id
79
+ AND te.tool_name IN ('Bash', 'Write', 'Edit', 'NotebookEdit')
80
+ )
81
+ AND NOT EXISTS (
82
+ SELECT 1
83
+ FROM tool_events te
84
+ WHERE te.session_id = iws.session_id
85
+ AND te.commit_sha IS NOT NULL
86
+ ) AS is_abandoned_session
87
+ FROM in_window_sessions iws
88
+ ),
89
+ session_spend AS (
90
+ SELECT session_id,
91
+ COALESCE(SUM(CASE WHEN provisional = 0 THEN cost_equiv_u END), 0) AS spend_u
92
+ FROM in_window_turns
93
+ GROUP BY session_id
94
+ )
95
+ SELECT
96
+ COALESCE(SUM(
97
+ CASE WHEN sf.is_abandoned_session AND s.user_turn_count >= 10
98
+ THEN ss.spend_u ELSE 0 END
99
+ ), 0) AS deep_abandoned_spend_u,
100
+ COALESCE(SUM(
101
+ CASE WHEN sf.is_abandoned_session AND s.user_turn_count < 10
102
+ THEN ss.spend_u ELSE 0 END
103
+ ), 0) AS early_abandoned_spend_u
104
+ FROM session_flags sf
105
+ JOIN session_spend ss USING (session_id)
106
+ JOIN sessions s USING (session_id)`)
107
+ .get(...params);
108
+ return {
109
+ deep_abandoned_spend_u: row.deep_abandoned_spend_u,
110
+ early_abandoned_spend_u: row.early_abandoned_spend_u,
111
+ };
112
+ }
113
+ /**
114
+ * Classify all no-commit RECONCILED sessions and return an aggregate proxy.
115
+ *
116
+ * A no-commit session S (state='RECONCILED', no tool_events.commit_sha):
117
+ * - PENDING : now < last_turn_at + windowHours (window not elapsed yet)
118
+ * - UNRESOLVED: matured AND another session S' in the same workspace has
119
+ * first_turn_at in (last_turn_at, last_turn_at + windowHours]
120
+ * - RESOLVED : matured AND no such follow-up session found
121
+ * - EXCLUDED : session has a commit (not counted in no_commit_session_count)
122
+ *
123
+ * Time arithmetic uses Date.parse on ISO strings; Date.now() is never called.
124
+ */
125
+ export function getClosureProxy(db, opts) {
126
+ const windowHours = opts.windowHours ?? 48;
127
+ const nowMs = Date.parse(opts.now);
128
+ const windowMs = windowHours * 3600 * 1000;
129
+ const note = `Directional: a re-open within ${windowHours}h may be unrelated work; burst-working operators will false-flag as unresolved. PENDING until the window elapses.`;
130
+ const workspaceFilter = opts.workspaceId === null ? "" : " AND s.workspace_id = ?";
131
+ const params = opts.workspaceId === null ? [] : [opts.workspaceId];
132
+ const candidates = db
133
+ .prepare(`SELECT s.session_id, s.workspace_id, s.last_turn_at
134
+ FROM sessions s
135
+ WHERE s.state = 'RECONCILED'
136
+ AND s.last_turn_at IS NOT NULL
137
+ AND NOT EXISTS (
138
+ SELECT 1 FROM tool_events te
139
+ WHERE te.session_id = s.session_id AND te.commit_sha IS NOT NULL
140
+ )${workspaceFilter}`)
141
+ .all(...params);
142
+ const emptyProxy = () => ({
143
+ no_commit_session_count: 0,
144
+ resolved_count: 0,
145
+ unresolved_count: 0,
146
+ pending_count: 0,
147
+ resolved_share: null,
148
+ window_hours: windowHours,
149
+ workspace_id: opts.workspaceId,
150
+ });
151
+ if (candidates.length === 0) {
152
+ return buildResponse(emptyProxy(), {
153
+ claim_kind: "EXPERIMENTAL",
154
+ n: 0,
155
+ window: { from: opts.now, to: opts.now },
156
+ qualification: { provisional_excluded: false, unpriced_turns: 0, claim_kinds_count: 1, note },
157
+ ...(opts.workspaceId === null ? {} : { drilldown_ids: { workspace_id: opts.workspaceId } }),
158
+ });
159
+ }
160
+ // Fetch all sessions in the candidate workspaces for the follow-up check.
161
+ const uniqueWsIds = [...new Set(candidates.map((c) => c.workspace_id))];
162
+ const placeholders = uniqueWsIds.map(() => "?").join(",");
163
+ const wsSessions = db
164
+ .prepare(`SELECT session_id, workspace_id, first_turn_at
165
+ FROM sessions
166
+ WHERE workspace_id IN (${placeholders}) AND first_turn_at IS NOT NULL`)
167
+ .all(...uniqueWsIds);
168
+ const byWs = new Map();
169
+ for (const s of wsSessions) {
170
+ const arr = byWs.get(s.workspace_id) ?? [];
171
+ arr.push(s);
172
+ byWs.set(s.workspace_id, arr);
173
+ }
174
+ let resolved_count = 0;
175
+ let unresolved_count = 0;
176
+ let pending_count = 0;
177
+ for (const c of candidates) {
178
+ const lastMs = Date.parse(c.last_turn_at);
179
+ const matured = nowMs >= lastMs + windowMs;
180
+ if (!matured) {
181
+ pending_count++;
182
+ continue;
183
+ }
184
+ const siblings = byWs.get(c.workspace_id) ?? [];
185
+ const reopened = siblings.some((s2) => {
186
+ if (s2.session_id === c.session_id)
187
+ return false;
188
+ const s2Ms = Date.parse(s2.first_turn_at);
189
+ return s2Ms > lastMs && s2Ms <= lastMs + windowMs;
190
+ });
191
+ if (reopened) {
192
+ unresolved_count++;
193
+ }
194
+ else {
195
+ resolved_count++;
196
+ }
197
+ }
198
+ const no_commit_session_count = resolved_count + unresolved_count + pending_count;
199
+ const maturedCount = resolved_count + unresolved_count;
200
+ const resolved_share = maturedCount === 0 ? null : resolved_count / maturedCount;
201
+ const proxy = {
202
+ no_commit_session_count,
203
+ resolved_count,
204
+ unresolved_count,
205
+ pending_count,
206
+ resolved_share,
207
+ window_hours: windowHours,
208
+ workspace_id: opts.workspaceId,
209
+ };
210
+ return buildResponse(proxy, {
211
+ claim_kind: "EXPERIMENTAL",
212
+ n: no_commit_session_count,
213
+ window: { from: opts.now, to: opts.now },
214
+ qualification: { provisional_excluded: false, unpriced_turns: 0, claim_kinds_count: 1, note },
215
+ ...(opts.workspaceId === null ? {} : { drilldown_ids: { workspace_id: opts.workspaceId } }),
216
+ });
217
+ }
218
+ /**
219
+ * Per-session closure status for drill-down.
220
+ *
221
+ * Same logic as getClosureProxy but for a single session.
222
+ * Time arithmetic uses Date.parse on ISO strings; Date.now() is never called.
223
+ */
224
+ export function getSessionClosureStatus(db, sessionId, opts) {
225
+ const windowHours = opts.windowHours ?? 48;
226
+ const windowMs = windowHours * 3600 * 1000;
227
+ // A session with any commit is EXCLUDED from the no-commit classification.
228
+ const hasCommit = db
229
+ .prepare("SELECT 1 FROM tool_events WHERE session_id = ? AND commit_sha IS NOT NULL LIMIT 1")
230
+ .get(sessionId);
231
+ if (hasCommit)
232
+ return "EXCLUDED";
233
+ const sess = db
234
+ .prepare("SELECT workspace_id, last_turn_at, state FROM sessions WHERE session_id = ?")
235
+ .get(sessionId);
236
+ // If the session is missing or not a RECONCILED no-commit session, it cannot mature.
237
+ if (!sess || sess.state !== "RECONCILED" || sess.last_turn_at === null)
238
+ return "PENDING";
239
+ const nowMs = Date.parse(opts.now);
240
+ const lastMs = Date.parse(sess.last_turn_at);
241
+ const matured = nowMs >= lastMs + windowMs;
242
+ if (!matured)
243
+ return "PENDING";
244
+ const followups = db
245
+ .prepare(`SELECT session_id, first_turn_at FROM sessions
246
+ WHERE workspace_id = ? AND session_id != ?
247
+ AND first_turn_at IS NOT NULL AND first_turn_at > ?`)
248
+ .all(sess.workspace_id, sessionId, sess.last_turn_at);
249
+ const reopened = followups.some((s2) => {
250
+ const s2Ms = Date.parse(s2.first_turn_at);
251
+ return s2Ms > lastMs && s2Ms <= lastMs + windowMs;
252
+ });
253
+ return reopened ? "UNRESOLVED" : "RESOLVED";
254
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * src/query/api/efficiency-headroom.ts — BM2 efficiency headroom.
3
+ *
4
+ * "How far am I from my own modeled ceiling?" — the sum of modeled weekly
5
+ * savings across the OPEN recommendations (state PROPOSED or ADOPTED, non-null
6
+ * savings) expressed as a fraction of the trailing-7d actual spend.
7
+ *
8
+ * Honesty contract: this is a MODELED ceiling built from unvalidated per-detector
9
+ * fractions (e.g. D6's avoidance fraction is UNVALIDATED). It is "modeled headroom
10
+ * — if every open recommendation were applied", never a "$X wasted" headline
11
+ * (INT-5). The frozen envelope has no MODELED ClaimKind, so it rides as
12
+ * EXPERIMENTAL with the caveat carried in the qualification note.
13
+ *
14
+ * FR-REC-103 is respected: this is a SEPARATE surface from listRecommendations,
15
+ * which still never sums modeled savings into an "achieved" total.
16
+ *
17
+ * SEC-101: sums of existing aggregates (recommendations.modeled_savings_u_per_wk,
18
+ * turns.cost_equiv_u) only — no transcript content.
19
+ */
20
+ import { buildResponse } from "../envelope.js";
21
+ /**
22
+ * Compute the efficiency-headroom ratio over [from, to). The headroom numerator
23
+ * is window-independent (it sums the current open recommendation set); the
24
+ * denominator is the actual spend in the window (default trailing 7d).
25
+ */
26
+ export function getEfficiencyHeadroom(db, opts) {
27
+ const { from, to } = opts;
28
+ const recs = db
29
+ .prepare(`SELECT COALESCE(SUM(modeled_savings_u_per_wk), 0) AS sum_u,
30
+ COUNT(modeled_savings_u_per_wk) AS with_savings,
31
+ COUNT(*) AS open_total
32
+ FROM recommendations
33
+ WHERE state IN ('PROPOSED', 'ADOPTED')`)
34
+ .get();
35
+ const spend = db
36
+ .prepare(`SELECT COALESCE(SUM(cost_equiv_u), 0) AS actual_u
37
+ FROM turns
38
+ WHERE ts >= ? AND ts < ? AND provisional = 0`)
39
+ .get(from, to);
40
+ const headroom_u_per_wk = recs.sum_u ?? 0;
41
+ const actual_u_per_wk = spend.actual_u;
42
+ const openRecsExist = recs.open_total > 0;
43
+ // Null when: no spend (avoid /0 → ∞/NaN), or open recs exist but none carries a
44
+ // savings figure (an all-null modeled set is not a defensible ceiling).
45
+ let headroom_pct;
46
+ if (actual_u_per_wk === 0) {
47
+ headroom_pct = null;
48
+ }
49
+ else if (openRecsExist && recs.with_savings === 0) {
50
+ headroom_pct = null;
51
+ }
52
+ else {
53
+ headroom_pct = headroom_u_per_wk / actual_u_per_wk;
54
+ }
55
+ const data = {
56
+ headroom_u_per_wk,
57
+ actual_u_per_wk,
58
+ headroom_pct,
59
+ open_rec_count: recs.with_savings,
60
+ from,
61
+ to,
62
+ };
63
+ return buildResponse(data, {
64
+ claim_kind: "EXPERIMENTAL",
65
+ n: recs.with_savings,
66
+ window: { from, to },
67
+ qualification: {
68
+ provisional_excluded: true,
69
+ unpriced_turns: 0,
70
+ claim_kinds_count: 1,
71
+ note: "Modeled headroom — if every open recommendation were applied; built from unvalidated per-detector fractions.",
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * src/query/api/headroom-trend.ts — percent-native headroom over time.
3
+ *
4
+ * This is an additive query-side view over the calibrated limit and the two
5
+ * cap-read coefficient regimes. It deliberately does not introduce an
6
+ * absolute-cap denominator: the configured limit is the only denominator.
7
+ */
8
+ import { DEFAULT_CAP_READ_COEFF } from "../cap-weighted.js";
9
+ import { getQueryDb } from "../db-context.js";
10
+ import { buildResponse } from "../envelope.js";
11
+ import { capWeightedByBucket } from "../trends.js";
12
+ const UPPER_BOUND_COEFF = 1.0;
13
+ const CAVEAT_NOTE = "Percent-native headroom (no absolute-cap denominator); cap coefficient is unverified.";
14
+ const PRESET_DAYS = {
15
+ "24h": 1,
16
+ "7d": 7,
17
+ "30d": 30,
18
+ };
19
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
20
+ function configGet(db, key) {
21
+ const row = db.prepare("SELECT value FROM user_config WHERE key = ?").get(key);
22
+ return row?.value ?? null;
23
+ }
24
+ function resolveWindow(filter, now = new Date()) {
25
+ const nowIso = now.toISOString();
26
+ if (filter.preset !== undefined) {
27
+ const from = new Date(now.getTime() - PRESET_DAYS[filter.preset] * MS_PER_DAY).toISOString();
28
+ return { from, to: nowIso, preset: filter.preset };
29
+ }
30
+ if (filter.from !== undefined && filter.to !== undefined) {
31
+ return { from: filter.from, to: filter.to };
32
+ }
33
+ if (filter.from !== undefined) {
34
+ return { from: filter.from, to: nowIso };
35
+ }
36
+ if (filter.to !== undefined) {
37
+ const from = new Date(new Date(filter.to).getTime() - 7 * MS_PER_DAY).toISOString();
38
+ return { from, to: filter.to };
39
+ }
40
+ const from = new Date(now.getTime() - 7 * MS_PER_DAY).toISOString();
41
+ return { from, to: nowIso, preset: "7d" };
42
+ }
43
+ function readLimitTokens(db) {
44
+ const limitRaw = configGet(db, "limit_tokens");
45
+ if (limitRaw === null)
46
+ return null;
47
+ const limit = Number(limitRaw);
48
+ return Number.isFinite(limit) && limit > 0 ? limit : null;
49
+ }
50
+ /**
51
+ * Return percent-native headroom against the calibrated limit for each trend
52
+ * bucket, using both the 0.1× headline and 1.0× upper-bound cap regimes.
53
+ */
54
+ export function getHeadroomTrend(filter, bucket = "day", workspaceId) {
55
+ const db = getQueryDb();
56
+ const window = resolveWindow(filter);
57
+ const limitTokens = readLimitTokens(db);
58
+ if (limitTokens === null) {
59
+ return buildResponse({ state: "NO_LIMIT" }, {
60
+ claim_kind: "LIST_EQUIV",
61
+ n: 0,
62
+ window,
63
+ qualification: {
64
+ provisional_excluded: true,
65
+ unpriced_turns: 0,
66
+ claim_kinds_count: 1,
67
+ note: CAVEAT_NOTE,
68
+ },
69
+ });
70
+ }
71
+ const headlineRows = capWeightedByBucket(db, window.from, window.to, bucket, DEFAULT_CAP_READ_COEFF, workspaceId);
72
+ const upperRows = capWeightedByBucket(db, window.from, window.to, bucket, UPPER_BOUND_COEFF, workspaceId);
73
+ const headlineByBucket = new Map(headlineRows.map((row) => [row.bucket, row]));
74
+ const upperByBucket = new Map(upperRows.map((row) => [row.bucket, row]));
75
+ const bucketKeys = new Set([...headlineByBucket.keys(), ...upperByBucket.keys()]);
76
+ const points = [...bucketKeys].sort().map((bucketKey) => {
77
+ const capWeightedHeadline = headlineByBucket.get(bucketKey)?.cap_weighted_tokens ?? 0;
78
+ const capWeightedUpper = upperByBucket.get(bucketKey)?.cap_weighted_tokens ?? 0;
79
+ return {
80
+ bucket: bucketKey,
81
+ headroom_headline: (limitTokens - capWeightedHeadline) / limitTokens,
82
+ headroom_upper: (limitTokens - capWeightedUpper) / limitTokens,
83
+ cap_weighted_headline: capWeightedHeadline,
84
+ cap_weighted_upper: capWeightedUpper,
85
+ };
86
+ });
87
+ return buildResponse({
88
+ state: "OK",
89
+ bucket,
90
+ points,
91
+ cap_read_coeff_headline: DEFAULT_CAP_READ_COEFF,
92
+ cap_read_coeff_upper: UPPER_BOUND_COEFF,
93
+ coefficient_unverified: true,
94
+ }, {
95
+ claim_kind: "LIST_EQUIV",
96
+ n: points.length,
97
+ window,
98
+ qualification: {
99
+ provisional_excluded: true,
100
+ unpriced_turns: 0,
101
+ claim_kinds_count: 1,
102
+ note: CAVEAT_NOTE,
103
+ },
104
+ });
105
+ }