agentwrangler 0.1.1-next.fbbbe7f → 0.1.1-next.fbffef0

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 (108) hide show
  1. package/README.md +82 -162
  2. package/dist/apply/jobs.js +13 -3
  3. package/dist/daemon/effect-pass.js +24 -0
  4. package/dist/daemon/http.js +10 -0
  5. package/dist/daemon/index.js +48 -87
  6. package/dist/daemon/lifecycle.js +122 -0
  7. package/dist/daemon/router.js +296 -14
  8. package/dist/db/migrations/018_effect_cycles.sql +109 -0
  9. package/dist/db/migrations/019_work_records.sql +291 -0
  10. package/dist/detector/context-history-retention.js +49 -0
  11. package/dist/detector/detectors/d10_catalog_footprint.js +2 -2
  12. package/dist/detector/detectors/d1_ctx_always_loaded.js +9 -9
  13. package/dist/detector/detectors/d2_session_long_full_context.js +2 -2
  14. package/dist/detector/detectors/d4_model_mismatch.js +3 -3
  15. package/dist/detector/detectors/d6_tool_result_bloat.js +1 -1
  16. package/dist/detector/detectors/d7_loop_retry_waste.js +1 -1
  17. package/dist/detector/detectors/d8_cache_write_churn.js +2 -2
  18. package/dist/detector/detectors/d9_idle_background_session.js +1 -1
  19. package/dist/detector/measurement.js +14 -3
  20. package/dist/effects/api-contract.js +1 -0
  21. package/dist/effects/engine.js +574 -0
  22. package/dist/effects/gate.js +2 -0
  23. package/dist/effects/index.js +5 -0
  24. package/dist/effects/legacy.js +28 -0
  25. package/dist/effects/registry.js +152 -0
  26. package/dist/effects/sql-observer.js +238 -0
  27. package/dist/effects/store.js +181 -0
  28. package/dist/effects/types.js +2 -0
  29. package/dist/hook/precompact-checkpoint-hook.mjs +26 -13
  30. package/dist/ingest/ingestor.js +322 -73
  31. package/dist/ingest/pricing.js +15 -0
  32. package/dist/ingest/tail.js +130 -20
  33. package/dist/query/api/cost-per-success.js +13 -1
  34. package/dist/query/api/delivery.js +25 -3
  35. package/dist/query/api/effect-service.js +308 -0
  36. package/dist/query/api/effectiveness.js +30 -5
  37. package/dist/query/api/efficiency-headroom.js +12 -14
  38. package/dist/query/api/esf-observations.js +285 -0
  39. package/dist/query/api/outcomes.js +2 -1
  40. package/dist/query/api/overview.js +105 -6
  41. package/dist/query/api/recommendations-ledger.js +9 -14
  42. package/dist/query/api/recommendations.js +21 -1
  43. package/dist/query/api/reported-work.js +46 -0
  44. package/dist/query/api/spend-flavor.js +22 -15
  45. package/dist/query/api/trends.js +86 -6
  46. package/dist/query/api/work-records.js +85 -0
  47. package/dist/query/settings-store.js +13 -0
  48. package/dist/query/spend.js +91 -15
  49. package/dist/query/trends.js +27 -0
  50. package/dist/ui/assets/{BarChart-p1usctxJ.js → BarChart-BOecTQgC.js} +3 -3
  51. package/dist/ui/assets/BriefsPage-CjvXspiw.js +2 -0
  52. package/dist/ui/assets/CacheWriteSpikesChart-5Pn1sUZC.js +1 -0
  53. package/dist/ui/assets/{chart-theme-JH5aQymO.js → CartesianChart-56rkZQt4.js} +36 -36
  54. package/dist/ui/assets/Chip-BZTcuGOm.js +1 -0
  55. package/dist/ui/assets/ComposedChart-C8PPdkFl.js +1 -0
  56. package/dist/ui/assets/{EmptyState-CvQeJjjG.js → EmptyState-BfD86Pv3.js} +1 -1
  57. package/dist/ui/assets/FlavorDecomposition-BsOuw4CF.js +1 -0
  58. package/dist/ui/assets/FrictionCell-DQBJsot-.js +2 -0
  59. package/dist/ui/assets/GlossaryPage-DutLIAlv.js +1 -0
  60. package/dist/ui/assets/HotSessionsPage-DLWlyBfb.js +1 -0
  61. package/dist/ui/assets/{InfoTip-Dx7BHpBu.js → InfoTip-BrYyvCNl.js} +1 -1
  62. package/dist/ui/assets/{Legend--q6-rQGk.js → Legend-DzSAmt8-.js} +3 -3
  63. package/dist/ui/assets/{Line-DzVtUwF4.js → Line-1eksYo3m.js} +1 -1
  64. package/dist/ui/assets/Modal-DX-JN5Ul.js +1 -0
  65. package/dist/ui/assets/ObservationEvidence-Bbsc-DCW.js +1 -0
  66. package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
  67. package/dist/ui/assets/OverviewPage-CWgzRIqk.js +2 -0
  68. package/dist/ui/assets/RecommendationsPage-sRHEku70.js +2 -0
  69. package/dist/ui/assets/{Scatter-Cr_hCkjG.js → Scatter-Ys7Lbhjf.js} +1 -1
  70. package/dist/ui/assets/SessionDetailPage-L02ituTQ.js +1 -0
  71. package/dist/ui/assets/SettingsPage-DJjWicvx.js +25 -0
  72. package/dist/ui/assets/{Skeleton-CcD61x7q.js → Skeleton-ab1u7ntu.js} +1 -1
  73. package/dist/ui/assets/SpendPercentileChip-DnRJWBUP.js +1 -0
  74. package/dist/ui/assets/TrendChart-_nohASRq.js +1 -0
  75. package/dist/ui/assets/WorkspaceDetailPage-BQ3XA3Hf.js +1 -0
  76. package/dist/ui/assets/WorkspacesPage-DKdpBgC9.js +1 -0
  77. package/dist/ui/assets/chart-theme-0POFlal8.js +1 -0
  78. package/dist/ui/assets/graphicalItemSelectors-B97PsJDp.js +1 -0
  79. package/dist/ui/assets/{index-DRhoA51c.js → index-DAAmee2m.js} +10 -10
  80. package/dist/ui/assets/{index-SrfNUeBZ.css → index-D_IJsvkJ.css} +1 -1
  81. package/dist/ui/assets/{prompt-templates-DhR4Qoy9.js → prompt-templates-CVzMex9L.js} +4 -4
  82. package/dist/ui/assets/rec-sessions-ggz9MYgP.js +1 -0
  83. package/dist/ui/assets/{useExperimentalActions-BPDdswCN.js → useExperimentalActions-avkWaDmQ.js} +1 -1
  84. package/dist/ui/index.html +2 -2
  85. package/dist/work-records/allocation.js +242 -0
  86. package/dist/work-records/ids.js +80 -0
  87. package/dist/work-records/index.js +4 -0
  88. package/dist/work-records/store.js +376 -0
  89. package/dist/work-records/types.js +29 -0
  90. package/dist/work-records/validation.js +71 -0
  91. package/package.json +2 -1
  92. package/dist/ui/assets/BriefsPage-CneqLVGC.js +0 -2
  93. package/dist/ui/assets/CacheWriteSpikesChart-zNRZbzy9.js +0 -1
  94. package/dist/ui/assets/Chip-Bu6udQGq.js +0 -1
  95. package/dist/ui/assets/ComposedChart-8D5E-Po_.js +0 -1
  96. package/dist/ui/assets/FlavorDecomposition-xcMLA50f.js +0 -1
  97. package/dist/ui/assets/FrictionCell-OafGLGxT.js +0 -2
  98. package/dist/ui/assets/GlossaryPage-BmOP3Syj.js +0 -1
  99. package/dist/ui/assets/HotSessionsPage-FoxuuDLF.js +0 -1
  100. package/dist/ui/assets/OverviewPage-BjSQYNvS.js +0 -2
  101. package/dist/ui/assets/RecommendationsPage-CRTpzcHs.js +0 -2
  102. package/dist/ui/assets/SessionDetailPage-Dd3u8kVI.js +0 -1
  103. package/dist/ui/assets/SettingsPage-BPVQyjlc.js +0 -25
  104. package/dist/ui/assets/SpendPercentileChip-BaQhJQAn.js +0 -1
  105. package/dist/ui/assets/TrendChart-Baz3GGgE.js +0 -1
  106. package/dist/ui/assets/WorkspaceDetailPage-yk4z_EtQ.js +0 -1
  107. package/dist/ui/assets/WorkspacesPage-BDtclXJP.js +0 -1
  108. package/dist/ui/assets/graphicalItemSelectors-CCbl8AKC.js +0 -1
package/README.md CHANGED
@@ -1,184 +1,104 @@
1
1
  <p align="center">
2
- <img src="docs/assets/logo.png" alt="AgentWrangler logo" width="260">
2
+ <img src="https://raw.githubusercontent.com/Doogit/AgentWrangler/main/docs/assets/logo.png" alt="AgentWrangler logo" width="200">
3
3
  </p>
4
4
 
5
- <h1 align="center">AgentWrangler</h1>
5
+ # AgentWrangler
6
6
 
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>
12
-
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>
19
-
20
- ![AgentWrangler dashboard — spend verdict, model mix, recommendations, and per-repo efficiency](docs/assets/dashboard.gif)
21
-
22
- <!--
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`.
26
- -->
27
-
28
- ## Why
29
-
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:
34
-
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.
39
-
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.
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.
42
10
 
43
11
  ## Quick start
44
12
 
13
+ Requires **Node 22-24 and npm**, plus Claude Code transcripts for populated charts.
14
+
45
15
  ```sh
46
16
  npx agentwrangler@latest
47
17
  ```
48
18
 
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.
52
-
53
- More options (install from source, GitHub outcomes sync, environment variables):
54
- **[Getting started →](docs/getting-started.md)**
55
-
56
- ## Features
57
-
58
- ### Overview — verdict first, details on demand
59
-
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.
64
-
65
- ![Overview tab — at-a-glance verdict, rate limits, burn forecast, hot sessions](docs/assets/overview.png)
66
-
67
- ### Recommendations — waste-source detectors, ranked by impact
68
-
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.
76
-
77
- ![Recommendations tab — ranked detector families with modeled savings and one-click actions](docs/assets/recommendations.png)
78
-
79
- ### Installable guardrails — local checks inside Claude Code, before the waste
80
-
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.
117
-
118
- ![Workspaces tab — per-repository spend, efficiency, and outcome linkage](docs/assets/workspaces.png)
119
-
120
- <details>
121
- <summary>Workspace detail view</summary>
122
-
123
- ![Workspace detail — top sessions, context composition, and outcomes](docs/assets/workspace-detail.png)
124
- </details>
125
-
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.
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.
130
23
 
131
- ![Briefs tab weekly verdict, week-over-week deltas, and top actions](docs/assets/briefs.png)
24
+ [Install from source, configure scan roots, or troubleshoot](https://github.com/Doogit/AgentWrangler/blob/main/docs/getting-started.md).
132
25
 
133
- ### Honest numbers, labeled as such
26
+ ## Your first five minutes
134
27
 
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)**
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.
140
43
 
141
- ## Privacy local-only by design
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.
142
48
 
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.
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).
150
50
 
151
- Full details, including exactly what is and isn't stored: **[Privacy model →](docs/privacy.md)**
51
+ ## Dashboard preview
152
52
 
153
- ## Configuration
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)
154
54
 
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).
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.
158
57
 
159
- ## Documentation
58
+ ## Pick a question
160
59
 
161
- | Page | What's in it |
60
+ | I want to... | Start here |
162
61
  |---|---|
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).
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) |
68
+
69
+ ## Installable guardrails — local checks inside Claude Code, before the waste
70
+
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.
73
+
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;
@@ -298,7 +302,10 @@ function completeApplyPhase(db, job, rec, result, rt) {
298
302
  let runId = null;
299
303
  const completedAt = rt.now();
300
304
  try {
301
- adoptRecommendation(job.rec_id, completedAt.getTime());
305
+ adoptRecommendation(job.rec_id, completedAt.getTime(), {
306
+ machineConfirmed: true,
307
+ actionRevision: job.job_id,
308
+ });
302
309
  runId = randomUUID();
303
310
  const evidencePackHash = createHash("sha256")
304
311
  .update(JSON.stringify(rec.evidence))
@@ -413,6 +420,9 @@ export function rollbackApplyJob(jobId) {
413
420
  if (row.status !== "APPLIED" && row.status !== "CONFIRMING") {
414
421
  throw new Error("job cannot be rolled back from this state");
415
422
  }
423
+ if (db.prepare("SELECT 1 FROM effect_cycles WHERE rec_id=? LIMIT 1").get(row.rec_id)) {
424
+ throw new Error("Whole-file backup rollback is not a supported effect inverse. Revert manually and attest the rollback in the effect ledger.");
425
+ }
416
426
  if (row.backup_path === null)
417
427
  throw new Error("no backup available");
418
428
  fs.copyFileSync(row.backup_path, row.file_ref);
@@ -0,0 +1,24 @@
1
+ import { runMeasurementPass } from "../detector/measurement.js";
2
+ import { ESF_EFFECT_WRITER_ENABLED } from "../effects/gate.js";
3
+ import { effectEngineForDb } from "../query/api/effect-service.js";
4
+ const INTERVAL_MS = 60 * 60 * 1000;
5
+ const lastPass = new WeakMap();
6
+ /** Probe-clock scheduling; restarting can safely replay the bounded, idempotent engine pass. */
7
+ export function runEffectMeasurementPass(db, now) {
8
+ if (!ESF_EFFECT_WRITER_ENABLED) {
9
+ runMeasurementPass(db, now);
10
+ return;
11
+ }
12
+ const previous = lastPass.get(db);
13
+ const current = now.getTime();
14
+ if (previous !== undefined && current >= previous && current - previous < INTERVAL_MS)
15
+ return;
16
+ lastPass.set(db, current);
17
+ try {
18
+ effectEngineForDb(db).runPass(now);
19
+ }
20
+ catch {
21
+ // Never expose source identities or DB contents in a background error.
22
+ console.warn("Effect measurement pass failed; retrying on the next scheduled pass.");
23
+ }
24
+ }
@@ -138,6 +138,16 @@ function requiresSessionToken(pathname) {
138
138
  return true;
139
139
  if (/^\/api\/recommendations\/jobs\/[^/]+\/(confirm|rollback)$/.test(pathname))
140
140
  return true;
141
+ if (/^\/api\/esf\/effects\/(track|stop|close|rollback|attest-rollback)$/.test(pathname))
142
+ return true;
143
+ if (pathname === "/api/work-records" || pathname === "/api/work-records/ids")
144
+ return true;
145
+ if (/^\/api\/work-records\/[^/]+\/(edit|closeout|archive|reopen|sessions|contexts|delete)$/.test(pathname))
146
+ return true;
147
+ if (/^\/api\/work-records\/[^/]+\/(sessions|contexts)\/[^/]+\/detach$/.test(pathname))
148
+ return true;
149
+ if (pathname === "/api/work-records/allocations/recompute")
150
+ return true;
141
151
  return false;
142
152
  }
143
153
  /**
@@ -22,7 +22,6 @@ import { openDb } from "../db/open.js";
22
22
  import { configGet as bptConfigGet, calibrateBytesPerToken } from "../detector/calibration.js";
23
23
  import { runContextProbe } from "../detector/context-probe.js";
24
24
  import { runDetectors } from "../detector/index.js";
25
- import { runMeasurementPass } from "../detector/measurement.js";
26
25
  import { installHook, uninstallHook } from "../hook/install.js";
27
26
  import { collectSessionChurn } from "../ingest/churn-collector.js";
28
27
  import { runPostProbeHook, setPostIngestHook, setPostProbeHook } from "../ingest/detector-hook.js";
@@ -37,7 +36,9 @@ import { generateWeeklyReport } from "../query/api/reports.js";
37
36
  import { setQueryDb } from "../query/db-context.js";
38
37
  import { getSettingsData, setHealthInstance, setRuntimeResetHook, } from "../query/settings-store.js";
39
38
  import { loadConfig } from "./config.js";
39
+ import { runEffectMeasurementPass } from "./effect-pass.js";
40
40
  import { createServer } from "./http.js";
41
+ import { createDaemonLifecycle } from "./lifecycle.js";
41
42
  import { createOutcomesPassRunner } from "./outcomes-pass.js";
42
43
  import { setReady, setScanRoots, setScanState } from "./readiness.js";
43
44
  const VERSION = "0.1.0";
@@ -107,6 +108,10 @@ if (isSmoke) {
107
108
  "review_findings",
108
109
  "recommendations",
109
110
  "recommendation_effects",
111
+ "effect_cycles",
112
+ "effect_guardrail_results",
113
+ "effect_rollback_operations",
114
+ "effect_mutation_keys",
110
115
  "apply_jobs",
111
116
  "analysis_runs",
112
117
  "ingest_quarantine",
@@ -150,18 +155,16 @@ function runProbePass(label) {
150
155
  const now = new Date();
151
156
  const { rows } = runContextProbe(db, now);
152
157
  console.log(`ContextProbe[${label}]: ${rows} row(s) upserted`);
153
- // W4: measurement pass after each probe (best-effort; the seam never throws
154
- // through, and runMeasurementPass itself is guarded/log-not-throw).
158
+ // Best-effort measurement after each probe, using the same observation clock.
155
159
  runPostProbeHook(db, now);
156
160
  }
157
161
  catch (e) {
158
162
  console.log(`ContextProbe[${label}]: skipped — ${e instanceof Error ? e.message : String(e)}`);
159
163
  }
160
164
  }
161
- // Wire the W4 Impact Ledger measurement pass onto the post-probe seam. The pass
162
- // receives the probe's clock (deterministic; NFR-107), is throttled internally.
165
+ // Versioned measurement owns new cycles; W4 history stays read-only under the code gate.
163
166
  setPostProbeHook((probeDb, now) => {
164
- runMeasurementPass(probeDb, now);
167
+ runEffectMeasurementPass(probeDb, now);
165
168
  });
166
169
  runProbePass("boot");
167
170
  // ── Outcomes bootstrap (WP5 — best-effort, never crash daemon) ────────────────
@@ -169,10 +172,6 @@ runProbePass("boot");
169
172
  // The runner (src/daemon/outcomes-pass.ts) adds a hard 15-min per-pass deadline:
170
173
  // a hung pass can no longer wedge the poll cadence forever — the running flag
171
174
  // always clears and the next poll resumes.
172
- let outcomesTimer = null;
173
- let reportsTimer = null;
174
- let reportsRunning = false;
175
- let bptCalibrationTimer = null;
176
175
  const outcomesRunner = createOutcomesPassRunner({
177
176
  db,
178
177
  probe: () => runProbePass("poll"),
@@ -186,29 +185,31 @@ const outcomesRunner = createOutcomesPassRunner({
186
185
  function runOutcomesPass() {
187
186
  return outcomesRunner.run();
188
187
  }
188
+ // R12 bytes→token calibration — best-effort, never crash the daemon.
189
+ // Runs on boot (deferred) and weekly when: opt-in enabled AND ratio is absent
190
+ // or older than 30 days. Mirrors the outcomes bootstrap pattern.
191
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
192
+ function runBptCalibrationIfStale() {
193
+ const enabled = bptConfigGet(db, "bytes_per_token_calibration_enabled");
194
+ if (enabled !== "true")
195
+ return;
196
+ const measuredAt = bptConfigGet(db, "bytes_per_token_measured_at");
197
+ if (measuredAt !== null && Date.now() - new Date(measuredAt).getTime() < THIRTY_DAYS_MS)
198
+ return;
199
+ calibrateBytesPerToken(db).catch((e) => {
200
+ console.error(`Bytes-per-token calibration failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
201
+ });
202
+ }
189
203
  // ── Deferred boot scan (runs AFTER the HTTP port is bound) ───────────────────
190
204
  // startTailBatched() performs a yielding initial back-scan (ingestAllKnown +
191
205
  // reconcile), then arms the tail/discovery timers. We do NOT
192
206
  // also call runBackscan — that would spin up a second Ingestor whose separate
193
207
  // Health instance is the one surfaced in Settings. One Ingestor, one Health.
194
208
  // Bounded: if ingestion fails to start, still serve the dashboard (degraded).
195
- let handle = null;
196
- // One-shot guard: runBootScan executes exactly once regardless of how many
197
- // requests or timers fire kickBootScan concurrently.
198
- let scanKicked = false;
199
- function kickBootScan() {
200
- if (scanKicked)
201
- return;
202
- scanKicked = true;
203
- // Use setImmediate so the triggering HTTP response flushes before boot work
204
- // starts. The scan itself also yields between bounded batches.
205
- setImmediate(() => {
206
- runBootScan().catch((e) => {
207
- console.error(`Boot scan failed: ${e instanceof Error ? e.message : String(e)}`);
208
- });
209
- });
210
- }
209
+ // The one-shot kick guard, the daemon-owned intervals, and shutdown ordering
210
+ // live in the lifecycle harness (src/daemon/lifecycle.ts).
211
211
  async function runBootScan() {
212
+ let handle = null;
212
213
  try {
213
214
  const scanRoots = getSettingsData(db).scan_roots;
214
215
  setScanRoots(scanRoots);
@@ -261,21 +262,8 @@ async function runBootScan() {
261
262
  console.error(`Self-churn collection failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
262
263
  }
263
264
  });
264
- // R12 bytes→token calibration best-effort, never crash the daemon.
265
- // Runs on boot (deferred) and weekly when: opt-in enabled AND ratio is absent
266
- // or older than 30 days. Mirrors the outcomes bootstrap pattern.
267
- const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
268
- function runBptCalibrationIfStale() {
269
- const enabled = bptConfigGet(db, "bytes_per_token_calibration_enabled");
270
- if (enabled !== "true")
271
- return;
272
- const measuredAt = bptConfigGet(db, "bytes_per_token_measured_at");
273
- if (measuredAt !== null && Date.now() - new Date(measuredAt).getTime() < THIRTY_DAYS_MS)
274
- return;
275
- calibrateBytesPerToken(db).catch((e) => {
276
- console.error(`Bytes-per-token calibration failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
277
- });
278
- }
265
+ // Deferred boot calibration check (the weekly interval is armed by the
266
+ // lifecycle harness after this scan resolves).
279
267
  setImmediate(() => {
280
268
  try {
281
269
  runBptCalibrationIfStale();
@@ -284,42 +272,31 @@ async function runBootScan() {
284
272
  console.error(`Bytes-per-token calibration boot failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
285
273
  }
286
274
  });
287
- bptCalibrationTimer = setInterval(() => {
288
- try {
289
- runBptCalibrationIfStale();
290
- }
291
- catch (e) {
292
- console.error(`Bytes-per-token calibration poll failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
293
- }
294
- }, 7 * 24 * 60 * 60 * 1000);
295
- // 10-minute poll — guarded by outcomesRunning to prevent overlap
296
- outcomesTimer = setInterval(() => {
275
+ return handle;
276
+ }
277
+ // ── Lifecycle harness: boot-kick guard, daemon intervals, shutdown ordering ──
278
+ // closeServer closes over `server` (declared below); shutdown only ever runs
279
+ // after listen, so the reference is initialized by then.
280
+ const lifecycle = createDaemonLifecycle({
281
+ bootScan: runBootScan,
282
+ calibrationTick: runBptCalibrationIfStale,
283
+ outcomesTick: () => {
297
284
  runOutcomesPass().catch((e) => {
298
285
  console.error(`Outcomes poll failed: ${e instanceof Error ? e.message : String(e)}`);
299
286
  });
300
- }, 10 * 60 * 1000);
301
- reportsTimer = setInterval(async () => {
302
- if (reportsRunning)
303
- return;
304
- reportsRunning = true;
305
- try {
306
- await Promise.resolve(generateWeeklyReport(db, new Date()));
307
- }
308
- catch (e) {
309
- console.error(`Weekly report poll failed: ${e instanceof Error ? e.message : String(e)}`);
310
- }
311
- finally {
312
- reportsRunning = false;
313
- }
314
- }, 7 * 24 * 60 * 60 * 1000);
315
- }
287
+ },
288
+ reportsTick: () => generateWeeklyReport(db, new Date()),
289
+ closeServer: (onClosed) => server.close(onClosed),
290
+ closeDb: () => db.close(),
291
+ onClosed: () => process.exit(0),
292
+ });
316
293
  // ── 4. Start HTTP server ──────────────────────────────────────────────────────
317
294
  // Bind the port BEFORE the back-scan so the loading page is delivered
318
295
  // immediately while the initial scan yields between file batches.
319
296
  // Generate a random in-memory session token for the CSRF token gate.
320
297
  // NEVER log it, NEVER persist it — it lives only in process memory.
321
298
  const sessionToken = randomUUID();
322
- const server = createServer(db, config.port, config.uiRoot, sessionToken, kickBootScan);
299
+ const server = createServer(db, config.port, config.uiRoot, sessionToken, lifecycle.kickBootScan);
323
300
  server.listen(config.port, "127.0.0.1", () => {
324
301
  const url = `http://127.0.0.1:${config.port}`;
325
302
  console.log(`Listening on ${url}`);
@@ -332,7 +309,7 @@ server.listen(config.port, "127.0.0.1", () => {
332
309
  // Fallback: kick the back-scan after 3 s even if no browser request arrives
333
310
  // (headless mode, --no-open, CI). The one-shot guard in kickBootScan() makes
334
311
  // whichever trigger fires first the only one that runs the scan.
335
- setTimeout(kickBootScan, 3000);
312
+ lifecycle.armBootFallback();
336
313
  });
337
314
  /** Open `url` in the system default browser (best-effort; never crashes the daemon). */
338
315
  function openBrowser(url) {
@@ -355,24 +332,8 @@ function openBrowser(url) {
355
332
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
356
333
  process.on("SIGINT", () => {
357
334
  console.log("Shutting down…");
358
- handle?.stop();
359
- if (outcomesTimer !== null)
360
- clearInterval(outcomesTimer);
361
- if (reportsTimer !== null)
362
- clearInterval(reportsTimer);
363
- server.close(() => {
364
- db.close();
365
- process.exit(0);
366
- });
335
+ lifecycle.shutdown();
367
336
  });
368
337
  process.on("SIGTERM", () => {
369
- handle?.stop();
370
- if (outcomesTimer !== null)
371
- clearInterval(outcomesTimer);
372
- if (reportsTimer !== null)
373
- clearInterval(reportsTimer);
374
- server.close(() => {
375
- db.close();
376
- process.exit(0);
377
- });
338
+ lifecycle.shutdown();
378
339
  });