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,75 @@
1
+ /** Runtime configuration for the local context-budget hook. */
2
+ import { isHookInstalled } from "../../hook/install.js";
3
+ import { getQueryDb } from "../db-context.js";
4
+ import { buildResponse } from "../envelope.js";
5
+ export const DEFAULT_HOOK_CONFIG = {
6
+ context_window: 200_000,
7
+ soft_pct: 0.6,
8
+ hard_pct: 0.8,
9
+ stale_s: 300,
10
+ d7_fail_count: 3,
11
+ d7_window_turns: 10,
12
+ d9_idle_seconds: 1800,
13
+ };
14
+ const CONFIG_PREFIX = "hook_config.";
15
+ const CONFIG_KEYS = Object.keys(DEFAULT_HOOK_CONFIG);
16
+ function configKey(key) {
17
+ return `${CONFIG_PREFIX}${key}`;
18
+ }
19
+ function isValidConfig(config) {
20
+ return (Number.isFinite(config.context_window) &&
21
+ config.context_window > 0 &&
22
+ Number.isFinite(config.soft_pct) &&
23
+ config.soft_pct > 0 &&
24
+ Number.isFinite(config.hard_pct) &&
25
+ config.hard_pct > config.soft_pct &&
26
+ config.hard_pct <= 1 &&
27
+ Number.isFinite(config.stale_s) &&
28
+ config.stale_s > 0 &&
29
+ Number.isInteger(config.d7_fail_count) &&
30
+ config.d7_fail_count >= 3 &&
31
+ Number.isInteger(config.d7_window_turns) &&
32
+ config.d7_window_turns > 0 &&
33
+ Number.isInteger(config.d9_idle_seconds) &&
34
+ config.d9_idle_seconds > 0);
35
+ }
36
+ /** Read each dedicated user_config key, degrading corrupt values to defaults. */
37
+ export function readHookConfig(db) {
38
+ const read = db.prepare("SELECT value FROM user_config WHERE key = ?");
39
+ const config = { ...DEFAULT_HOOK_CONFIG };
40
+ for (const key of CONFIG_KEYS) {
41
+ const row = read.get(configKey(key));
42
+ if (row?.value === null || row?.value === undefined)
43
+ continue;
44
+ const value = Number(row.value);
45
+ if (Number.isFinite(value))
46
+ config[key] = value;
47
+ }
48
+ return isValidConfig(config) ? config : { ...DEFAULT_HOOK_CONFIG };
49
+ }
50
+ export function getHookConfig() {
51
+ return buildResponse({ ...readHookConfig(getQueryDb()), installed: isHookInstalled() }, { claim_kind: "N_A", n: 1 });
52
+ }
53
+ /** Validate and atomically persist a partial update to dedicated config keys. */
54
+ export function updateHookConfig(update) {
55
+ if (update === null || typeof update !== "object" || Array.isArray(update)) {
56
+ throw new Error("Invalid hook config");
57
+ }
58
+ for (const key of Object.keys(update)) {
59
+ if (!CONFIG_KEYS.includes(key))
60
+ throw new Error("Invalid hook config key");
61
+ }
62
+ const db = getQueryDb();
63
+ const next = { ...readHookConfig(db), ...update };
64
+ if (!isValidConfig(next))
65
+ throw new Error("Invalid hook config");
66
+ const write = db.prepare(`INSERT INTO user_config (key, value, updated_at) VALUES (?, ?, ?)
67
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`);
68
+ db.transaction(() => {
69
+ for (const key of CONFIG_KEYS) {
70
+ if (update[key] !== undefined)
71
+ write.run(configKey(key), String(next[key]), new Date().toISOString());
72
+ }
73
+ })();
74
+ return buildResponse(next, { claim_kind: "N_A", n: 1 });
75
+ }
@@ -0,0 +1,8 @@
1
+ /** Local-only wrappers for the Claude Code hook installer. */
2
+ import { installHook, uninstallHook } from "../../hook/install.js";
3
+ export function installHookRoute() {
4
+ return installHook();
5
+ }
6
+ export function uninstallHookRoute() {
7
+ return uninstallHook();
8
+ }
@@ -0,0 +1,17 @@
1
+ import { getQueryDb } from "../db-context.js";
2
+ import { hotSessionsByCost } from "../spend.js";
3
+ import { getSessionSpendPercentile } from "./self-percentiles.js";
4
+ export function getHotSessions(window, limit = 20) {
5
+ const db = getQueryDb();
6
+ // One query for the ranked rows, then one cheap percentile lookup per row
7
+ // (<= limit rows, so no N-per-row HTTP calls). Reuses the per-session query
8
+ // so the two BM3 surfaces stay identical.
9
+ return hotSessionsByCost(db, limit, window).map((row) => {
10
+ const percentile = getSessionSpendPercentile(db, row.session_id);
11
+ return {
12
+ ...row,
13
+ spend_percentile: percentile.percentile,
14
+ spend_percentile_n: percentile.n,
15
+ };
16
+ });
17
+ }
@@ -0,0 +1,52 @@
1
+ /** Fail-open idle background-session measurements for the local dashboard. */
2
+ import { capWeightExprSql, resolveCapReadCoeff } from "../cap-weighted.js";
3
+ import { getQueryDb } from "../db-context.js";
4
+ import { buildResponse } from "../envelope.js";
5
+ import { readHookConfig } from "./hook-config.js";
6
+ function response(rows) {
7
+ return buildResponse(rows, { claim_kind: "OBS_PROXY", n: rows.length });
8
+ }
9
+ /**
10
+ * Return idle sessions whose reconciled cap-weighted usage is dominated by
11
+ * sidechain turns. This is display-only metadata: no session control occurs.
12
+ */
13
+ export function getIdleSessions() {
14
+ try {
15
+ const db = getQueryDb();
16
+ const config = readHookConfig(db);
17
+ const expr = capWeightExprSql("t", resolveCapReadCoeff(db));
18
+ const rawRows = db
19
+ .prepare(`SELECT t.session_id AS session_id,
20
+ MAX(t.workspace_id) AS workspace_id,
21
+ MAX(t.ts) AS last_activity_ts,
22
+ SUM(${expr}) AS cap_weighted_raw
23
+ FROM turns t
24
+ WHERE t.provisional = 0
25
+ GROUP BY t.session_id
26
+ HAVING SUM(CASE WHEN t.is_sidechain = 1 THEN ${expr} ELSE 0 END)
27
+ > SUM(${expr}) / 2`)
28
+ .all();
29
+ const now = Date.now();
30
+ const rows = [];
31
+ for (const row of rawRows) {
32
+ const lastActivityMs = Date.parse(row.last_activity_ts);
33
+ if (!Number.isFinite(lastActivityMs))
34
+ continue;
35
+ const idleSeconds = Math.floor(Math.max(0, now - lastActivityMs) / 1_000);
36
+ if (idleSeconds < config.d9_idle_seconds)
37
+ continue;
38
+ rows.push({
39
+ session_id: row.session_id,
40
+ workspace_id: row.workspace_id,
41
+ last_activity_ts: row.last_activity_ts,
42
+ idle_seconds: idleSeconds,
43
+ cap_weighted_tokens: Math.round(row.cap_weighted_raw ?? 0),
44
+ sidechain: true,
45
+ });
46
+ }
47
+ return response(rows);
48
+ }
49
+ catch {
50
+ return response([]);
51
+ }
52
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * src/query/api/index.ts — LocalQueryAPI barrel.
3
+ *
4
+ * The stable read seam between the daemon and the SPA.
5
+ * FROZEN in WP0 — method names and return-type shapes must not change without
6
+ * a plan decision (changing them breaks WP2/WP3/WP4 concurrently in flight).
7
+ *
8
+ * Ownership split (for parallel fan-out per §12):
9
+ * - overview.ts: WP2 (getGlobalOverview, listWorkspaces, getWorkspace,
10
+ * listSessions, getSession, getTurnTimeline)
11
+ * - settings.ts: WP4 (getSettings, updateSettings)
12
+ *
13
+ * The UI never imports sql or better-sqlite3 directly. All data access goes
14
+ * through this barrel.
15
+ */
16
+ export { stubResponse, buildResponse } from "../envelope.js";
17
+ export { getGlobalOverview, listWorkspaces, getWorkspace, listSessions, getSession, getTurnTimeline, listLiveSessions, } from "./overview.js";
18
+ export { getSettings, updateSettings, resetDatabase, calibrateLimit, calibrateBytesPerToken, } from "./settings.js";
19
+ export { listRecommendations, getRecommendationCard, dismissRecommendation, adoptRecommendation, deriveActiveGroups, buildSeededPrompt, } from "./recommendations.js";
20
+ export { startApplyJob, getApplyJob, confirmApplyJob, rollbackApplyJob } from "../../apply/jobs.js";
21
+ export { openTerminalForRec } from "../../apply/open-terminal.js";
22
+ export { listLedger } from "./recommendations-ledger.js";
23
+ export { getSuccessRate, listWorkspaceOutcomes, getWorkspaceOutcomeDetail, getLinkageRate, } from "./outcomes.js";
24
+ export { generateWeeklyReport, listReports, getReport } from "./reports.js";
25
+ export { classifyCacheEfficiency, getFlavorDecomposition, getCacheEfficiency, } from "./spend-flavor.js";
26
+ export { getCacheWriteTrend, detectSpikes } from "./trends.js";
27
+ export { getContextComposition } from "./context-composition.js";
28
+ export { getContextBudget } from "./context-budget.js";
29
+ export { getDeliveryMetrics } from "./delivery.js";
30
+ export { computeSessionDelivery, getAbandonedSpendSplit, getClosureProxy, getSessionClosureStatus, } from "./effectiveness.js";
31
+ export { getSessionDrivers } from "./session-drivers.js";
32
+ export { getLoopGuard } from "./loop-guard.js";
33
+ export { getHookConfig, updateHookConfig } from "./hook-config.js";
34
+ export { installHookRoute, uninstallHookRoute } from "./hook-install.js";
35
+ export { getIdleSessions } from "./idle-sessions.js";
36
+ export { getBurnStatus } from "./burn-status.js";
37
+ export { getSelfChurn } from "./self-churn.js";
38
+ export { getOffloadShare } from "./offload-share.js";
39
+ export { getSessionSpendPercentile, getWeeklySelfPercentile } from "./self-percentiles.js";
40
+ export { getCostPerSuccess } from "./cost-per-success.js";
@@ -0,0 +1,90 @@
1
+ /** Fail-open D7 repeated-failure circuit breaker for the local hook. */
2
+ import { getQueryDb } from "../db-context.js";
3
+ import { buildResponse } from "../envelope.js";
4
+ import { DEFAULT_HOOK_CONFIG, readHookConfig } from "./hook-config.js";
5
+ function response(data) {
6
+ return buildResponse(data, {
7
+ claim_kind: "OBS_PROXY",
8
+ n: data.failing_run_len,
9
+ drilldown_ids: { session_id: data.session_id },
10
+ });
11
+ }
12
+ function okGuard(sessionId, reason, failCountThreshold = DEFAULT_HOOK_CONFIG.d7_fail_count, windowTurns = DEFAULT_HOOK_CONFIG.d7_window_turns) {
13
+ return {
14
+ stage: "ok",
15
+ session_id: sessionId,
16
+ identical_run_len: 0,
17
+ failing_run_len: 0,
18
+ fail_count_threshold: failCountThreshold,
19
+ window_turns: windowTurns,
20
+ reason,
21
+ ts: new Date().toISOString(),
22
+ };
23
+ }
24
+ function isFailingExitClass(exitClass) {
25
+ return exitClass === "TEST_FAIL" || exitClass === "ERROR";
26
+ }
27
+ /**
28
+ * Return a bounded, strictly fail-open D7 guard result. The endpoint returns
29
+ * only measured counts and identifiers; tool input and transcript text remain
30
+ * outside this query and response.
31
+ */
32
+ export function getLoopGuard(sessionId) {
33
+ try {
34
+ const db = getQueryDb();
35
+ const config = readHookConfig(db);
36
+ const events = db
37
+ .prepare(`SELECT event_id, ts, input_hash, exit_class
38
+ FROM tool_events INDEXED BY idx_tool_ts_session_event
39
+ WHERE session_id = ?
40
+ ORDER BY ts DESC, event_id DESC
41
+ LIMIT ?`)
42
+ .all(sessionId, config.d7_window_turns * 8);
43
+ if (events.length === 0) {
44
+ const session = db
45
+ .prepare("SELECT 1 AS present FROM sessions WHERE session_id = ? LIMIT 1")
46
+ .get(sessionId);
47
+ return response(okGuard(sessionId, session === undefined ? "unknown_session" : "no_events", config.d7_fail_count, config.d7_window_turns));
48
+ }
49
+ if (events.some((event) => !Number.isFinite(Date.parse(event.ts)))) {
50
+ return response(okGuard(sessionId, "invalid_timestamp", config.d7_fail_count, config.d7_window_turns));
51
+ }
52
+ const latest = events[0];
53
+ if (latest === undefined) {
54
+ return response(okGuard(sessionId, "no_events", config.d7_fail_count, config.d7_window_turns));
55
+ }
56
+ const inputHash = latest.input_hash;
57
+ const trailingRun = [];
58
+ if (inputHash !== null) {
59
+ for (const event of events) {
60
+ if (event.input_hash !== inputHash)
61
+ break;
62
+ trailingRun.push(event);
63
+ }
64
+ }
65
+ const identicalRunLen = trailingRun.length;
66
+ const failingRunLen = trailingRun.filter((event) => isFailingExitClass(event.exit_class)).length;
67
+ const stage = failingRunLen >= config.d7_fail_count
68
+ ? "block"
69
+ : failingRunLen >= Math.max(2, config.d7_fail_count - 1)
70
+ ? "warn"
71
+ : "ok";
72
+ return response({
73
+ stage,
74
+ session_id: sessionId,
75
+ identical_run_len: identicalRunLen,
76
+ failing_run_len: failingRunLen,
77
+ fail_count_threshold: config.d7_fail_count,
78
+ window_turns: config.d7_window_turns,
79
+ reason: stage === "block"
80
+ ? "repeated_identical_failures"
81
+ : stage === "warn"
82
+ ? "near_repeated_identical_failures"
83
+ : "below_threshold",
84
+ ts: latest.ts,
85
+ });
86
+ }
87
+ catch {
88
+ return response(okGuard(sessionId, "unavailable"));
89
+ }
90
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * src/query/api/offload-share.ts — query-only L2c subagent offload metrics.
3
+ *
4
+ * Offload share uses structural turn counts only; no transcript, tool text,
5
+ * or paths leave this surface (SEC-101).
6
+ */
7
+ import { buildResponse } from "../envelope.js";
8
+ /**
9
+ * Derive subagent offload share for non-provisional turns in [from, to).
10
+ */
11
+ export function getOffloadShare(db, opts) {
12
+ const workspaceFilter = opts.workspaceId === null ? "" : " AND workspace_id = ?";
13
+ const params = opts.workspaceId === null ? [opts.from, opts.to] : [opts.from, opts.to, opts.workspaceId];
14
+ const totals = db
15
+ .prepare(`SELECT COUNT(DISTINCT session_id) AS session_count,
16
+ COUNT(DISTINCT CASE WHEN is_sidechain = 1 THEN session_id END) AS sidechain_session_count,
17
+ COUNT(*) AS total_turns,
18
+ COALESCE(SUM(CASE WHEN is_sidechain = 1 THEN 1 ELSE 0 END), 0) AS sidechain_turns
19
+ FROM turns
20
+ WHERE ts >= ? AND ts < ? AND provisional = 0${workspaceFilter}`)
21
+ .get(...params);
22
+ const data = {
23
+ ...totals,
24
+ offload_share: totals.total_turns === 0 ? null : totals.sidechain_turns / totals.total_turns,
25
+ from: opts.from,
26
+ to: opts.to,
27
+ workspace_id: opts.workspaceId,
28
+ };
29
+ return buildResponse(data, {
30
+ claim_kind: "OBS_PROXY",
31
+ n: data.total_turns,
32
+ window: { from: opts.from, to: opts.to },
33
+ qualification: {
34
+ provisional_excluded: true,
35
+ unpriced_turns: 0,
36
+ claim_kinds_count: 1,
37
+ note: "Within-session subagent offload share: is_sidechain turns / all turns (structural).",
38
+ },
39
+ ...(opts.workspaceId === null ? {} : { drilldown_ids: { workspace_id: opts.workspaceId } }),
40
+ });
41
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * src/query/api/outcomes.ts — LocalQueryAPI: outcomes surface.
3
+ *
4
+ * Mirrors settings.ts pattern: getQueryDb() + buildResponse + typed payload.
5
+ *
6
+ * All responses carry claim_kind:'EXPERIMENTAL' + the 73% methodology
7
+ * disclosure (plan §3 + §6 Q2). COND-1 gate applied in denominators.
8
+ *
9
+ * 73% note: fixed "73% (validation corpus)" methodology disclosure shown
10
+ * ALONGSIDE the live per-workspace getLinkageRate number — never conflated.
11
+ *
12
+ * SQL references: Data Model v2 §2 lines 261-275 / 303-314 / 329-334.
13
+ */
14
+ import { getQueryDb } from "../db-context.js";
15
+ import { buildResponse } from "../envelope.js";
16
+ // ---------------------------------------------------------------------------
17
+ // Methodology disclosure (fixed — plan §6 Q2)
18
+ // ---------------------------------------------------------------------------
19
+ const LINKAGE_METHODOLOGY_NOTE = "73% (validation corpus): linkage methodology validated on a sample corpus. " +
20
+ "Live linkage rate is per-workspace and shown separately in getLinkageRate. " +
21
+ "All findings are EXPERIMENTAL and excluded from gated denominators (COND-1).";
22
+ // ---------------------------------------------------------------------------
23
+ // Helper: experimental envelope meta
24
+ // ---------------------------------------------------------------------------
25
+ function experimentalMeta(n, from) {
26
+ const now = new Date().toISOString();
27
+ return {
28
+ claim_kind: "EXPERIMENTAL",
29
+ n,
30
+ window: { from: from ?? "1970-01-01T00:00:00Z", to: now },
31
+ qualification: {
32
+ provisional_excluded: true,
33
+ unpriced_turns: 0,
34
+ claim_kinds_count: 1,
35
+ note: LINKAGE_METHODOLOGY_NOTE,
36
+ },
37
+ };
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // getSuccessRate
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * Return the global observed success rate.
44
+ * Denominator: terminal (non-IN_PROGRESS) work_items with ≥1 session_work_links.
45
+ * COND-1: findings from EXPERIMENTAL extractors excluded from deferral check.
46
+ */
47
+ export function getSuccessRate() {
48
+ const db = getQueryDb();
49
+ const now = new Date().toISOString();
50
+ const windowStart = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
51
+ // SQL from Data Model v2 §2 lines 261-275
52
+ const row = db
53
+ .prepare(`SELECT
54
+ COUNT(*) AS terminal_n,
55
+ AVG(outcome IN ('OBSERVED_SUCCESS','OBSERVED_SUCCESS_WITH_DEFERRALS')) AS success_rate,
56
+ SUM(outcome = 'OBSERVED_SUCCESS') AS clean_success_n,
57
+ SUM(outcome = 'OBSERVED_SUCCESS_WITH_DEFERRALS') AS with_deferrals_n,
58
+ SUM(outcome IN ('OBSERVED_SUCCESS','OBSERVED_SUCCESS_WITH_DEFERRALS')
59
+ AND wi.checks_conclusion = 'NONE') AS no_ci_success_n
60
+ FROM observed_outcomes o JOIN work_items wi USING (work_item_id)
61
+ WHERE o.outcome != 'IN_PROGRESS'
62
+ AND COALESCE(wi.merged_at, wi.closed_at) >= ?
63
+ AND COALESCE(wi.merged_at, wi.closed_at) < ?
64
+ AND EXISTS (SELECT 1 FROM session_work_links l WHERE l.work_item_id = o.work_item_id)`)
65
+ .get(windowStart, now);
66
+ // SQL from Data Model v2 §2 lines 329-334
67
+ const lrRow = db
68
+ .prepare(`SELECT AVG(EXISTS(SELECT 1 FROM session_work_links l WHERE l.session_id = s.session_id))
69
+ AS linkage_rate
70
+ FROM sessions s WHERE s.state='RECONCILED'
71
+ AND EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id
72
+ AND te.tool_name = 'Bash')`)
73
+ .get();
74
+ if (row === undefined || row.terminal_n === 0) {
75
+ const data = {
76
+ terminal_n: 0,
77
+ success_rate: null,
78
+ clean_success_n: 0,
79
+ with_deferrals_n: 0,
80
+ no_ci_success_n: 0,
81
+ linkage_rate: lrRow?.linkage_rate ?? null,
82
+ methodology_note: LINKAGE_METHODOLOGY_NOTE,
83
+ };
84
+ return buildResponse(data, experimentalMeta(0, windowStart));
85
+ }
86
+ const data = {
87
+ terminal_n: row.terminal_n,
88
+ success_rate: row.success_rate,
89
+ clean_success_n: row.clean_success_n,
90
+ with_deferrals_n: row.with_deferrals_n,
91
+ no_ci_success_n: row.no_ci_success_n,
92
+ linkage_rate: lrRow?.linkage_rate ?? null,
93
+ methodology_note: LINKAGE_METHODOLOGY_NOTE,
94
+ };
95
+ return buildResponse(data, experimentalMeta(row.terminal_n, windowStart));
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // listWorkspaceOutcomes
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * Return per-workspace outcome summaries.
102
+ */
103
+ export function listWorkspaceOutcomes() {
104
+ const db = getQueryDb();
105
+ const rows = db
106
+ .prepare(`WITH linked_items AS (
107
+ SELECT wi.*, o.outcome
108
+ FROM work_items wi
109
+ LEFT JOIN observed_outcomes o ON o.work_item_id = wi.work_item_id
110
+ WHERE EXISTS (
111
+ SELECT 1 FROM session_work_links l WHERE l.work_item_id = wi.work_item_id
112
+ )
113
+ )
114
+ SELECT w.workspace_id, w.project_slug,
115
+ COUNT(li.work_item_id) AS total_n,
116
+ SUM(CASE WHEN li.state = 'OPEN' THEN 1 ELSE 0 END) AS in_progress_n,
117
+ SUM(CASE WHEN li.state IN ('MERGED', 'CLOSED') THEN 1 ELSE 0 END) AS terminal_n,
118
+ SUM(CASE WHEN li.outcome IN ('OBSERVED_SUCCESS','OBSERVED_SUCCESS_WITH_DEFERRALS')
119
+ THEN 1 ELSE 0 END) AS success_n,
120
+ SUM(CASE WHEN li.outcome = 'OBSERVED_FAILURE' THEN 1 ELSE 0 END) AS failure_n
121
+ FROM workspaces w
122
+ LEFT JOIN linked_items li ON li.workspace_id = w.workspace_id
123
+ GROUP BY w.workspace_id, w.project_slug
124
+ ORDER BY w.project_slug, w.workspace_id`)
125
+ .all();
126
+ // Compute per-workspace linkage rates
127
+ const lrRows = db
128
+ .prepare(`SELECT s.workspace_id,
129
+ AVG(EXISTS(SELECT 1 FROM session_work_links l WHERE l.session_id = s.session_id))
130
+ AS linkage_rate
131
+ FROM sessions s WHERE s.state='RECONCILED'
132
+ AND EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id
133
+ AND te.tool_name = 'Bash')
134
+ GROUP BY s.workspace_id`)
135
+ .all();
136
+ const lrByWs = new Map(lrRows.map((r) => [r.workspace_id, r.linkage_rate]));
137
+ const adherenceRows = db
138
+ .prepare(`SELECT workspace_id, COUNT(*) AS n,
139
+ AVG(CASE WHEN model LIKE '%opus%' THEN 1.0 ELSE 0.0 END) AS premium_share
140
+ FROM turns
141
+ WHERE is_sidechain = 0 AND provisional = 0
142
+ GROUP BY workspace_id`)
143
+ .all();
144
+ const adherenceByWs = new Map(adherenceRows.map((r) => [
145
+ r.workspace_id,
146
+ r.n > 0 && r.premium_share !== null ? Math.round(100 * (1 - r.premium_share)) : null,
147
+ ]));
148
+ const data = rows.map((r) => ({
149
+ ...r,
150
+ success_rate: r.terminal_n > 0 ? r.success_n / r.terminal_n : null,
151
+ linkage_rate: lrByWs.get(r.workspace_id) ?? null,
152
+ adherence_score: adherenceByWs.get(r.workspace_id) ?? null,
153
+ }));
154
+ return buildResponse(data, experimentalMeta(data.length));
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // getWorkspaceOutcomeDetail
158
+ // ---------------------------------------------------------------------------
159
+ /**
160
+ * Return detail for a single work item (findings + linked sessions).
161
+ */
162
+ export function getWorkspaceOutcomeDetail(workItemId) {
163
+ const db = getQueryDb();
164
+ const wi = db
165
+ .prepare(`SELECT wi.work_item_id, wi.number, wi.state, wi.checks_conclusion,
166
+ wi.merged_at, wi.closed_at, o.outcome
167
+ FROM work_items wi
168
+ LEFT JOIN observed_outcomes o USING (work_item_id)
169
+ WHERE wi.work_item_id = ?`)
170
+ .get(workItemId);
171
+ if (wi === undefined) {
172
+ return buildResponse(null, experimentalMeta(0));
173
+ }
174
+ const findings = db
175
+ .prepare(`SELECT finding_id, source, severity, status, evidence_ref,
176
+ raised_at, cleared_at, extractor_version
177
+ FROM review_findings WHERE work_item_id = ? ORDER BY raised_at`)
178
+ .all(workItemId);
179
+ const linkedSessions = db
180
+ .prepare("SELECT session_id FROM session_work_links WHERE work_item_id = ?")
181
+ .all(workItemId).map((r) => r.session_id);
182
+ const data = {
183
+ ...wi,
184
+ findings,
185
+ linked_sessions: linkedSessions,
186
+ };
187
+ return buildResponse(data, experimentalMeta(1));
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // getLinkageRate
191
+ // ---------------------------------------------------------------------------
192
+ /**
193
+ * Return the live linkage rate (per §6 Q2: separate from the 73% disclosure).
194
+ * SQL from Data Model v2 §2 lines 329-334.
195
+ */
196
+ export function getLinkageRate(workspaceId) {
197
+ const db = getQueryDb();
198
+ const whereExtra = workspaceId !== undefined ? " AND s.workspace_id = ?" : "";
199
+ const params = workspaceId !== undefined ? [workspaceId] : [];
200
+ const row = db
201
+ .prepare(`SELECT AVG(EXISTS(SELECT 1 FROM session_work_links l WHERE l.session_id = s.session_id))
202
+ AS linkage_rate,
203
+ COUNT(*) AS denominator_n
204
+ FROM sessions s WHERE s.state='RECONCILED'
205
+ AND EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id
206
+ AND te.tool_name = 'Bash')${whereExtra}`)
207
+ .get(...params);
208
+ const data = {
209
+ linkage_rate: row?.linkage_rate ?? null,
210
+ denominator_n: row?.denominator_n ?? 0,
211
+ methodology_note: "Live per-workspace linkage rate. Denominator: RECONCILED sessions with ≥1 Bash tool_event. " +
212
+ "See also the fixed 73% validation corpus disclosure in getSuccessRate.",
213
+ };
214
+ return buildResponse(data, {
215
+ ...experimentalMeta(data.denominator_n),
216
+ ...(workspaceId !== undefined ? { drilldown_ids: { workspace_id: workspaceId } } : {}),
217
+ });
218
+ }