agentwrangler 0.1.0 → 0.1.1-next.2e8c141

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 (49) hide show
  1. package/README.md +152 -84
  2. package/dist/daemon/index.js +9 -3
  3. package/dist/daemon/readiness.js +26 -1
  4. package/dist/daemon/router.js +3 -1
  5. package/dist/db/migrations/016_ingest_offset_file_version.sql +8 -0
  6. package/dist/db/migrations/017_ingest_metric_events.sql +22 -0
  7. package/dist/detector/measurement.js +3 -2
  8. package/dist/hook/precompact-checkpoint-hook.mjs +51 -15
  9. package/dist/ingest/ingestor.js +151 -62
  10. package/dist/ingest/pricing.js +15 -0
  11. package/dist/ingest/tail.js +55 -11
  12. package/dist/query/api/outcomes.js +2 -1
  13. package/dist/query/api/overview.js +13 -3
  14. package/dist/query/settings-store.js +2 -0
  15. package/dist/ui/assets/BarChart-BwFZrcLn.js +3 -0
  16. package/dist/ui/assets/BriefsPage-CtJwHLOh.js +2 -0
  17. package/dist/ui/assets/CacheWriteSpikesChart-zJIJWO41.js +1 -0
  18. package/dist/ui/assets/CartesianChart-qApXl3Ak.js +84 -0
  19. package/dist/ui/assets/Chip-Cpt_fEc9.js +1 -0
  20. package/dist/ui/assets/ComposedChart-DYf1wymW.js +1 -0
  21. package/dist/ui/assets/EmptyState-DL8gAVnJ.js +1 -0
  22. package/dist/ui/assets/FlavorDecomposition-CvJW78M9.js +1 -0
  23. package/dist/ui/assets/FrictionCell-DciiSd3L.js +2 -0
  24. package/dist/ui/assets/GlossaryPage-BMkZGt2D.js +1 -0
  25. package/dist/ui/assets/HotSessionsPage-Ejn385bb.js +1 -0
  26. package/dist/ui/assets/InfoTip-Ckc9_LpJ.js +1 -0
  27. package/dist/ui/assets/Legend-C_YMp0Rv.js +5 -0
  28. package/dist/ui/assets/Line-DU4UvEBO.js +1 -0
  29. package/dist/ui/assets/OverviewPage-BcEKdWZj.js +2 -0
  30. package/dist/ui/assets/RecommendationsPage-CHfuNP_W.js +2 -0
  31. package/dist/ui/assets/Scatter-CNetVLuX.js +1 -0
  32. package/dist/ui/assets/SessionDetailPage-CYm5Gxo0.js +1 -0
  33. package/dist/ui/assets/SettingsPage-DjgGZj8N.js +25 -0
  34. package/dist/ui/assets/Skeleton-BO9mXDXU.js +1 -0
  35. package/dist/ui/assets/SpendPercentileChip-BvaZ8Lzi.js +1 -0
  36. package/dist/ui/assets/TrendChart-DKy9aH5G.js +1 -0
  37. package/dist/ui/assets/WorkspaceDetailPage-dutc4EpN.js +1 -0
  38. package/dist/ui/assets/WorkspacesPage-isSML2fh.js +1 -0
  39. package/dist/ui/assets/chart-theme-BPPMjbVX.js +1 -0
  40. package/dist/ui/assets/graphicalItemSelectors-DgKF1Dg2.js +1 -0
  41. package/dist/ui/assets/index-CUyiomzU.js +41 -0
  42. package/dist/ui/assets/index-SrfNUeBZ.css +1 -0
  43. package/dist/ui/assets/prompt-templates-DhR4Qoy9.js +13 -0
  44. package/dist/ui/assets/useExperimentalActions-BeDSmwBu.js +1 -0
  45. package/dist/ui/assets/workspace-label-CysPJNc0.js +1 -0
  46. package/dist/ui/index.html +2 -2
  47. package/package.json +21 -5
  48. package/dist/ui/assets/index-DnRKgc21.css +0 -1
  49. package/dist/ui/assets/index-h1Q1wWq5.js +0 -168
package/README.md CHANGED
@@ -1,116 +1,184 @@
1
- # AgentWrangler
1
+ <p align="center">
2
+ <img src="docs/assets/logo.png" alt="AgentWrangler logo" width="260">
3
+ </p>
2
4
 
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.
5
+ <h1 align="center">AgentWrangler</h1>
7
6
 
8
- ## Local-only — your data never leaves your machine
7
+ <p align="center">
8
+ <b>See where your Claude Code tokens go — and whether the work actually shipped.</b><br>
9
+ Local-first observability for Claude Code: token spend, session outcomes, waste detection,
10
+ and installable guardrails. Runs entirely on your machine.
11
+ </p>
9
12
 
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.
16
-
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.
13
+ <p align="center">
14
+ <a href="https://www.npmjs.com/package/agentwrangler"><img src="https://img.shields.io/npm/v/agentwrangler" alt="npm version"></a>
15
+ <a href="https://github.com/Doogit/AgentWrangler/actions/workflows/ci.yml"><img src="https://github.com/Doogit/AgentWrangler/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
+ <a href="https://img.shields.io/node/v/agentwrangler"><img src="https://img.shields.io/node/v/agentwrangler" alt="node version"></a>
17
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue" alt="license"></a>
18
+ </p>
21
19
 
22
20
  ![AgentWrangler dashboard — spend verdict, model mix, recommendations, and per-repo efficiency](docs/assets/dashboard.gif)
23
21
 
24
22
  <!--
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`.
23
+ All screenshots and the demo GIF in this README are captured from a SANITIZED instance
24
+ (Vite test-mode fixtures — anonymized names, no live data) so no real workspace/repository
25
+ names or paths are committed (SEC-101). Regenerate against `npx vite --mode test`.
28
26
  -->
29
27
 
30
- ## Quick start
28
+ ## Why
31
29
 
32
- Requirements: **Node `>=22 <25`** and npm.
30
+ Claude Code tells you almost nothing about where your token budget goes. Sessions balloon,
31
+ caches miss, background agents idle — and you find out when you hit the rate limit.
32
+ AgentWrangler reads the transcripts Claude Code already writes to your disk and answers three
33
+ questions:
33
34
 
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
- ```
35
+ - **Where did the tokens go?** Per-model, per-workspace, per-session spend with cache economics.
36
+ - **Did the work ship?** Sessions are linked to the pull requests they merged or closed.
37
+ - **What should I change?** Ranked waste-source detectors with modeled savings — and one-click
38
+ guardrail hooks that warn *inside* Claude Code before waste happens.
40
39
 
41
- Then open **http://127.0.0.1:47821** (the daemon opens it for you unless `AW_NO_OPEN=1`).
40
+ No cloud backend, no telemetry, no account. A daemon on `127.0.0.1`, a dashboard in your
41
+ browser, and a SQLite file in your home directory.
42
42
 
43
- After installing dependencies you can also do it in one step:
43
+ ## Quick start
44
44
 
45
45
  ```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
46
+ npx agentwrangler@latest
48
47
  ```
49
48
 
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.
49
+ That's it requires Node **22–24**. The daemon starts on `http://127.0.0.1:47821`, opens your
50
+ browser, and scans your `~/.claude/projects` transcripts in the background; the dashboard
51
+ appears immediately and fills in as the scan completes.
53
52
 
54
- ## Optional setup
53
+ More options (install from source, GitHub outcomes sync, environment variables):
54
+ **[Getting started →](docs/getting-started.md)**
55
55
 
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`).
56
+ ## Features
64
57
 
65
- ## Configuration (environment variables)
58
+ ### Overview verdict first, details on demand
66
59
 
67
- All are optional; sensible defaults apply. See [`.env.example`](.env.example) for the full list.
60
+ One screen answers "how bad is it this week": a spend verdict with trend, your top waste
61
+ source with a copyable fix prompt, live rate-limit gauges (5-hour and 7-day), a burn forecast
62
+ against your calibrated weekly limit, hot sessions, cache efficiency, and per-model
63
+ context-per-turn tiles.
68
64
 
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)* |
65
+ ![Overview tab at-a-glance verdict, rate limits, burn forecast, hot sessions](docs/assets/overview.png)
77
66
 
78
- ## What it measures
67
+ ### Recommendations waste-source detectors, ranked by impact
79
68
 
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).
69
+ Ten detector families watch your sessions for the patterns that actually burn tokens: cache
70
+ misses (the biggest single lever), session hygiene, retry/redundant-read loops, tool-result
71
+ bloat, model routing, idle background sessions, and more. Each recommendation shows modeled
72
+ weekly savings, a confidence tier, and a concrete action install a hook, copy a config
73
+ snippet, or copy a guided prompt straight into Claude Code. Adopted changes flow into an
74
+ **impact ledger** that tracks the measured effect, and modeled savings are never counted as
75
+ achieved.
85
76
 
86
- ## Limitations
77
+ ![Recommendations tab — ranked detector families with modeled savings and one-click actions](docs/assets/recommendations.png)
87
78
 
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.
79
+ ### Installable guardrails local checks inside Claude Code, before the waste
96
80
 
97
- ## License
81
+ Five small hooks you can install from the dashboard (directly, or via a copyable prompt that
82
+ Claude Code applies itself):
83
+
84
+ | Guardrail | What it does |
85
+ |---|---|
86
+ | **Context-budget warning** | Warns when a session's context crosses your soft/hard thresholds |
87
+ | **Loop guard** | Flags repeated identical tool failures before they spiral |
88
+ | **Burn alert** | Catches idle sessions still burning tokens in the background |
89
+ | **Pre-compaction checkpoint** | Copies the raw local transcript before an automatic compaction, subject to a local retention cap |
90
+ | **Dangerous-command guard** | Asks before risky shell commands and denies a small catastrophe list |
91
+
92
+ The context-budget and burn hooks warn. The loop guard warns before it denies repeated identical
93
+ failures, and the dangerous-command guard can ask or deny. Direct install enables all five hooks;
94
+ the copied install prompt enables the context-budget, loop, and burn hooks only. Thresholds are
95
+ tunable from Settings, and direct uninstall removes every AgentWrangler hook.
96
+
97
+ ### Sessions — who spent it, and on what
98
+
99
+ The highest-cost sessions ranked with their output-to-context split, model, friction band
100
+ (API errors, tool failures, compactions, interrupts), and a "top X% by spend" self-percentile
101
+ chip. Drill into any session for a turn-by-turn timeline, its cost drivers (which detectors
102
+ fired and how hard), and a guided fix prompt built only from measured numbers.
103
+
104
+ ![Sessions tab — highest-cost sessions with friction bands and spend percentiles](docs/assets/sessions.png)
105
+
106
+ <details>
107
+ <summary>Session detail view</summary>
108
+
109
+ ![Session detail — per-session KPIs, cost drivers, and a measured-context fix prompt](docs/assets/session-detail.png)
110
+ </details>
111
+
112
+ ### Workspaces — spend efficiency by repository
113
+
114
+ Every repo you run Claude Code in, with spend share, trend, context-per-turn, cache-write
115
+ share, Opus share, and $/turn. With a GitHub token configured, sessions are linked to the PRs
116
+ and commits they produced — so you can see cost-per-merged-PR, not just cost.
98
117
 
99
- Licensed under the [Apache License 2.0](LICENSE). Copyright 2026 AgentWrangler contributors.
118
+ ![Workspaces tab per-repository spend, efficiency, and outcome linkage](docs/assets/workspaces.png)
100
119
 
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).
120
+ <details>
121
+ <summary>Workspace detail view</summary>
103
122
 
104
- ## Documentation index
123
+ ![Workspace detail — top sessions, context composition, and outcomes](docs/assets/workspace-detail.png)
124
+ </details>
105
125
 
106
- | Path | Purpose |
126
+ ### Weekly brief one page, three decisions
127
+
128
+ The week in one screen: spend verdict, what changed vs. last week, the top actions to take —
129
+ with a **Copy as Markdown** button so the whole brief drops into a standup note or a message.
130
+
131
+ ![Briefs tab — weekly verdict, week-over-week deltas, and top actions](docs/assets/briefs.png)
132
+
133
+ ### Honest numbers, labeled as such
134
+
135
+ Every metric carries an honesty-tier chip — `EXACT`, `LIST_EQUIV`, `MODELED`, `PROXY`,
136
+ `DIRECTIONAL`, `EXPERIMENTAL` — so you always know what is measured versus estimated. Dollar
137
+ figures are list-price *equivalents* (subscription plans aren't billed per token; tokens drive
138
+ rate limits), and the built-in glossary ("How to read this dashboard") defines every
139
+ key metric in plain language. The full tour: **[Dashboard tour →](docs/dashboard-tour.md)**
140
+
141
+ ## Privacy — local-only by design
142
+
143
+ - The daemon binds to **`127.0.0.1`** only. There is no cloud backend, telemetry, or account.
144
+ - Most dashboard data is **aggregates, ids, counts, and structural anchors**. Local command text
145
+ and filesystem paths can also be retained in SQLite; treat the database as sensitive. The optional
146
+ direct-install PreCompact hook can separately copy full raw transcripts locally.
147
+ - The optional GitHub token is read locally, never logged, never persisted to the DB.
148
+ - Usage refreshes can call Anthropic with an existing Claude Code sign-in. GitHub outcomes sync
149
+ calls GitHub only when a token is configured; calibration and G2 judging are separate opt-ins.
150
+
151
+ Full details, including exactly what is and isn't stored: **[Privacy model →](docs/privacy.md)**
152
+
153
+ ## Configuration
154
+
155
+ Everything is optional with sensible defaults — port, DB path, scan roots, GitHub token, and
156
+ more are environment variables documented in [Getting started](docs/getting-started.md#configuration)
157
+ and [`.env.example`](.env.example).
158
+
159
+ ## Documentation
160
+
161
+ | Page | What's in it |
107
162
  |---|---|
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 |
163
+ | [Getting started](docs/getting-started.md) | Install paths, optional setup, configuration, troubleshooting |
164
+ | [Dashboard tour](docs/dashboard-tour.md) | Every tab in depth, plus the metric vocabulary |
165
+ | [Privacy model](docs/privacy.md) | Local storage, raw-checkpoint exception, and network integrations |
166
+ | [Architecture](docs/planning/AgentWrangler_Technical_Architecture_v4_5_0.md) | Daemon, ingestion, detector, and query design |
167
+ | [Data model & metrics](docs/planning/AgentWrangler_Data_Model_and_Metrics_v2.md) | SQLite schema and metric definitions |
168
+ | [Contributing](.github/CONTRIBUTING.md) | Dev setup, checks, PR expectations |
169
+ | [Security policy](.github/SECURITY.md) | Threat model and how to report a vulnerability |
170
+
171
+ ## Limitations
172
+
173
+ - Reads Claude Code's JSONL transcript format; an upstream format change can require an
174
+ ingestion update.
175
+ - Tested on Windows; macOS/Linux are believed working — reports welcome.
176
+ - Single local user by design — no multi-user or team-aggregation mode.
177
+ - Outcome linkage needs a read-only GitHub token; without one the feature stays inert (and
178
+ Settings says so — nothing fails silently).
179
+
180
+ ## License
181
+
182
+ [Apache 2.0](LICENSE) © 2026 AgentWrangler contributors.
183
+ See [CONTRIBUTING.md](.github/CONTRIBUTING.md), [SECURITY.md](.github/SECURITY.md), and
184
+ [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md).
@@ -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, {
@@ -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
+ );
@@ -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];
@@ -53,6 +53,32 @@ export function writeCheckpoint(transcriptPath, sessionId, dir, now = new Date()
53
53
  return dest;
54
54
  }
55
55
 
56
+ /**
57
+ * Return the timestamp encoded by a snapshot filename, or null for files this hook did
58
+ * not create. Malformed .jsonl files are deliberately left alone by retention.
59
+ */
60
+ function snapshotTimestamp(name) {
61
+ const match = /-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z-\d{6}\.jsonl$/.exec(
62
+ name,
63
+ );
64
+ if (!match) return null;
65
+ const [, year, month, day, hour, minute, second, millisecond] = match;
66
+ const timestamp = Date.parse(
67
+ `${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}Z`,
68
+ );
69
+ if (Number.isNaN(timestamp)) return null;
70
+ const parsed = new Date(timestamp);
71
+ return parsed.getUTCFullYear() === Number(year) &&
72
+ parsed.getUTCMonth() + 1 === Number(month) &&
73
+ parsed.getUTCDate() === Number(day) &&
74
+ parsed.getUTCHours() === Number(hour) &&
75
+ parsed.getUTCMinutes() === Number(minute) &&
76
+ parsed.getUTCSeconds() === Number(second) &&
77
+ parsed.getUTCMilliseconds() === Number(millisecond)
78
+ ? timestamp
79
+ : null;
80
+ }
81
+
56
82
  /** Prune oldest snapshots beyond the count cap, then beyond the total-byte cap. */
57
83
  export function enforceRetention(dir, maxCount = MAX_COUNT, maxBytes = MAX_BYTES) {
58
84
  let entries;
@@ -60,30 +86,40 @@ export function enforceRetention(dir, maxCount = MAX_COUNT, maxBytes = MAX_BYTES
60
86
  entries = fs
61
87
  .readdirSync(dir)
62
88
  .filter((name) => name.endsWith(".jsonl"))
63
- .sort() // timestamp-derived names sort chronologically
64
89
  .map((name) => {
65
90
  const full = path.join(dir, name);
66
- return { full, size: fs.statSync(full).size };
67
- });
91
+ const timestamp = snapshotTimestamp(name);
92
+ if (timestamp === null) return null;
93
+ try {
94
+ const stat = fs.statSync(full);
95
+ if (!stat.isFile()) return null;
96
+ return { full, name, size: stat.size, timestamp, mtimeMs: stat.mtimeMs };
97
+ } catch {
98
+ // A vanished or unreadable entry must not prevent pruning known snapshots.
99
+ return null;
100
+ }
101
+ })
102
+ .filter((entry) => entry !== null)
103
+ .sort(
104
+ (a, b) =>
105
+ a.timestamp - b.timestamp || a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name),
106
+ );
68
107
  } catch {
69
108
  return; // dir missing or unreadable — nothing to prune
70
109
  }
71
110
 
72
- const removeOldest = () => {
73
- const victim = entries.shift();
74
- if (!victim) return;
111
+ let count = entries.length;
112
+ let total = entries.reduce((sum, entry) => sum + entry.size, 0);
113
+ for (const victim of entries) {
114
+ if (count <= maxCount && total <= maxBytes) break;
75
115
  try {
76
116
  fs.rmSync(victim.full);
77
- } catch {
78
- // A locked or already-removed snapshot is not worth failing for.
117
+ } catch (error) {
118
+ // A locked snapshot still counts. Try each later candidate at most once.
119
+ if (error?.code !== "ENOENT") continue;
79
120
  }
80
- };
81
-
82
- while (entries.length > maxCount) removeOldest();
83
- let total = entries.reduce((sum, entry) => sum + entry.size, 0);
84
- while (entries.length > 0 && total > maxBytes) {
85
- total -= entries[0].size;
86
- removeOldest();
121
+ count -= 1;
122
+ total -= victim.size;
87
123
  }
88
124
  }
89
125