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.
- package/LICENSE +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- 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
|
+
}
|