agentwrangler 0.1.0 → 0.1.1-next.2f6c1df

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 (64) hide show
  1. package/README.md +82 -94
  2. package/dist/apply/jobs.js +6 -2
  3. package/dist/daemon/index.js +9 -3
  4. package/dist/daemon/readiness.js +26 -1
  5. package/dist/daemon/router.js +4 -2
  6. package/dist/db/migrations/016_ingest_offset_file_version.sql +8 -0
  7. package/dist/db/migrations/017_ingest_metric_events.sql +22 -0
  8. package/dist/detector/detectors/d10_catalog_footprint.js +2 -2
  9. package/dist/detector/detectors/d1_ctx_always_loaded.js +9 -9
  10. package/dist/detector/detectors/d2_session_long_full_context.js +2 -2
  11. package/dist/detector/detectors/d4_model_mismatch.js +3 -3
  12. package/dist/detector/detectors/d6_tool_result_bloat.js +1 -1
  13. package/dist/detector/detectors/d7_loop_retry_waste.js +1 -1
  14. package/dist/detector/detectors/d8_cache_write_churn.js +2 -2
  15. package/dist/detector/detectors/d9_idle_background_session.js +1 -1
  16. package/dist/detector/measurement.js +3 -2
  17. package/dist/hook/precompact-checkpoint-hook.mjs +74 -25
  18. package/dist/ingest/ingestor.js +151 -62
  19. package/dist/ingest/pricing.js +15 -0
  20. package/dist/ingest/tail.js +55 -11
  21. package/dist/query/api/cost-per-success.js +13 -1
  22. package/dist/query/api/delivery.js +25 -3
  23. package/dist/query/api/effectiveness.js +30 -5
  24. package/dist/query/api/efficiency-headroom.js +12 -14
  25. package/dist/query/api/outcomes.js +2 -1
  26. package/dist/query/api/overview.js +90 -6
  27. package/dist/query/settings-store.js +2 -0
  28. package/dist/query/spend.js +64 -2
  29. package/dist/ui/assets/BarChart-BC-ciS9d.js +3 -0
  30. package/dist/ui/assets/BriefsPage-Dls6npt7.js +2 -0
  31. package/dist/ui/assets/CacheWriteSpikesChart-ncVwVoud.js +1 -0
  32. package/dist/ui/assets/CartesianChart-CRV6h2et.js +84 -0
  33. package/dist/ui/assets/Chip-EizRm13x.js +1 -0
  34. package/dist/ui/assets/ComposedChart-CrOxp6Ym.js +1 -0
  35. package/dist/ui/assets/EmptyState-9kcoimjj.js +1 -0
  36. package/dist/ui/assets/FlavorDecomposition-DRZxH0vQ.js +1 -0
  37. package/dist/ui/assets/FrictionCell-BGYB9UKh.js +2 -0
  38. package/dist/ui/assets/GlossaryPage-CRyQb1Hn.js +1 -0
  39. package/dist/ui/assets/HotSessionsPage-DfE90Kxi.js +1 -0
  40. package/dist/ui/assets/InfoTip-CmcYFNQt.js +1 -0
  41. package/dist/ui/assets/Legend-BCXsSaEM.js +5 -0
  42. package/dist/ui/assets/Line-2rUzs15v.js +1 -0
  43. package/dist/ui/assets/OverviewPage-Bm5KvXqa.js +2 -0
  44. package/dist/ui/assets/RecommendationsPage-CvSl3lzp.js +2 -0
  45. package/dist/ui/assets/Scatter-DpIZt_y5.js +1 -0
  46. package/dist/ui/assets/SessionDetailPage-w_lSgite.js +1 -0
  47. package/dist/ui/assets/SettingsPage-CfUoP8lB.js +25 -0
  48. package/dist/ui/assets/Skeleton-DjuxPm_D.js +1 -0
  49. package/dist/ui/assets/SpendPercentileChip-DYun7Wzh.js +1 -0
  50. package/dist/ui/assets/TrendChart-gyhHK9H_.js +1 -0
  51. package/dist/ui/assets/WorkspaceDetailPage-DacPKaKA.js +1 -0
  52. package/dist/ui/assets/WorkspacesPage-DiNQaAYG.js +1 -0
  53. package/dist/ui/assets/chart-theme-DnpNuyRt.js +1 -0
  54. package/dist/ui/assets/graphicalItemSelectors-CFhpQjLz.js +1 -0
  55. package/dist/ui/assets/index-D_IJsvkJ.css +1 -0
  56. package/dist/ui/assets/index-hbaLYHUX.js +41 -0
  57. package/dist/ui/assets/prompt-templates-CVzMex9L.js +13 -0
  58. package/dist/ui/assets/rec-sessions-ggz9MYgP.js +1 -0
  59. package/dist/ui/assets/useExperimentalActions-B7J8gIkb.js +1 -0
  60. package/dist/ui/assets/workspace-label-CysPJNc0.js +1 -0
  61. package/dist/ui/index.html +2 -2
  62. package/package.json +21 -5
  63. package/dist/ui/assets/index-DnRKgc21.css +0 -1
  64. package/dist/ui/assets/index-h1Q1wWq5.js +0 -168
package/README.md CHANGED
@@ -1,116 +1,104 @@
1
- # AgentWrangler
2
-
3
- Local-first observability for Claude Code that **joins your token spend to the pull requests it
4
- actually merged or closed** — entirely on your machine — and ships **installable guardrails** (a
5
- dangerous-command block, a pre-compaction checkpoint, a context-budget warning) you turn on from the
6
- dashboard. See where your agent budget goes, whether the work shipped, and cut the waste.
7
-
8
- ## Local-only — your data never leaves your machine
9
-
10
- AgentWrangler runs entirely on your computer: a daemon bound to **`127.0.0.1`** and a dashboard in
11
- your browser that talks only to that loopback address. **No cloud backend, no telemetry, nothing
12
- phones home.** It reads your local Claude Code transcripts, stores only aggregates in a local SQLite
13
- file (`~/.agentwrangler/db.sqlite`), and never persists raw transcript or PR content — only counts,
14
- ids, and structural anchors (the SEC-101 privacy invariant). An optional GitHub token, used only for
15
- the outcomes feature, is read locally, never logged, and never leaves your machine.
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Doogit/AgentWrangler/main/docs/assets/logo.png" alt="AgentWrangler logo" width="200">
3
+ </p>
16
4
 
17
- **Privacy exception (opt-in):** `npm run evidence:judge-g2 -- --execute` is the only path where PR
18
- content leaves the machine. It calls the Claude API with your local Claude Code OAuth credential to
19
- adjudicate G2 deferral findings, and runs only when `g2_claude_judge_opt_in` is enabled; otherwise it
20
- refuses to run. No rationale text is persisted.
21
-
22
- ![AgentWrangler dashboard — spend verdict, model mix, recommendations, and per-repo efficiency](docs/assets/dashboard.gif)
5
+ # AgentWrangler
23
6
 
24
- <!--
25
- Captured from a SANITIZED instance (Vite test-mode fixtures anonymized names, no
26
- live data) so no real workspace/repository names/paths are committed (SEC-101).
27
- Regenerate with the ce-demo-reel skill against `npx vite --mode test`.
28
- -->
7
+ See where your Claude Code tokens go, inspect costly sessions, and track whether a change helped.
8
+ AgentWrangler reads local Claude Code transcripts and serves a dashboard on your machine.
9
+ No account or cloud backend is required.
29
10
 
30
11
  ## Quick start
31
12
 
32
- Requirements: **Node `>=22 <25`** and npm.
33
-
34
- ```sh
35
- git clone https://github.com/Doogit/AgentWrangler && cd AgentWrangler
36
- npm ci
37
- npm run build:ui # build the dashboard
38
- npm run daemon # starts the daemon and opens your browser
39
- ```
40
-
41
- Then open **http://127.0.0.1:47821** (the daemon opens it for you unless `AW_NO_OPEN=1`).
42
-
43
- After installing dependencies you can also do it in one step:
13
+ Requires **Node 22-24 and npm**, plus Claude Code transcripts for populated charts.
44
14
 
45
15
  ```sh
46
- npm ci # required first — the CLI runs via tsx, a devDependency
47
- npx agentwrangler # builds the UI if needed, then launches the daemon + browser
16
+ npx agentwrangler@latest
48
17
  ```
49
18
 
50
- On first launch the daemon binds the port immediately and serves a loading page, then scans your
51
- `~/.claude/projects/**/*.jsonl` transcripts in the background the dashboard appears right away and
52
- fills in as the scan completes, so a large history won't block the page from opening.
53
-
54
- ## Optional setup
19
+ Open **http://127.0.0.1:47821** if the browser does not open automatically. Keep the terminal
20
+ running; press **Ctrl+C** to stop. The first scan runs in the background. An empty history is
21
+ valid: use Claude Code, then return after ingestion. If a daemon is already running, stop it
22
+ before starting another on the same port.
55
23
 
56
- - **GitHub outcomes sync** links sessions to the PRs/commits they produced. Provide a read-only
57
- GitHub PAT via the `AW_GITHUB_TOKEN` environment variable (works on all platforms). On Windows you
58
- may instead store it in Credential Manager as `AgentWrangler-GithubToken`. Without a token the
59
- outcomes feature stays inert and Settings tells you so — nothing fails silently.
60
- - **Usage reader** — reads your Claude Code OAuth credentials locally to calibrate the weekly limit
61
- and burn forecast. Sign in through Claude Code as usual; Settings shows the reader status.
62
- - **Context-budget hook** — an optional PreToolUse hook that warns before long sessions balloon.
63
- Install it from the Settings page (or `npm run install-hook`).
24
+ [Install from source, configure scan roots, or troubleshoot](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md).
64
25
 
65
- ## Configuration (environment variables)
26
+ ## Your first five minutes
66
27
 
67
- All are optional; sensible defaults apply. See [`.env.example`](.env.example) for the full list.
28
+ 1. **Check the scan.** In Overview, read the onboarding status. A completed scan with no
29
+ recommendations is a valid result. For an unexpected empty history, inspect scan roots
30
+ and parser health in Settings; saved scan-root changes require a daemon restart.
31
+ 2. **Find one expensive session.** Select a date window, open Workspaces, choose a workspace,
32
+ then open one of its sessions. Compare context, cache writes, and output before deciding
33
+ what to change.
34
+ 3. **Inspect one recommendation.** Open Recommendations and **Show details** on an instance.
35
+ Read its evidence and caveats. A modeled amount is a projection; directional advice may
36
+ have no dollar estimate. If nothing fires, there is nothing to adopt just to finish setup.
37
+ 4. **Make one deliberate change.** A copied prompt is an artifact to review and run yourself.
38
+ It does not edit files. After completing a supported change, use **I completed the change**
39
+ when offered, then **Track this change** to record a baseline.
40
+ 5. **Return to the Impact ledger.** Tracking starts observation, not a savings claim. Eligible
41
+ signals use a 14-day observation window and may finish inconclusive. You can finish this
42
+ first visit without a GitHub token, calibration, or installed hooks.
68
43
 
69
- | Variable | Purpose | Default |
70
- |---|---|---|
71
- | `AW_PORT` | Daemon HTTP port | `47821` |
72
- | `AW_DB_PATH` | SQLite database path | `~/.agentwrangler/db.sqlite` |
73
- | `AW_SCAN_ROOT` | Transcript corpus to scan | `~/.claude/projects` |
74
- | `AW_UI_ROOT` | Directory the built UI is served from | `<repo>/dist/ui` |
75
- | `AW_GITHUB_TOKEN` | Read-only GitHub PAT for outcomes sync | *(unset)* |
76
- | `AW_NO_OPEN` | Set to `1` to not auto-open the browser | *(unset)* |
44
+ Dollar figures are **list-price equivalents**, not your subscription bill. Modeled savings
45
+ are not achieved savings, and observed improvement does not prove the change caused it.
46
+ GitHub linkage adds outcome metadata; calibration enables the burn forecast. Local spend,
47
+ session inspection, and supported recommendation tracking work without either.
77
48
 
78
- ## What it measures
49
+ [Worked example: trim always-loaded context and inspect its effect](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md#example-trim-always-loaded-context).
79
50
 
80
- AgentWrangler ingests your transcripts and surfaces cost trends, cache-efficiency, session hygiene
81
- findings (e.g. long sessions never `/clear`ed, limit-burn risk), and — with a GitHub token — outcome
82
- linkage between sessions and the work they shipped. Numbers are cap-weighted and honesty-tiered: the
83
- UI is explicit about what is measured versus estimated, and never invents a proxy it can't ground.
84
- Definitions live in [`docs/planning/AgentWrangler_Data_Model_and_Metrics_v2.md`](docs/planning/AgentWrangler_Data_Model_and_Metrics_v2.md).
51
+ ## Dashboard preview
85
52
 
86
- ## Limitations
53
+ ![Synthetic Overview: spend for the selected window, model mix, and links to sessions and recommendations](https://raw.githubusercontent.com/Doogit/AgentWrangler/main/docs/assets/overview.png)
87
54
 
88
- - **Platform support.** Tested on Windows; macOS/Linux are believed working — reports welcome.
89
- - **Transcript-format coupling.** AgentWrangler reads Claude Code's JSONL transcript format; a change
90
- to that format upstream can require an ingestion update.
91
- - **Outcomes credential sources.** The cross-platform path is `AW_GITHUB_TOKEN`; the OS credential
92
- store integration currently covers Windows Credential Manager only (macOS/Linux keychain is a
93
- follow-on).
94
- - **Single local user.** It observes one machine's Claude Code history; there is no multi-user or
95
- team-aggregation mode by design.
55
+ This static preview uses synthetic data. [The dashboard tour](https://github.com/Doogit/AgentWrangler/blob/main/docs/dashboard-tour.md)
56
+ contains the other views and an optional animated preview.
96
57
 
97
- ## License
58
+ ## Pick a question
98
59
 
99
- Licensed under the [Apache License 2.0](LICENSE). Copyright 2026 AgentWrangler contributors.
60
+ | I want to... | Start here |
61
+ |---|---|
62
+ | Find where my tokens went | [Workspaces and Sessions](https://github.com/Doogit/AgentWrangler/blob/main/docs/dashboard-tour.md#workspaces) |
63
+ | Turn a recommendation into a change | [Worked example](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md#example-trim-always-loaded-context) |
64
+ | Understand the dollars and verdicts | [Metric vocabulary](https://github.com/Doogit/AgentWrangler/blob/main/docs/dashboard-tour.md#glossary-how-to-read-this-dashboard) |
65
+ | Fix an empty dashboard | [First launch](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md#first-launch) |
66
+ | Connect GitHub or calibrate limits | [Optional setup](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md#optional-setup) |
67
+ | Read a weekly summary | [Briefs](https://github.com/Doogit/AgentWrangler/blob/main/docs/dashboard-tour.md#briefs) |
100
68
 
101
- See [CONTRIBUTING.md](CONTRIBUTING.md) to build and test, [SECURITY.md](SECURITY.md) to report a
102
- vulnerability, and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
69
+ ## Installable guardrails local checks inside Claude Code, before the waste
103
70
 
104
- ## Documentation index
71
+ Hooks are optional and require an explicit install. Settings **Install directly** installs
72
+ five hooks; **Copy install prompt** prepares instructions for three. Copying alone installs nothing.
105
73
 
106
- | Path | Purpose |
107
- |---|---|
108
- | `docs/adr/ADR-100-stack.md` | Accepted MVP stack: Node 22 LTS + TypeScript + better-sqlite3, localhost UI (no Tauri/Electron for MVP) |
109
- | `docs/adr/ADR-100-shell-research-2026-08-21.md` | External research backing the localhost-UI decision |
110
- | `docs/planning/AgentWrangler_PRD_v0_7_0.md` | Product requirements v0.7.0 |
111
- | `docs/planning/AgentWrangler_Technical_Architecture_v4_5_0.md` | Architecture v4.5.0 |
112
- | `docs/planning/AgentWrangler_Data_Model_and_Metrics_v2.md` | SQLite schema + metric definitions v2 |
113
- | `docs/planning/AgentWrangler_Ingestion_and_Findings_Spec_v1.md` | Transcript ingestion + findings extractor spec |
114
- | `docs/planning/AgentWrangler_Recommendations_Engine_Spec_v1.md` | Recommendations engine spec |
115
- | `docs/planning/AgentWrangler_Spike_Plan_v2.md` | Spike exit criteria (authoritative) |
116
- | `docs/planning/AgentWrangler_PreImplementation_Plan_v1.md` | Session map + session prompts |
74
+ | Hook | Behavior | Install path |
75
+ |---|---|---|
76
+ | Context-budget | Warns when context crosses configured thresholds | Direct or copied prompt |
77
+ | Loop guard | Warns, then can deny repeated identical failures | Direct or copied prompt |
78
+ | Burn alert | Warns about session budget consumption | Direct or copied prompt |
79
+ | Dangerous-command | Can ask or deny risky commands | Direct only |
80
+ | PreCompact checkpoint | Copies raw transcripts locally, with retention limits | Direct only |
81
+
82
+ [Installation, removal, and the checkpoint privacy details](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md#in-session-guardrails).
83
+
84
+ ## Privacy and limits
85
+
86
+ - The daemon binds to **127.0.0.1**. There is no telemetry or hosted product backend.
87
+ - SQLite contains aggregates and structural data, and can also contain local command text
88
+ and filesystem paths. Treat it as sensitive. The optional PreCompact hook makes separate
89
+ raw transcript copies on your machine.
90
+ - Usage refresh can contact Anthropic using your existing Claude Code sign-in. GitHub
91
+ outcomes sync requires a configured token. [Privacy details](https://github.com/Doogit/AgentWrangler/blob/main/docs/privacy.md).
92
+ - Claude Code format changes can require parser updates. This is a single-user tool.
93
+ - Windows local validation and Linux/macOS CI smoke coverage do not establish full native
94
+ accessibility or credential-store compatibility on every platform.
95
+
96
+ ## Deeper documentation
97
+
98
+ [Configuration](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md#configuration) |
99
+ [Architecture](https://github.com/Doogit/AgentWrangler/blob/main/docs/planning/AgentWrangler_Technical_Architecture_v4_5_0.md) |
100
+ [Data model and metrics](https://github.com/Doogit/AgentWrangler/blob/main/docs/planning/AgentWrangler_Data_Model_and_Metrics_v2.md) |
101
+ [Contributing](https://github.com/Doogit/AgentWrangler/blob/main/.github/CONTRIBUTING.md) |
102
+ [Security policy](https://github.com/Doogit/AgentWrangler/blob/main/.github/SECURITY.md)
103
+
104
+ [Apache 2.0](https://github.com/Doogit/AgentWrangler/blob/main/LICENSE).
@@ -200,6 +200,7 @@ function runClaudePhase(db, job, rec, phase, settingsPath, rt) {
200
200
  const changedPaths = [];
201
201
  let finalized = false;
202
202
  let fatalExitMessage = null;
203
+ let killTimer;
203
204
  const cleanup = () => {
204
205
  fs.unlink(settingsPath, () => { });
205
206
  };
@@ -251,9 +252,11 @@ function runClaudePhase(db, job, rec, phase, settingsPath, rt) {
251
252
  }
252
253
  });
253
254
  const timer = setTimeout(() => {
255
+ // Keep the job exclusive until the child has actually closed. Publishing
256
+ // FAILED here lets a retry (or workspace cleanup) race the dying process.
257
+ fatalExitMessage ??= "job timed out";
254
258
  proc.kill("SIGTERM");
255
- setTimeout(() => proc.kill("SIGKILL"), 2000);
256
- markFailed("job timed out");
259
+ killTimer = setTimeout(() => proc.kill("SIGKILL"), 2000);
257
260
  }, rt.timeoutMs);
258
261
  proc.on("error", (err) => {
259
262
  clearTimeout(timer);
@@ -261,6 +264,7 @@ function runClaudePhase(db, job, rec, phase, settingsPath, rt) {
261
264
  });
262
265
  proc.on("close", (code) => {
263
266
  clearTimeout(timer);
267
+ clearTimeout(killTimer);
264
268
  if (finalized)
265
269
  return;
266
270
  finalized = true;
@@ -35,11 +35,11 @@ import { linkSessions } from "../outcomes/linker.js";
35
35
  import { syncAllWorkspaces } from "../outcomes/sync.js";
36
36
  import { generateWeeklyReport } from "../query/api/reports.js";
37
37
  import { setQueryDb } from "../query/db-context.js";
38
- import { setHealthInstance, setRuntimeResetHook } from "../query/settings-store.js";
38
+ import { getSettingsData, setHealthInstance, setRuntimeResetHook, } from "../query/settings-store.js";
39
39
  import { loadConfig } from "./config.js";
40
40
  import { createServer } from "./http.js";
41
41
  import { createOutcomesPassRunner } from "./outcomes-pass.js";
42
- import { setReady } from "./readiness.js";
42
+ import { setReady, setScanRoots, setScanState } from "./readiness.js";
43
43
  const VERSION = "0.1.0";
44
44
  /** Parse a named CLI argument: --name value or --name=value. */
45
45
  function getArg(name) {
@@ -110,6 +110,8 @@ if (isSmoke) {
110
110
  "apply_jobs",
111
111
  "analysis_runs",
112
112
  "ingest_quarantine",
113
+ "ingest_metric_events",
114
+ "ingest_metric_baselines",
113
115
  "ingest_offsets",
114
116
  "schema_migrations",
115
117
  "user_config",
@@ -208,7 +210,9 @@ function kickBootScan() {
208
210
  }
209
211
  async function runBootScan() {
210
212
  try {
211
- const ingestor = new Ingestor(db, config.scanRoots, {
213
+ const scanRoots = getSettingsData(db).scan_roots;
214
+ setScanRoots(scanRoots);
215
+ const ingestor = new Ingestor(db, scanRoots, {
212
216
  onNewMappings: (count) => {
213
217
  console.log(`Discovery mapped ${count} new repo(s) — scheduling outcomes pass`);
214
218
  runOutcomesPass().catch((e) => {
@@ -218,10 +222,12 @@ async function runBootScan() {
218
222
  });
219
223
  setHealthInstance(ingestor.health);
220
224
  handle = await ingestor.startTailBatched();
225
+ setScanState("complete");
221
226
  setRuntimeResetHook(() => ingestor.clearRuntimeState());
222
227
  console.log(`Ingestion: initial scan complete — health ${JSON.stringify(ingestor.healthSnapshot())}`);
223
228
  }
224
229
  catch (e) {
230
+ setScanState("failed");
225
231
  console.error(`Ingestion failed to start — serving dashboard in degraded mode: ${e instanceof Error ? e.message : String(e)}`);
226
232
  }
227
233
  // After the initial back-scan, workspaces are discovered — re-probe so per-workspace
@@ -4,7 +4,32 @@
4
4
  * Set to true by index.ts after the initial back-scan completes.
5
5
  * Read by http.ts to decide whether to serve the loading page or the real SPA.
6
6
  */
7
- const state = { ready: false };
7
+ import * as fs from "node:fs";
8
+ const state = { ready: false, scan: "scanning", scanRoots: [] };
9
+ /** Snapshot the roots actually used by the running ingestor, not pending settings. */
10
+ export function setScanRoots(roots) {
11
+ state.scanRoots = [...roots];
12
+ }
13
+ export function setScanState(scan) {
14
+ state.scan = scan;
15
+ }
16
+ /** Aggregate-only diagnostics; never exposes a local path or filesystem error. */
17
+ export function getScanStatus() {
18
+ let invalid = 0;
19
+ for (const root of state.scanRoots) {
20
+ try {
21
+ if (!fs.statSync(root).isDirectory()) {
22
+ invalid += 1;
23
+ continue;
24
+ }
25
+ fs.accessSync(root, fs.constants.R_OK);
26
+ }
27
+ catch {
28
+ invalid += 1;
29
+ }
30
+ }
31
+ return { scan_state: state.scan, invalid_scan_root_count: invalid };
32
+ }
8
33
  /** Returns true after the initial back-scan + detector pass have finished. */
9
34
  export function isReady() {
10
35
  return state.ready;
@@ -26,7 +26,7 @@ import { getSessionSpendPercentile, getWeeklySelfPercentile, } from "../query/ap
26
26
  import { getFlavorDecomposition } from "../query/api/spend-flavor.js";
27
27
  import { getCacheWriteTrend, getTrends } from "../query/api/trends.js";
28
28
  import { getSettingsData } from "../query/settings-store.js";
29
- import { isReady } from "./readiness.js";
29
+ import { getScanStatus, isReady } from "./readiness.js";
30
30
  function sendJson(res, status, body) {
31
31
  const payload = JSON.stringify(body);
32
32
  res.writeHead(status, {
@@ -264,7 +264,7 @@ export function handleApiRequest(_db, req, res, method, url) {
264
264
  sendJson(res, 200, getPractices(_db, { from, to }));
265
265
  return;
266
266
  }
267
- // GET /api/efficiency-headroom (BM2 modeled savings vs trailing-window spend)
267
+ // GET /api/efficiency-headroom (individual weekly estimates; separate selected-window spend)
268
268
  if (method === "GET" && pathname === "/api/efficiency-headroom") {
269
269
  const { from, to } = resolveWindow(parseWindowFilter(url));
270
270
  sendJson(res, 200, getEfficiencyHeadroom(_db, { from, to }));
@@ -741,6 +741,8 @@ export function handleApiRequest(_db, req, res, method, url) {
741
741
  sessions,
742
742
  files_seen: parser_health.files_seen,
743
743
  files_parsed: parser_health.files_parsed,
744
+ lines_quarantined: parser_health.lines_quarantined,
745
+ ...getScanStatus(),
744
746
  });
745
747
  return;
746
748
  }
@@ -0,0 +1,8 @@
1
+ -- Nullable metadata lets existing offsets establish a baseline on their next read.
2
+ -- No transcript content is stored here. Identity detects path replacement; timestamps
3
+ -- detect equal-size in-place rewrites beyond the existing 256-byte head fingerprint.
4
+ ALTER TABLE ingest_offsets ADD COLUMN file_size INTEGER;
5
+ ALTER TABLE ingest_offsets ADD COLUMN file_dev TEXT;
6
+ ALTER TABLE ingest_offsets ADD COLUMN file_ino TEXT;
7
+ ALTER TABLE ingest_offsets ADD COLUMN file_mtime_ms REAL;
8
+ ALTER TABLE ingest_offsets ADD COLUMN file_ctime_ms REAL;
@@ -0,0 +1,22 @@
1
+ -- Persist structural metric events so transcript replay cannot inflate session counters.
2
+ -- event_id is a SHA-256 digest computed in-process; transcript content is never stored.
3
+ CREATE TABLE ingest_metric_events (
4
+ event_id TEXT PRIMARY KEY,
5
+ session_id TEXT NOT NULL,
6
+ user_turn_ts TEXT,
7
+ is_user_turn INTEGER NOT NULL CHECK (is_user_turn IN (0, 1)),
8
+ is_compact_summary INTEGER NOT NULL CHECK (is_compact_summary IN (0, 1)),
9
+ is_api_error INTEGER NOT NULL CHECK (is_api_error IN (0, 1)),
10
+ is_interrupt INTEGER NOT NULL CHECK (is_interrupt IN (0, 1))
11
+ );
12
+
13
+ CREATE INDEX idx_ingest_metric_events_session_user_ts
14
+ ON ingest_metric_events(session_id, user_turn_ts)
15
+ WHERE is_user_turn = 1;
16
+
17
+ -- Existing offsets predate the event ledger. Their consumed prefixes are seeded into
18
+ -- the ledger without incrementing counters, then this durable boundary is advanced.
19
+ CREATE TABLE ingest_metric_baselines (
20
+ file_path TEXT PRIMARY KEY,
21
+ seeded_offset INTEGER NOT NULL CHECK (seeded_offset >= 0)
22
+ );
@@ -95,7 +95,7 @@ export const d10Detector = {
95
95
  const latestProbedAt = rows.reduce((latest, row) => (latest === null || row.probed_at > latest ? row.probed_at : latest), null);
96
96
  const refs = rows.map((row) => row.file_ref);
97
97
  const evidence = {
98
- title: `Review ${state.effective_catalog_state} tool catalog: ${Math.round(catalogTokens / 1000)}K tokens`,
98
+ title: `Review tool catalog: ${Math.round(catalogTokens / 1000)}K estimated tokens`,
99
99
  component: "MCP_SCHEMAS",
100
100
  file_ref: refs.length === 1 ? refs[0] : null,
101
101
  file_refs: refs,
@@ -120,7 +120,7 @@ export const d10Detector = {
120
120
  scopeKey: "D10|global|MCP_SCHEMAS",
121
121
  category: "TOOLING",
122
122
  scope_workspace_id: null,
123
- lever: "Too many connected tools, plugins, and skills",
123
+ lever: "The tool catalog exceeds its size target; actual loaded context is not measured",
124
124
  target_metric: "catalog_context_tokens",
125
125
  // R11 is required before catalog size can become a freed-headroom claim.
126
126
  modeled_savings_u_per_wk: null,
@@ -29,16 +29,16 @@ function stepsFor(component, fileRef) {
29
29
  case "CLAUDE_MD":
30
30
  return [
31
31
  `Open ${fileRef}`,
32
- "Move changelog/history/rationale prose to a linked doc",
33
- "Keep current-state rules + pointers only",
34
- "Re-measure: probe re-sizes on next daemon pass",
32
+ "Move changelogs, history, and background explanations to a linked document",
33
+ "Keep only current rules and links here",
34
+ "Check the size again after the next local scan",
35
35
  ];
36
36
  case "MEMORY":
37
37
  return [
38
38
  `Review memory files under ${fileRef}`,
39
- "Delete stale or duplicate memories",
40
- "Consolidate overlapping facts into concise entries",
41
- "Re-measure: probe re-sizes on next daemon pass",
39
+ "Remove old or duplicate memories",
40
+ "Combine overlapping facts into concise entries",
41
+ "Check the size again after the next local scan",
42
42
  ];
43
43
  case "MCP_SCHEMAS":
44
44
  return [
@@ -62,13 +62,13 @@ function titleFor(component, tokens, target) {
62
62
  function leverFor(component) {
63
63
  switch (component) {
64
64
  case "CLAUDE_MD":
65
- return "Move changelog/history prose out of CLAUDE.md; keep current-state + pointers.";
65
+ return "Move changelogs and history out of CLAUDE.md; keep current rules and links.";
66
66
  case "MEMORY":
67
- return "Prune stale/duplicate memories; consolidate overlapping facts.";
67
+ return "Remove old or duplicate memories and combine overlapping facts.";
68
68
  case "MCP_SCHEMAS":
69
69
  return "Identify rarely-used skills/plugins; extract to on-demand or disable.";
70
70
  default:
71
- return "Trim always-loaded context source to the per-source target.";
71
+ return "Shorten this content that is loaded into every conversation.";
72
72
  }
73
73
  }
74
74
  export const d1Detector = {
@@ -81,12 +81,12 @@ export const d2Detector = {
81
81
  scopeKey: `D2|global|${formula.model}`,
82
82
  category: "CONTEXT",
83
83
  scope_workspace_id: null,
84
- lever: "/clear between unrelated tasks; split long work; avoid mid-task /compact.",
84
+ lever: "Use /clear between unrelated tasks, split long work into separate sessions, and avoid automatic compaction while you are working.",
85
85
  target_metric: "avg_context_per_turn",
86
86
  modeled_savings_u_per_wk: savingsU,
87
87
  modeled_formula: formula,
88
88
  evidence: {
89
- title: `Shorten sessions: ${n} long-context run${n === 1 ? "" : "s"} this week`,
89
+ title: `Split ${n} long session${n === 1 ? "" : "s"} this week`,
90
90
  qualifying_session_count: qualifying.length,
91
91
  qualifying_turn_count: qualifyingTurnCount,
92
92
  session_ids: sessionIds,
@@ -192,7 +192,7 @@ export const d4Detector = {
192
192
  ? {
193
193
  withheld: true,
194
194
  withheld_reason: `Sonnet weekly cap is the binding constraint (Sonnet util ${bindingSonnet.utilization} >= all-models ${perModelSnapshot.seven_day_util}) — routing Opus->Sonnet would worsen it`,
195
- title: `[withheld] Route Opus→Sonnet: ${mismatchPct}% of turns are high-context low-output`,
195
+ title: `Model change is not recommended: ${mismatchPct}% of Opus turns have large context and little output`,
196
196
  }
197
197
  : perModelSnapshot && sonnetEntries && sonnetEntries.length > 0
198
198
  ? { cap_attribution: "all_models_or_opus_binds" }
@@ -202,7 +202,7 @@ export const d4Detector = {
202
202
  category: "MODEL",
203
203
  scope_workspace_id: workspace_id,
204
204
  // Advisory gate (W0.3): which cap binds is NOT inferable from JSONL. Conditional lever.
205
- lever: "If your all-models / Opus / 5h cap is the one binding — check /usage — these high-context low-output Opus turns are Sonnet-movable. This does NOT help, and can hurt, if your Sonnet-specific weekly cap is the binding constraint.",
205
+ lever: "Check /usage first. If your overall, Opus, or 5-hour limit is filling, these large-context Opus turns with little output may be suitable for Sonnet. Do not switch if your Sonnet weekly limit is the one filling.",
206
206
  target_metric: "model_mix_opus_fraction",
207
207
  // Advisory gate: suppress the crisp $/wk headline until live /usage cap-attribution exists.
208
208
  modeled_savings_u_per_wk: null,
@@ -210,7 +210,7 @@ export const d4Detector = {
210
210
  // Destructure out result_usd_per_wk so the advisory formula carries no crisp $/wk figure.
211
211
  modeled_formula: (({ result_usd_per_wk: _, ...rest }) => ({ ...rest, kind: "ADVISORY" }))(formula),
212
212
  evidence: {
213
- title: `Route Opus→Sonnet: ${mismatchPct}% of turns are high-context low-output`,
213
+ title: `Review model choice: ${mismatchPct}% of Opus turns have large context and little output`,
214
214
  workspace_id,
215
215
  total_opus_turns_per_week: totalOpus,
216
216
  mismatch_turns_per_week: mismatchCount,
@@ -262,7 +262,7 @@ export const d6Detector = {
262
262
  modeled_savings_u_per_wk: modeledSavingsU,
263
263
  modeled_formula: formula,
264
264
  evidence: {
265
- title: `Trim tool output: ${Math.round(bloatShare * 100)}% bloat share in session`,
265
+ title: `Reduce large tool results: ${Math.round(bloatShare * 100)}% of this session's context`,
266
266
  session_id: row.session_id,
267
267
  workspace_id: row.workspace_id,
268
268
  tool_result_bytes: row.tool_result_bytes,
@@ -299,7 +299,7 @@ export const d7Detector = {
299
299
  expression: "cap-weighted exposure of turns owning repeat-excess events; not an avoidable-token or USD savings estimate",
300
300
  },
301
301
  evidence: {
302
- title: `Break retry loops: ${flaggedTurns.size} flagged turn${flaggedTurns.size === 1 ? "" : "s"} in session`,
302
+ title: `Stop repeated attempts: ${flaggedTurns.size} affected turn${flaggedTurns.size === 1 ? "" : "s"} in this session`,
303
303
  session_id: sessionId,
304
304
  workspace_id: session.workspaceId,
305
305
  loop_flagged_event_count: flaggedEventIds.size,
@@ -125,7 +125,7 @@ export const d8Detector = {
125
125
  // TTL-regime facet: is this session dominated by 5m-tier creation?
126
126
  const fiveMinCreation = events.reduce((s, e) => s + e.cache_write_5m, 0);
127
127
  const regime5m = totalCreation > 0 && fiveMinCreation / totalCreation >= D8_REGIME_5M_SHARE;
128
- const baseLever = "Use /clear (or resume-from-summary) before idling past the cache TTL, and batch prefix/CLAUDE.md edits to a session boundary so they don't invalidate the warm cache mid-session.";
128
+ const baseLever = "Before a long break, use /clear or resume from a summary. Make instruction-file changes between sessions so they do not rebuild the cache while you work.";
129
129
  const lever = regime5m
130
130
  ? `${baseLever} This session's creation is mostly 5m-tier — enable the 1h cache regime (ENABLE_PROMPT_CACHING_1H) where long pauses are unavoidable.`
131
131
  : baseLever;
@@ -138,7 +138,7 @@ export const d8Detector = {
138
138
  modeled_savings_u_per_wk: savingsU,
139
139
  modeled_formula: formula,
140
140
  evidence: {
141
- title: `Reduce cache-write churn: ${events.length} re-write${events.length === 1 ? "" : "s"} in session`,
141
+ title: `Avoid repeated cache rebuilds: ${events.length} after a long pause`,
142
142
  session_id: sessionId,
143
143
  workspace_id,
144
144
  churn_event_count: events.length,
@@ -62,7 +62,7 @@ export const d9Detector = {
62
62
  modeled_savings_u_per_wk: null,
63
63
  modeled_formula: d9Formula(sidechainCap, D9_UNPRODUCTIVE_FRACTION),
64
64
  evidence: {
65
- title: `Review background fan-out: ${Math.round(share * 100)}% cap-weighted sidechain`,
65
+ title: `Review background-agent work: ${Math.round(share * 100)}% of estimated limit use`,
66
66
  workspace_id: row.workspace_id,
67
67
  sidechain_cap_weighted_tokens: sidechainCap,
68
68
  total_cap_weighted_tokens: totalCap,
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Injected `now: Date` everywhere — never call new Date() in this module.
22
22
  */
23
+ import { PREMIUM_MODEL_SQL } from "../ingest/pricing.js";
23
24
  import { GLOBAL_WORKSPACE_ID } from "./context-probe.js";
24
25
  import { isD1SourceBackedRecommendation, parseD1SourceIdentity } from "./d1-source-identity.js";
25
26
  /** Wait ≥1 day after adoption before entering MEASURING. */
@@ -82,14 +83,14 @@ function avgFloorContext(db, scopeWorkspaceId, fromIso, toIso) {
82
83
  /**
83
84
  * D4 signal: routing-adherence score = ROUND(100 × (1 − premium_share)) over
84
85
  * non-sidechain reconciled turns in [fromIso, toIso), where premium = model
85
- * contains 'opus'. The data-model's "mechanical turn" classifier is not yet
86
+ * matches PREMIUM_MODEL_SQL (Opus/Fable/Mythos). The data-model's "mechanical turn" classifier is not yet
86
87
  * shipped, so all reconciled turns are the mechanical set (conservative proxy —
87
88
  * documented in the W4 build digest).
88
89
  */
89
90
  function routingAdherenceScore(db, scopeWorkspaceId, fromIso, toIso) {
90
91
  const scoped = scopeWorkspaceId !== null;
91
92
  const sql = `SELECT COUNT(*) AS n,
92
- AVG(CASE WHEN model LIKE '%opus%' THEN 1.0 ELSE 0.0 END) AS premium_share
93
+ AVG(CASE WHEN ${PREMIUM_MODEL_SQL} THEN 1.0 ELSE 0.0 END) AS premium_share
93
94
  FROM turns
94
95
  WHERE is_sidechain = 0 AND provisional = 0 AND ts >= ? AND ts < ? ${scoped ? "AND workspace_id = ?" : ""}`;
95
96
  const params = scoped ? [fromIso, toIso, scopeWorkspaceId] : [fromIso, toIso];